Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 and npm workspace packages now appear as individual build targets (e.g. `yarn@./:my-package`, `npm@./:my-package`), enabling per-package dependency scoping via `.fossa.yml`.

## 3.16.1

- Licensing: Add support for SPDX tag-value format license declarations
Expand Down
23 changes: 23 additions & 0 deletions docs/references/strategies/languages/nodejs/npm-lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ Search for files named `package.json` and check for a corresponding
> will be combined to determine which dependencies are direct and which ones are
> development.

### Workspace Build Targets

Each workspace package (including the root) is exposed as an individual build
target. For example, a monorepo named `my-monorepo` with packages `api` and
`web` will produce targets:

```
npm@./:my-monorepo
npm@./:api
npm@./:web
```

When a subset of targets is selected, only those packages' dependencies are
included in the analysis.

When no filtering is applied, all targets are selected and all dependencies
from every workspace package are included in the analysis.

> Note: Target-level dependency scoping is only supported for lockfile version 1
> (npm v5–v6) and version 2 (npm v7–v8). Version 3 lockfiles (npm v9+) will
> show workspace build targets, but filtering to specific targets does not yet
> scope the dependency results.

## Analysis (for lockFile version 3)

We consider `package-lock.json` to be version 3 compatible, if and only if,
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 package (including the root) is exposed as an individual build
target. For example, a monorepo named `my-monorepo` with packages `app`,
`lib-utils`, and `lib-core` will produce targets:

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

When a subset of targets is selected, only those packages' dependencies are
included in the analysis.

When no filtering is applied, all targets are selected and all dependencies
from every workspace package are included in the analysis.

## FAQ

Expand Down
91 changes: 75 additions & 16 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 @@ -97,15 +100,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 (BunProjectType, 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 @@ -149,33 +154,59 @@ mkProject project = do
NPM g -> (g, NpmProjectType)
Bun _ g -> (g, BunProjectType)
Pnpm _ g -> (g, PnpmProjectType)
-- Only expose build targets for project types whose getDeps actually
-- honors them. Otherwise users see per-package targets in list-targets
-- but filtering has no effect on analysis.
projectBuildTargets' = case project of
Yarn _ _ -> findWorkspaceBuildTargets graph
NPMLock _ _ -> findWorkspaceBuildTargets graph
_ -> ProjectWithoutTargets
Manifest rootManifest <- fromEitherShow $ findWorkspaceRootManifest graph
pure $
DiscoveredProject
{ projectType = typename
, projectPath = parent rootManifest
, projectBuildTargets = ProjectWithoutTargets
, projectBuildTargets = projectBuildTargets'
, projectData = project
}

-- | Build targets from workspace package names (root + members).
-- If the workspace graph has children (i.e., workspace members), each
-- package name (including the root) becomes a 'BuildTarget'. If there
-- are no workspace children (single-package project), returns
-- 'ProjectWithoutTargets'.
findWorkspaceBuildTargets :: PkgJsonGraph -> FoundTargets
findWorkspaceBuildTargets graph@PkgJsonGraph{..} =
let WorkspacePackageNames childNames = findWorkspaceNames graph
in if Set.null childNames
then ProjectWithoutTargets
else
let rootName = findWorkspaceRootManifest graph >>= \m -> maybe (Left "no name") Right (packageName =<< Map.lookup m jsonLookup)
in case rootName of
-- If the root package.json has no name field, fall back to
-- ProjectWithoutTargets so its deps aren't silently dropped.
Left _ -> ProjectWithoutTargets
Right n ->
let allNames = Set.insert n childNames
in maybe ProjectWithoutTargets FoundTargets (NonEmptySet.nonEmpty (Set.map BuildTarget allNames))

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 (Bun bunLockFile _) = analyzeBunLock bunLockFile
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 _ (Bun bunLockFile _) = analyzeBunLock bunLockFile
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 All @@ -187,12 +218,12 @@ analyzeBunLock (Manifest bunLockFile) = do
result <- BunLock.analyze bunLockFile
pure $ DependencyResults result Complete [bunLockFile]

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 @@ -216,16 +247,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 @@ -288,6 +320,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 packages whose
-- package name matches a selected target (root or workspace member).
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
Loading
Loading