Skip to content
Merged
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 and npm 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
11 changes: 11 additions & 0 deletions docs/references/strategies/languages/nodejs/npm-lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ Search for files named `package.json` and check for a corresponding
> `package-lock.json`. The other `package.json` files in the project directory
> will be combined to determine which dependencies are direct and which ones are
> development.
>
> Each workspace member is exposed as an individual build target (e.g.
> `npm@./:my-package`). You can view these with `fossa list-targets` and filter
> them in `.fossa.yml`. When no filtering is applied (or all targets are
> selected), all dependencies are included — the same as previous behavior.
> Filtering to a specific subset of targets scopes the analysis to only those
> members' dependencies.
>
> Note: Target-level dependency filtering is only supported for lockfile version
> 1. Version 3 lockfiles will show workspace build targets in `fossa list-targets`,
> but filtering to specific targets does not yet scope the dependency results.

## Analysis (for lockFile version 3)

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
```
Comment thread
jagonalez marked this conversation as resolved.

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
78 changes: 63 additions & 15 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
Comment thread
jagonalez marked this conversation as resolved.
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,39 +157,50 @@ mkProject project = do
DiscoveredProject
{ projectType = typename
, projectPath = parent rootManifest
, projectBuildTargets = ProjectWithoutTargets
, projectBuildTargets = findWorkspaceBuildTargets graph
Comment thread
jagonalez marked this conversation as resolved.
Outdated
, 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 targets (NPMLock packageLockFile graph) = analyzeNpmLock targets 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
result <- PnpmLock.analyze pnpmLockFile
pure $ DependencyResults result Complete [pnpmLockFile]

analyzeNpmLock :: (Has Diagnostics sig m, Has ReadFS sig m) => Manifest -> PkgJsonGraph -> m DependencyResults
analyzeNpmLock (Manifest npmLockFile) graph = do
analyzeNpmLock :: (Has Diagnostics sig m, Has ReadFS sig m) => FoundTargets -> Manifest -> PkgJsonGraph -> m DependencyResults
analyzeNpmLock targets (Manifest npmLockFile) graph = do
npmLockVersion <- detectNpmLockVersion npmLockFile
result <- case npmLockVersion of
NpmLockV3Compatible -> PackageLockV3.analyze npmLockFile
NpmLockV1Compatible -> PackageLock.analyze npmLockFile (extractDepLists graph) (findWorkspaceNames graph)
NpmLockV1Compatible -> PackageLock.analyze npmLockFile (extractDepListsForTargets targets graph) (findWorkspaceNames graph)
pure $ DependencyResults result Complete [npmLockFile]

analyzeNpm :: (Has Diagnostics sig m) => PkgJsonGraph -> m DependencyResults
Expand All @@ -208,16 +224,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 +297,37 @@ 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 (backward compatible).
-- When 'FoundTargets' matches all workspace members, includes all deps
-- (no filtering was applied by the user, so preserve current behavior).
-- When 'FoundTargets' is a subset, only includes deps from those members.
extractDepListsForTargets :: FoundTargets -> PkgJsonGraph -> FlatDeps
extractDepListsForTargets ProjectWithoutTargets graph = extractDepLists graph
extractDepListsForTargets (FoundTargets targets) graph@PkgJsonGraph{..}
| targetNames == allWorkspaceNames = extractDepLists graph
| otherwise = foldMap extractSingle selectedPackageJsons
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
where
targetNames :: Set Text
targetNames = Set.map unBuildTarget (NonEmptySet.toSet targets)

WorkspacePackageNames allWorkspaceNames = findWorkspaceNames graph

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
Loading
Loading