Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# FOSSA CLI Changelog

## Unreleased

- Node.js: Yarn workspace members now appear as individual build targets (e.g. `yarn@./:my-package`) instead of being merged into a single opaque target. This allows `fossa list-targets` to show each workspace member, users to filter with `.fossa.yml`, and dependency scoping per workspace member.

## 3.15.6

- Docs: Document `ALLOW_INVALID_CERTS` environment variable for TLS certificate errors ([#1639](https://github.com/fossas/fossa-cli/pull/1639))
Expand Down
18 changes: 18 additions & 0 deletions docs/references/strategies/languages/nodejs/yarn.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ the `package.json` files used to build the `yarn.lock` file are also checked,
and the knowledge of both is combined to form a (usually) complete picture of
the full graph of dependencies.

### Workspace Build Targets

Each workspace member is exposed as an individual build target. For example, a
monorepo with packages `app`, `lib-utils`, and `lib-core` will produce targets:

```
yarn@./:app
yarn@./:lib-utils
yarn@./:lib-core
```

When a subset of targets is selected, only those workspace members'
dependencies are included in the analysis. The root `package.json`'s
dependencies (typically workspace tooling like `husky` or `prettier`) are
excluded when filtering to a subset.

When no filtering is applied (or all targets are selected), all dependencies
are included — including root dependencies.

## FAQ

Expand Down
70 changes: 58 additions & 12 deletions src/Strategy/Node.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module Strategy.Node (
pkgGraph,
NodeProject (..),
getDeps,
findWorkspaceBuildTargets,
extractDepListsForTargets,
) where

import Algebra.Graph.AdjacencyMap qualified as AM
Expand Down Expand Up @@ -36,6 +38,7 @@ import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, isJust, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Set.NonEmpty qualified as NonEmptySet
import Data.String.Conversion (decodeUtf8, toString)
import Data.Tagged (applyTag)
import Data.Text (Text)
Expand Down Expand Up @@ -96,15 +99,17 @@ import Strategy.Node.Pnpm.Workspace (PnpmWorkspace (workspaceSpecs))
import Strategy.Node.YarnV1.YarnLock qualified as V1
import Strategy.Node.YarnV2.YarnLock qualified as V2
import Types (
BuildTarget (BuildTarget),
DependencyResults (DependencyResults),
DiscoveredProject (..),
DiscoveredProjectType (NpmProjectType, PnpmProjectType, YarnProjectType),
FoundTargets (ProjectWithoutTargets),
FoundTargets (FoundTargets, ProjectWithoutTargets),
GraphBreadth (Complete, Partial),
License (License),
LicenseResult (LicenseResult, licensesFound),
LicenseType (LicenseURL, UnknownType),
licenseFile,
unBuildTarget,
)

skipJsFolders :: WalkStep
Expand Down Expand Up @@ -152,27 +157,40 @@ mkProject project = do
DiscoveredProject
{ projectType = typename
, projectPath = parent rootManifest
, projectBuildTargets = ProjectWithoutTargets
, projectBuildTargets = case project of
Yarn _ _ -> findWorkspaceBuildTargets graph
_ -> ProjectWithoutTargets
, projectData = project
}

-- | Build targets from workspace member package names.
-- If the workspace graph has children (i.e., workspace members), each child's
-- package name becomes a 'BuildTarget'. If there are no workspace children
-- (single-package project), returns 'ProjectWithoutTargets'.
findWorkspaceBuildTargets :: PkgJsonGraph -> FoundTargets
findWorkspaceBuildTargets graph =
let WorkspacePackageNames names = findWorkspaceNames graph
in maybe
ProjectWithoutTargets
FoundTargets
(NonEmptySet.nonEmpty (Set.map BuildTarget names))

instance AnalyzeProject NodeProject where
analyzeProject _ = getDeps
analyzeProjectStaticOnly _ = getDeps
analyzeProject = getDeps
analyzeProjectStaticOnly = getDeps

-- Since we don't natively support workspaces, we don't attempt to preserve them from this point on.
-- In the future, if you're adding generalized workspace support, start here.
getDeps ::
( Has ReadFS sig m
, Has Diagnostics sig m
, Has Logger sig m
) =>
FoundTargets ->
NodeProject ->
m DependencyResults
getDeps (Yarn yarnLockFile graph) = analyzeYarn yarnLockFile graph
getDeps (NPMLock packageLockFile graph) = analyzeNpmLock packageLockFile graph
getDeps (Pnpm pnpmLockFile _) = analyzePnpmLock pnpmLockFile
getDeps (NPM graph) = analyzeNpm graph
getDeps targets (Yarn yarnLockFile graph) = analyzeYarn targets yarnLockFile graph
getDeps _ (NPMLock packageLockFile graph) = analyzeNpmLock packageLockFile graph
getDeps _ (Pnpm pnpmLockFile _) = analyzePnpmLock pnpmLockFile
getDeps _ (NPM graph) = analyzeNpm graph

analyzePnpmLock :: (Has Diagnostics sig m, Has ReadFS sig m, Has Logger sig m) => Manifest -> m DependencyResults
analyzePnpmLock (Manifest pnpmLockFile) = do
Expand Down Expand Up @@ -208,16 +226,17 @@ analyzeYarn ::
( Has Diagnostics sig m
, Has ReadFS sig m
) =>
FoundTargets ->
Manifest ->
PkgJsonGraph ->
m DependencyResults
analyzeYarn (Manifest yarnLockFile) pkgJsonGraph = do
analyzeYarn targets (Manifest yarnLockFile) pkgJsonGraph = do
yarnVersion <- detectYarnVersion yarnLockFile
let analyzeFunc = case yarnVersion of
V1 -> V1.analyze
V2Compatible -> V2.analyze

graph <- analyzeFunc yarnLockFile $ extractDepLists pkgJsonGraph
graph <- analyzeFunc yarnLockFile $ extractDepListsForTargets targets pkgJsonGraph
pure . DependencyResults graph Complete $ yarnLockFile : pkgFileList pkgJsonGraph

detectYarnVersion ::
Expand Down Expand Up @@ -280,6 +299,33 @@ extractDepLists PkgJsonGraph{..} = foldMap extractSingle $ Map.elems jsonLookup
(applyTag @Development $ mapToSet packageDevDeps)
(Map.keysSet jsonLookup)

-- | Like 'extractDepLists', but scoped to the selected workspace targets.
-- When 'ProjectWithoutTargets', includes all deps.
-- When 'FoundTargets', only includes deps from workspace members whose
-- package name matches a selected target (excluding the root's deps).
extractDepListsForTargets :: FoundTargets -> PkgJsonGraph -> FlatDeps
extractDepListsForTargets ProjectWithoutTargets graph = extractDepLists graph
extractDepListsForTargets (FoundTargets targets) PkgJsonGraph{..} =
foldMap extractSingle selectedPackageJsons
where
targetNames :: Set Text
targetNames = Set.map unBuildTarget (NonEmptySet.toSet targets)

selectedPackageJsons :: [PackageJson]
selectedPackageJsons =
filter (maybe False (`Set.member` targetNames) . packageName) $
Map.elems jsonLookup

mapToSet :: Map Text Text -> Set NodePackage
mapToSet = Set.fromList . map (uncurry NodePackage) . Map.toList

extractSingle :: PackageJson -> FlatDeps
extractSingle PackageJson{..} =
FlatDeps
(applyTag @Production $ mapToSet (packageDeps `Map.union` packagePeerDeps))
(applyTag @Development $ mapToSet packageDevDeps)
(Map.keysSet jsonLookup)

loadPackage :: (Has Logger sig m, Has ReadFS sig m, Has Diagnostics sig m) => Manifest -> m (Maybe (Manifest, PackageJson))
loadPackage (Manifest file) = do
result <- recover $ readContentsJson @PackageJson file
Expand Down
139 changes: 135 additions & 4 deletions test/Node/NodeSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import Algebra.Graph.AdjacencyMap qualified as AM
import Data.Foldable (for_)
import Data.Glob (unsafeGlobRel)
import Data.Map qualified as Map
import Data.Set qualified as Set
import Data.Set.NonEmpty (nonEmpty)
import Data.Tagged (applyTag)
import Graphing qualified
import Path (Abs, Dir, Path, mkRelDir, mkRelFile, (</>))
import Path.IO (getCurrentDir)
import Strategy.Node (NodeProject (NPMLock), discover, getDeps)
import Strategy.Node (NodeProject (NPMLock), discover, extractDepListsForTargets, findWorkspaceBuildTargets, getDeps)
import Strategy.Node.PackageJson (
FlatDeps (..),
Manifest (..),
NodePackage (NodePackage),
PackageJson (
PackageJson,
packageDeps,
Expand All @@ -27,10 +32,12 @@ import Strategy.Node.PackageJson (
PkgJsonGraph (PkgJsonGraph, jsonGraph, jsonLookup),
PkgJsonLicense (LicenseText),
PkgJsonWorkspaces (PkgJsonWorkspaces, unWorkspaces),
Production,
)
import Test.Effect (it', shouldBe')
import Test.Hspec (Spec, describe, runIO)
import Test.Hspec (Spec, describe, it, runIO, shouldBe)
import Types (
BuildTarget (BuildTarget),
DependencyResults (
DependencyResults,
dependencyGraph,
Expand All @@ -39,7 +46,7 @@ import Types (
),
DiscoveredProject (DiscoveredProject, projectBuildTargets, projectData, projectPath, projectType),
DiscoveredProjectType (NpmProjectType),
FoundTargets (ProjectWithoutTargets),
FoundTargets (FoundTargets, ProjectWithoutTargets),
GraphBreadth (Complete),
)

Expand All @@ -48,6 +55,8 @@ spec = do
currDir <- runIO getCurrentDir
pkgJsonWorkspaceSpec currDir
npmLockAnalysisSpec currDir
workspaceBuildTargetsSpec currDir
extractDepListsForTargetsSpec currDir

discoveredWorkSpaceProj :: Path Abs Dir -> DiscoveredProject NodeProject
discoveredWorkSpaceProj currDir =
Expand Down Expand Up @@ -154,5 +163,127 @@ npmLockAnalysisSpec currDir = do
for_ discoveredProjects $
\DiscoveredProject{..} ->
do
depGraph <- getDeps projectData
depGraph <- getDeps projectBuildTargets projectData
depGraph `shouldBe'` discoveredWorkSpaceProjDeps currDir

workspaceBuildTargetsSpec :: Path Abs Dir -> Spec
workspaceBuildTargetsSpec currDir = describe "findWorkspaceBuildTargets" $ do
it "returns FoundTargets with workspace member names" $ do
let graph = workspaceGraphWithDeps currDir
targets = findWorkspaceBuildTargets graph
expected =
maybe ProjectWithoutTargets FoundTargets . nonEmpty $
Set.fromList [BuildTarget "pkg-a", BuildTarget "pkg-b"]
targets `shouldBe` expected

it "returns ProjectWithoutTargets for single-package project" $ do
let singleManifest = currDir </> $(mkRelFile "test/Node/testdata/workspace-test/package.json")
graph =
PkgJsonGraph
{ jsonGraph = AM.vertex (Manifest singleManifest)
, jsonLookup =
Map.fromList
[
( Manifest singleManifest
, emptyPackageJson{packageName = Just "my-app"}
)
]
}
findWorkspaceBuildTargets graph `shouldBe` ProjectWithoutTargets

extractDepListsForTargetsSpec :: Path Abs Dir -> Spec
extractDepListsForTargetsSpec currDir = describe "extractDepListsForTargets" $ do
let graph = workspaceGraphWithDeps currDir

it "includes all deps when ProjectWithoutTargets" $ do
let result = extractDepListsForTargets ProjectWithoutTargets graph
-- Should include deps from root, pkg-a, and pkg-b
let expectedDirect =
Set.fromList
[ NodePackage "lodash" "^4.0.0"
, NodePackage "express" "^4.0.0"
, NodePackage "husky" "^8.0.0"
]
directDeps result `shouldBe` applyTag @Production expectedDirect

it "scopes deps to selected targets only" $ do
let targets =
maybe ProjectWithoutTargets FoundTargets . nonEmpty $
Set.fromList [BuildTarget "pkg-a"]
result = extractDepListsForTargets targets graph
-- Should include only pkg-a's deps, not root or pkg-b
let expectedDirect = Set.fromList [NodePackage "lodash" "^4.0.0"]
directDeps result `shouldBe` applyTag @Production expectedDirect

it "excludes root deps when all targets selected" $ do
-- Yarn root package.json deps are workspace tooling (husky, prettier, etc.)
-- and should not be included when workspace targets are selected.
let targets =
maybe ProjectWithoutTargets FoundTargets . nonEmpty $
Set.fromList [BuildTarget "pkg-a", BuildTarget "pkg-b"]
result = extractDepListsForTargets targets graph
let expectedDirect =
Set.fromList
[ NodePackage "lodash" "^4.0.0"
, NodePackage "express" "^4.0.0"
]
directDeps result `shouldBe` applyTag @Production expectedDirect

-- | A workspace graph with actual dependencies for testing extractDepListsForTargets.
workspaceGraphWithDeps :: Path Abs Dir -> PkgJsonGraph
workspaceGraphWithDeps currDir =
PkgJsonGraph
{ jsonGraph =
AM.edges
[ (Manifest rootManifest, Manifest pkgAManifest)
, (Manifest rootManifest, Manifest pkgBManifest)
]
, jsonLookup =
Map.fromList
[
( Manifest rootManifest
, emptyPackageJson
{ packageName = Just "workspace-test"
, packageDeps = Map.fromList [("husky", "^8.0.0")]
, packageWorkspaces =
PkgJsonWorkspaces
{ unWorkspaces =
[ unsafeGlobRel "pkg-a"
, unsafeGlobRel "nested/pkg-b"
]
}
}
)
,
( Manifest pkgAManifest
, emptyPackageJson
{ packageName = Just "pkg-a"
, packageDeps = Map.fromList [("lodash", "^4.0.0")]
}
)
,
( Manifest pkgBManifest
, emptyPackageJson
{ packageName = Just "pkg-b"
, packageDeps = Map.fromList [("express", "^4.0.0")]
}
)
]
}
where
rootManifest = currDir </> $(mkRelFile "test/Node/testdata/workspace-test/package.json")
pkgAManifest = currDir </> $(mkRelFile "test/Node/testdata/workspace-test/pkg-a/package.json")
pkgBManifest = currDir </> $(mkRelFile "test/Node/testdata/workspace-test/nested/pkg-b/package.json")

emptyPackageJson :: PackageJson
emptyPackageJson =
PackageJson
{ packageName = Nothing
, packageVersion = Nothing
, packageWorkspaces = PkgJsonWorkspaces{unWorkspaces = []}
, packageDeps = Map.empty
, packageDevDeps = Map.empty
, packageLicense = Nothing
, packageLicenses = Nothing
, packagePeerDeps = Map.empty
}
Loading