Skip to content
Draft
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
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Pnpm: workspace members are now individual build targets, so a single member can be analyzed on its own with `--only-target 'pnpm@./:my-package'` or a `target:` entry in `.fossa.yml`. Previously every member's dependencies were merged into one result with no way to scope them. Dependencies a selected member reaches through the workspace protocol (`link:` in the lockfile) are included. With no target or path filter the result is unchanged. ([#1763](https://github.com/fossas/fossa-cli/pull/1763))
- Node: when a yarn, npm, or pnpm workspace root has no `name` in its `package.json`, discovery now warns that no build targets can be offered for the workspace and that adding a `name` enables them. Previously the targets were withheld silently. ([#1763](https://github.com/fossas/fossa-cli/pull/1763))
- Diagnostics: When an error or warning group contains multiple errors, each error's `Traceback:` header is now printed on its own line instead of being glued onto the last line of the preceding error message (e.g. `...none passed validationTraceback:`). ([#1758](https://github.com/fossas/fossa-cli/pull/1758))

## 3.18.2
Expand Down
5 changes: 5 additions & 0 deletions docs/references/strategies/languages/nodejs/npm-lockfile.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ npm@./:web
When a subset of targets is selected, only those packages' dependencies are
included in the analysis.

The workspace root's `package.json` needs a `name` field too. Without one, no
build targets are offered: `fossa list-targets` shows a bare `npm@./`, the
whole workspace is analyzed as a single unit, and a warning explains that adding
a `name` to the root `package.json` enables per-package targets.

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

Expand Down
62 changes: 61 additions & 1 deletion docs/references/strategies/languages/nodejs/pnpm.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ in `pnpm-lock.yaml` to analyze the dependency graph.

> 📘 Important Note
>
> Anything defined in the `importers` section will be ignored. In order to scan individual targts, the workspace needs to have individual/separate lock files.
> The `importers` section is the source of direct dependencies. By default every
> importer's dependencies are merged into one result for the whole workspace. To
> report on a single workspace member, select it as a build target — see
> [Workspace Build Targets](#workspace-build-targets) below.

An example is provided below:

Expand Down Expand Up @@ -157,6 +160,63 @@ CLI will infer the package name and version using `/${dependencyName}/${dependen
* Optional dependencies are included in the analysis by default. They can be ignored in FOSSA UI.
* `fossa-cli` supports lockFileVersion: 4.x, 5.x, 6.x, 7.x, 8.x, and 9.x.

### Workspace Build Targets

Each workspace member, and the workspace root, is exposed as an individual build
target. A workspace named `my-workspace` whose `pnpm-workspace.yaml` lists
`browser` and `server` produces:

```
pnpm@./:my-workspace
pnpm@./:browser
pnpm@./:server
```

A target's name is that package's `name` from its `package.json`, so a member
named `@acme/browser` is selected as `pnpm@./:@acme/browser`. Run
`fossa list-targets` to see the exact names.

The workspace root needs a `name` too. Without one, no build targets are
offered: `fossa list-targets` shows a bare `pnpm@./`, the whole workspace is
analyzed as a single unit, and a warning explains that adding a `name` to the
root `package.json` enables per-member targets. A root that is `private: true`
can carry any name; it is never published.

Selecting a subset reports only those members' dependencies:

```bash
fossa analyze --only-target 'pnpm@./:browser'
```

The same selection in `.fossa.yml`:

```yaml
version: 3
targets:
only:
- type: pnpm
path: ./
target: browser
```

When a selected member depends on a sibling member through the
[workspace protocol](https://pnpm.io/workspaces#workspace-protocol), pnpm records
that in the lockfile as `version: link:<path>`. The sibling's own dependencies
are part of the selected member's result, because the selected member depends on
them.

With no target filtering, all targets are selected and every member's
dependencies are included.

> 📘 Note
>
> A pnpm workspace is one FOSSA project rooted at the workspace root, and stays
> one project no matter which targets are selected — build targets scope what
> that project reports rather than splitting it into several projects. Running
> `fossa analyze` from inside a member's directory is not a way to scope a scan:
> without the lockfile in scope, analysis falls back to the
> [package.json strategy](packagejson.md) and its partial graph.

### Catalogs

pnpm [catalogs](https://pnpm.io/catalogs) (introduced in pnpm 9.5) are supported.
Expand Down
5 changes: 5 additions & 0 deletions docs/references/strategies/languages/nodejs/yarn.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ yarn@./:lib-core
When a subset of targets is selected, only those packages' dependencies are
included in the analysis.

The workspace root's `package.json` needs a `name` field too. Without one, no
build targets are offered: `fossa list-targets` shows a bare `yarn@./`, the
whole workspace is analyzed as a single unit, and a warning explains that adding
a `name` to the root `package.json` enables per-package targets.

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

Expand Down
105 changes: 105 additions & 0 deletions integration-test/Analysis/PnpmWorkspaceSpec.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
{-# LANGUAGE TemplateHaskell #-}

-- | End-to-end coverage for target-level dependency scoping of pnpm workspaces:
-- discovery over a vendored workspace fixture, then analysis per selected build
-- target.
module Analysis.PnpmWorkspaceSpec (spec) where

import Analysis.FixtureUtils (FixtureEnvironment (LocalEnvironment), testRunner, withResult)
import App.Fossa.Analyze.Types (AnalyzeProject (analyzeProject))
import App.Types (Mode (NonStrict))
import Control.Carrier.Debug (ignoreDebug)
import Control.Carrier.Reader (runReader)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Set.NonEmpty qualified as NonEmptySet
import Data.Text (Text)
import DepTypes (Dependency (dependencyName))
import Graphing (Graphing)
import Graphing qualified
import Path (Dir, Path, Rel, mkRelDir, (</>))
import Path.IO qualified as PIO
import Test.Hspec (Spec, beforeAll, describe, it, shouldBe, shouldSatisfy)
import Types (
BuildTarget (BuildTarget),
DependencyResults (dependencyGraph),
DiscoveredProject (projectBuildTargets, projectData, projectType),
DiscoveredProjectType (PnpmProjectType),
FoundTargets (FoundTargets, ProjectWithoutTargets),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

import Strategy.Node qualified as Node

fixtureDir :: Path Rel Dir
fixtureDir = $(mkRelDir "test/Node/testdata/pnpm-workspaces/")

-- | Every package in the fixture, the root included, is named in its
-- package.json. A workspace root without a @name@ yields no build targets.
allTargetNames :: [Text]
allTargetNames =
[ "@fossa-test/workspace"
, "@fossa-test/browser"
, "@fossa-test/server"
, "@fossa-test/shared"
]

data FixtureGraphs = FixtureGraphs
{ discoveredTargets :: FoundTargets
, wholeGraph :: Graphing Dependency
, rootGraph :: Graphing Dependency
, browserGraph :: Graphing Dependency
, serverGraph :: Graphing Dependency
, sharedGraph :: Graphing Dependency
}

mkTargets :: [Text] -> FoundTargets
mkTargets = maybe ProjectWithoutTargets FoundTargets . NonEmptySet.nonEmpty . Set.fromList . map BuildTarget

depNames :: Graphing Dependency -> Set Text
depNames = Set.fromList . map dependencyName . Graphing.vertexList

analyzeFixture :: IO FixtureGraphs
analyzeFixture = do
currentDir <- PIO.getCurrentDir
let scanDir = currentDir </> fixtureDir
discovered <- testRunner (Node.discover scanDir) LocalEnvironment
withResult discovered $ \_ projects -> case projects of
[project] -> do
projectType project `shouldBe` PnpmProjectType
let analyzeWith targets = do
analyzed <- testRunner (ignoreDebug $ runReader NonStrict $ analyzeProject targets (projectData project)) LocalEnvironment
withResult analyzed $ \_ depResults -> pure (dependencyGraph depResults)
FixtureGraphs (projectBuildTargets project)
<$> analyzeWith (projectBuildTargets project)
<*> analyzeWith (mkTargets ["@fossa-test/workspace"])
<*> analyzeWith (mkTargets ["@fossa-test/browser"])
<*> analyzeWith (mkTargets ["@fossa-test/server"])
<*> analyzeWith (mkTargets ["@fossa-test/shared"])
projects' -> fail ("expected exactly one discovered project, got " <> show (length projects'))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

spec :: Spec
spec = beforeAll analyzeFixture $
describe "pnpm workspace" $ do
it "should expose the root and each workspace member as build targets" $ \fixture ->
discoveredTargets fixture `shouldBe` mkTargets allTargetNames

it "should report only the selected member's dependencies" $ \fixture -> do
-- left-pad reaches browser through a catalog: specifier; is-odd belongs
-- only to server, and colorjs only to the root.
depNames (browserGraph fixture) `shouldSatisfy` Set.member "left-pad"
depNames (browserGraph fixture) `shouldSatisfy` (\names -> not (any (`Set.member` names) ["is-odd", "is-number", "colorjs"]))

depNames (serverGraph fixture) `shouldBe` Set.fromList ["is-odd", "is-number"]
depNames (rootGraph fixture) `shouldBe` Set.fromList ["colorjs"]

it "should follow a workspace link into the sibling it names" $ \fixture -> do
-- browser depends on the shared member via `version: link:../shared`.
-- Its dependencies, and their transitives, belong in browser's result;
-- the workspace package itself is not a reportable dependency.
depNames (browserGraph fixture) `shouldBe` Set.fromList ["left-pad", "uri-js", "punycode"]
depNames (sharedGraph fixture) `shouldBe` Set.fromList ["uri-js", "punycode"]

it "should analyze the whole workspace when all targets are selected" $ \fixture -> do
depNames (wholeGraph fixture) `shouldBe` Set.fromList ["colorjs", "left-pad", "is-odd", "is-number", "uri-js", "punycode"]
[rootGraph fixture, browserGraph fixture, serverGraph fixture, sharedGraph fixture]
`shouldSatisfy` all ((`Set.isSubsetOf` depNames (wholeGraph fixture)) . depNames)
1 change: 1 addition & 0 deletions spectrometer.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,7 @@ test-suite integration-tests
Analysis.NpmLockV3WorkspaceSpec
Analysis.NugetSpec
Analysis.PnpmSpec
Analysis.PnpmWorkspaceSpec
Analysis.Python.PipenvSpec
Analysis.Python.PoetrySpec
Analysis.Python.SetuptoolsSpec
Expand Down
Loading
Loading