Skip to content

feat(sbom): add --exclude-peers to omit peer dependencies#12443

Merged
zkochan merged 6 commits into
pnpm:mainfrom
Saturate:feat/sbom-exclude-peers
Jun 20, 2026
Merged

feat(sbom): add --exclude-peers to omit peer dependencies#12443
zkochan merged 6 commits into
pnpm:mainfrom
Saturate:feat/sbom-exclude-peers

Conversation

@Saturate

@Saturate Saturate commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

With auto-install-peers (default since v8), peer dependencies resolve into
the lockfile indistinguishably from a package's own deps, so pnpm sbom lists
them as the package's components. For a published-library SBOM that's wrong —
peers are supplied by the consumer. --exclude-peers drops them, plus any
transitive subtree reachable only through them.

peerDependencies come from the manifest (the lockfile carries no marker). The
collector filters those names at each importer's top level, so a peer's
exclusive subtree is never walked while a package also reached via a real dep
stays. With no --filter, every importer is walked, so each importer's own
manifest is resolved (via safeReadProjectManifestOnly, which tolerates a
missing manifest) and peers in workspace sub-packages are dropped too.

The flag name matches pnpm list --exclude-peers; the SBOM behavior is
stricter, pruning the exclusive subtree rather than only hiding leaf peers.
CycloneDX 1.7 has no scope or relationship for "consumer-provided peer", so
omission is the only spec-clean handling.

Known limitation: an aliased peer ("x": "npm:real@1" in peerDependencies)
is not excluded, since matching is by resolved package name. Aliased peer deps
are vanishingly rare in practice.

Tests

Single-project (peer + exclusive subtree dropped, absent from dependsOn,
real deps kept), workspace (peer declared in a sub-package dropped when run at
the root with no filter), and default-includes-peers.


Written by an agent (Claude Code, claude-fable-5).

Summary by CodeRabbit

  • New Features

    • Added a --exclude-peers option to pnpm sbom to omit peer dependencies (and any subtrees reachable only through them) from the generated SBOM.
  • Bug Fixes

    • Improved lockfile-only SBOM generation by pruning peer-only dependency trees and removing dropped peer references from the root dependency graph.
    • Ensures peer packages declared within workspace sub-packages are also excluded when --exclude-peers is enabled.
  • Tests

    • Added test coverage for peer dependency exclusion, including workspaces and additional peer fixtures.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a --exclude-peers flag to the pnpm sbom command. When set, the handler computes a per-importer map of peer dependency names (from manifests or the projects graph) and passes it to collectSbomComponents, which filters peer entries out of each importer's root dependency step before walking the SBOM graph.

Changes

SBOM peer-dependency exclusion

Layer / File(s) Summary
SBOM collector peer-pruning interface and filtering
deps/compliance/sbom/src/collectComponents.ts
Adds optional excludePeerNamesByImporter to CollectSbomComponentsOptions and filters each importer's root-step dependencies against that map before walking, pruning peer-exclusive subtrees.
CLI option, handler logic, and peerNamesFromManifest helper
deps/compliance/commands/src/sbom/sbom.ts, deps/compliance/commands/package.json, .changeset/sbom-exclude-peers.md
Adds excludePeers?: boolean to SbomCommandOptions, registers --exclude-peers in cliOptionsTypes, documents it in help(), builds excludePeerNamesByImporter in handler() from the projects graph or by reading lockfile importer manifests with safe canonicalized path resolution and bounded parallelism, and passes lockfileDir plus the mapping to collectSbomComponents. Introduces peerNamesFromManifest helper. Adds p-limit runtime dependency. Changeset records the minor version bump.
Test fixtures and peer-exclusion test cases
deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/package.json, deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/*, deps/compliance/commands/test/sbom/index.ts
Adds with-peer-dependency and with-peer-workspace fixture packages (including pkg-a workspace member and pnpm-workspace.yaml). Adds three Jest tests: peers included by default, --exclude-peers removes peer packages and their exclusive subtrees (including dependsOn references), and workspace-declared peers are also excluded.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • zkochan

Poem

🐇 Hop hop, the SBOM grows neat and trim,
Peers once lurking on a transitive whim—
--exclude-peers waves them all goodbye,
Only true deps remain, standing high.
The lockfile knows what really belongs! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(sbom): add --exclude-peers to omit peer dependencies' clearly and directly summarizes the main change—adding a new CLI flag to the pnpm sbom command with specific functionality for excluding peer dependencies from SBOM output.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Saturate Saturate marked this pull request as ready for review June 16, 2026 08:27
@Saturate Saturate requested a review from zkochan as a code owner June 16, 2026 08:27
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (9) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Peers leak via workspace links 🐞 Bug ≡ Correctness
Description
With --exclude-peers and --filter (selectedProjectsGraph present), buildSharedContext()
records peer names only for the selected importers, but collectSbomComponents() also walks extra
workspace importers reachable via link: dependencies. Peer dependencies declared in those
additional workspace packages will still be included in the SBOM, partially defeating
--exclude-peers.
Code

deps/compliance/commands/src/sbom/sbom.ts[R339-342]

+    if (opts.selectedProjectsGraph) {
+      for (const [projectDir, { package: project }] of Object.entries(opts.selectedProjectsGraph)) {
+        byImporter.set(getLockfileImporterId(lockfileDir, projectDir), peerNamesFromManifest(project.manifest))
+      }
Evidence
Peer-name collection is limited to selectedProjectsGraph, but SBOM traversal can include
additional importer IDs from resolveWorkspaceDeps.additionalImporterIds, and peer filtering is
keyed by importer ID; missing entries mean peers won’t be filtered for those walked importers.

deps/compliance/commands/src/sbom/sbom.ts[336-343]
deps/compliance/commands/src/sbom/sbom.ts[455-468]
deps/compliance/sbom/src/collectComponents.ts[63-75]
deps/compliance/sbom/src/collectComponents.ts[249-301]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When `opts.excludePeers` is enabled and `opts.selectedProjectsGraph` is set, `excludePeerNamesByImporter` is populated only for the selected projects. However, SBOM generation may also walk additional importer IDs (workspace packages) discovered via `resolveWorkspaceDeps()` (i.e. `link:` dependencies). Those additional importers will not have their peer dependencies excluded.
## Issue Context
This shows up in workspaces when generating an SBOM for a filtered subset that depends on other workspace packages; those extra workspace packages are still included in the SBOM graph, but their own `peerDependencies` are not filtered.
## Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[327-385]
- deps/compliance/commands/src/sbom/sbom.ts[455-489]
### Suggested approach
- When `opts.excludePeers` is true and workspace graphs are available, populate `excludePeerNamesByImporter` from *all* known workspace manifests (e.g. `allProjectsGraph` as well as `selectedProjectsGraph`), not only the selected set.
- You already build `workspaceManifestsByImporterId` from both graphs; reuse it to compute peer name sets without extra filesystem reads.
- Alternatively (or additionally), in `generateSbomForProject()`, after `resolvedWorkspaceDeps` is computed, ensure every `additionalImporterId` has a peer-name entry (from `ctx.workspaceManifestsByImporterId`) before calling `collectSbomComponents()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Peer pruning hides real deps 🐞 Bug ≡ Correctness
Description
collectSbomComponents() filters peer dependencies from an importer's root step after
lockfileWalkerGroupImporterSteps() has already added those depPaths to a shared cross-importer
"walked" set. If an excluded peer is the first importer to mention a depPath that another importer
includes as a real dependency, the later importer will skip it and the SBOM can omit that real
dependency entirely.
Code

deps/compliance/sbom/src/collectComponents.ts[R66-80]

+    importerWalkers.map(async ({ importerId, step }) => {
+      // Dropping a peer's direct entry here (rather than from the walker) prunes
+      // its exclusive transitive subtree for free: packages reachable only
+      // through the peer are never walked, while packages shared with a real
+      // dependency are still reached via that path.
+      const peerNames = opts.excludePeerNamesByImporter?.get(importerId)
+      const rootStep = (peerNames?.size)
+        ? {
+          ...step,
+          dependencies: step.dependencies.filter((dep) => {
+            const { name } = nameVerFromPkgSnapshot(dep.depPath, dep.pkgSnapshot)
+            return !name || !peerNames.has(name)
+          }),
+        }
+        : step
Evidence
The SBOM code filters peers after the importer steps are created, but the lockfile walker’s step()
adds each direct depPath to a shared walked Set across all importers. Therefore, when a peer dep
is filtered out, its depPath may already be in walked, preventing other importers from ever
emitting/walking it even when it is a non-peer dependency for them.

deps/compliance/sbom/src/collectComponents.ts[39-90]
lockfile/walker/src/index.ts[17-45]
lockfile/walker/src/index.ts[91-122]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`collectSbomComponents()` prunes peer deps by filtering `step.dependencies` *after* `lockfileWalkerGroupImporterSteps()` has already constructed steps using a shared `walked` set across importers. Because `step()` marks direct depPaths as walked up-front, a pruned peer depPath may be suppressed for later importers even if it is a real dependency there, causing missing components/relationships in the SBOM.
### Issue Context
- `lockfileWalkerGroupImporterSteps()` shares a single `walked` Set across all importer steps and calls `step()` immediately for each importer, which adds each direct depPath to `walked`.
- The new peer pruning in SBOM happens later by filtering `step.dependencies`.
### Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[39-91]
- lockfile/walker/src/index.ts[17-45]
- lockfile/walker/src/index.ts[91-122]
### Implementation direction
Choose one approach that ensures excluded peers do **not** get marked as walked globally before a non-peer path can include them:
1. **Per-importer walker state (SBOM-local):** when `excludePeerNamesByImporter` is present, build walker steps per importer with an isolated `walked` set (e.g., invoke the walker per importer rather than using the grouped walker that shares `walked`). Deduplicate components via `componentsMap` as today.
2. **Filter before walking:** compute the root entry depPaths to walk per importer (from the importer snapshot’s dependency refs) and exclude peers there (so the walker never sees/marks those depPaths).
3. **Walker enhancement:** extend `lockfileWalkerGroupImporterSteps()` to accept a per-importer “skip direct depPaths” set and ensure skipped depPaths are not added to `walked`.
Add a regression test with at least 2 importers where importer A lists package X as a peer (thus excluded) and importer B lists X as a normal dependency; verify X remains in SBOM when `--exclude-peers` is enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Peer+dev not excluded 🐞 Bug ≡ Correctness
Description
peerNamesFromManifest() treats devDependencies as “regular”, so a dep listed in both
peerDependencies and devDependencies won’t be excluded even in --prod mode (where dev deps are
not included), causing --exclude-peers to keep common consumer-provided peers.
Code

deps/compliance/commands/src/sbom/sbom.ts[R278-288]

+function peerNamesFromManifest (manifest: ProjectManifest): Set<string> {
+  // A name declared as both a peer and a regular dependency is a real dependency
+  // the package pulls in itself, so keep it.
+  const regular = new Set([
+    ...Object.keys(manifest.dependencies ?? {}),
+    ...Object.keys(manifest.devDependencies ?? {}),
+    ...Object.keys(manifest.optionalDependencies ?? {}),
+  ])
+  return new Set(
+    Object.keys(manifest.peerDependencies ?? {}).filter((name) => !regular.has(name))
+  )
Evidence
The peer-exclusion set is computed by subtracting any peer name also present in dependencies,
devDependencies, or optionalDependencies, which means peers duplicated into devDeps are not
excluded. The repo contains fixtures where the same package is listed as both a peer and a dev
dependency, demonstrating this is a real and common pattern that will break --exclude-peers
especially under --prod.

deps/compliance/commands/src/sbom/sbom.ts[278-288]
pnpm/test/fixtures/workspace-with-circular-peers/modules/module5/package.json[4-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`peerNamesFromManifest()` currently considers `devDependencies` and `optionalDependencies` as “regular” unconditionally. In `--prod` (or `--no-optional`) runs, this prevents excluding peers that are duplicated into those non-included fields (a common pattern is `peerDependencies` + `devDependencies` for local testing).
### Issue Context
`--exclude-peers` is meant to omit consumer-provided peer deps from SBOM output. However, peers duplicated into `devDependencies` should only be treated as “regular” when dev deps are actually included in the SBOM run.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[175-178]
- deps/compliance/commands/src/sbom/sbom.ts[210-233]
- deps/compliance/commands/src/sbom/sbom.ts[278-289]
### Suggested fix
- Change `peerNamesFromManifest(manifest)` to accept the computed `include` flags.
- Build the `regular` set from only the dependency fields that are included (e.g., always `dependencies` if included; `devDependencies` only if `include.devDependencies` is true; `optionalDependencies` only if `include.optionalDependencies` is true).
- Update all call sites to pass `include` so `--prod` correctly excludes peer+dev duplicates, while default runs that include dev deps keep them as real dev deps.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Redundant manifest scan 🐞 Bug ➹ Performance
Description
With --exclude-peers enabled and selectedProjectsGraph unset, buildSharedContext() scans every
lockfile importer via realpath() + safeReadProjectManifestOnly(), even when allProjectsGraph
is available and the command may later only walk a subset (notably in --split mode). This adds
avoidable filesystem I/O and latency to pnpm sbom --exclude-peers on large workspaces.
Code

deps/compliance/commands/src/sbom/sbom.ts[R327-383]

+  const lockfileDir = opts.lockfileDir ?? opts.dir
+
+  // peerDependencies are identified from the manifest, not the lockfile: with
+  // auto-install-peers they resolve into the importer's `dependencies` with no
+  // distinguishing marker. Map every walked importer to its peer names so the
+  // collector can drop them.
+  // Keyed by a Map, not a plain object: importer ids come from the lockfile
+  // (attacker-controlled in an untrusted clone), and a key like `__proto__`
+  // would corrupt a plain object's prototype.
+  let excludePeerNamesByImporter: Map<string, Set<string>> | undefined
+  if (opts.excludePeers) {
+    const byImporter = new Map<string, Set<string>>()
+    if (opts.selectedProjectsGraph) {
+      for (const [projectDir, { package: project }] of Object.entries(opts.selectedProjectsGraph)) {
+        byImporter.set(getLockfileImporterId(lockfileDir, projectDir), peerNamesFromManifest(project.manifest))
+      }
+    } else {
+      // No project graph was selected, so collectSbomComponents walks every
+      // importer in the lockfile. Resolve each importer's own manifest so peers
+      // in workspace packages are dropped too, not only those in the directory
+      // pnpm ran in. safeReadProjectManifestOnly returns null (rather than
+      // throwing) for an importer whose manifest is gone (e.g. a stale lockfile),
+      // and skips the installability check that would otherwise abort the SBOM.
+      const lockfileRoot = await realpath(lockfileDir)
+      // Bound the fan-out: a large workspace can have many importers, and
+      // reading every manifest at once would spike open file descriptors.
+      const limitManifestReads = pLimit(16)
+      await Promise.all(
+        Object.keys(lockfile.importers).map((importerId) => limitManifestReads(async () => {
+          // A crafted lockfile could carry an importer key that escapes the
+          // project, via `..` segments or a symlinked directory. Canonicalize
+          // with realpath and skip anything resolving outside the project root,
+          // so a manifest is never read from outside the tree.
+          let importerDir: string
+          try {
+            importerDir = await realpath(path.resolve(lockfileDir, importerId))
+          } catch {
+            return
+          }
+          const rel = path.relative(lockfileRoot, importerDir)
+          if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) return
+          // safeReadProjectManifestOnly tolerates a missing manifest but still
+          // throws on a malformed one (parse error). In an untrusted clone a
+          // single junk package.json must not abort the whole SBOM, so skip it
+          // (fail-open: an unparseable importer's peers just aren't filtered).
+          let importerManifest: ProjectManifest | null
+          try {
+            importerManifest = await safeReadProjectManifestOnly(importerDir)
+          } catch {
+            return
+          }
+          if (importerManifest) {
+            byImporter.set(importerId, peerNamesFromManifest(importerManifest))
+          }
+        }))
+      )
+    }
Evidence
handler() builds shared context before deciding split/non-split, so the new per-importer manifest
scan runs even when the command will later emit per-project SBOMs. The scan is triggered solely by
the absence of selectedProjectsGraph, even though allProjectsGraph is available and already used
to populate workspace manifests, making the additional filesystem work avoidable in common workspace
runs.

deps/compliance/commands/src/sbom/sbom.ts[187-197]
deps/compliance/commands/src/sbom/sbom.ts[327-383]
deps/compliance/commands/src/sbom/sbom.ts[396-402]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`buildSharedContext()` currently computes `excludePeerNamesByImporter` by reading manifests from disk for every `lockfile.importers` entry whenever `opts.selectedProjectsGraph` is not set. In workspaces, pnpm often already provides `opts.allProjectsGraph` (and the command already uses it to populate `workspaceManifestsByImporterId`), so the extra per-importer `realpath()` + manifest read is redundant and can be expensive. This cost is paid even when `handler()` later goes into `--split` and generates per-project SBOMs.
### Issue Context
- The expensive path is gated by `opts.excludePeers` and `!opts.selectedProjectsGraph`.
- `handler()` calls `buildSharedContext()` before deciding `shouldSplit`, so this scan happens even for split output.
### Fix Focus Areas
- Prefer deriving peer names from `opts.selectedProjectsGraph ?? opts.allProjectsGraph` when available (using the already-loaded `entry.package.manifest`).
- If you still need to handle stale lockfile importers not present in the graph, consider scanning *only* missing importerIds after seeding from the graph.
- Optionally defer any filesystem scanning until you know the set of importers that will actually be walked (especially in split mode).
- deps/compliance/commands/src/sbom/sbom.ts[187-197]
- deps/compliance/commands/src/sbom/sbom.ts[327-383]
- deps/compliance/commands/src/sbom/sbom.ts[396-402]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Per-importer walker hurts performance 🐞 Bug ➹ Performance
Description
When excludePeerNamesByImporter is set, collectSbomComponents() calls
lockfileWalkerGroupImporterSteps() once per importer, which removes the cross-importer
deduplication done by the walker’s shared walked set. On large workspaces with many importers and
shared subgraphs, pnpm sbom --exclude-peers can do substantially more traversal work than
necessary.
Code

deps/compliance/sbom/src/collectComponents.ts[R69-74]

+  // When excluding peers, walk each importer with its own `walked` set so one
+  // importer's peer can't suppress another's real dependency.
+  const importerWalkers = opts.excludePeerNamesByImporter
+    ? allImporterIds.flatMap((importerId) =>
+      lockfileWalkerGroupImporterSteps(opts.lockfile, [importerId], { include: opts.include }))
+    : lockfileWalkerGroupImporterSteps(opts.lockfile, allImporterIds, { include: opts.include })
Evidence
The new code invokes the walker once per importer when peers are excluded. The walker implementation
creates a single shared walked set per invocation and uses it to skip already-walked depPaths;
removing that shared set across importers is what increases traversal work.

deps/compliance/sbom/src/collectComponents.ts[69-75]
lockfile/walker/src/index.ts[17-44]
lockfile/walker/src/index.ts[91-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `--exclude-peers`, SBOM collection switches from a single `lockfileWalkerGroupImporterSteps(lockfile, allImporterIds, ...)` call to N separate calls (one per importer). Because `lockfileWalkerGroupImporterSteps()` dedupes traversal using a per-invocation `walked` set, this change can multiply work across importers.
## Issue Context
This only affects the `--exclude-peers` path, but that path is explicitly targeting published-library SBOM generation in workspaces, which can be large.
## Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[69-75]
- lockfile/walker/src/index.ts[17-45]
### Suggested approach
- Keep correctness (peers shouldn’t suppress other importers), but avoid repeated traversal by separating:
1) relationship edge creation per importer (after peer filtering), from
2) component discovery/traversal (which can still use a shared-walked traversal from the union of entry nodes).
- Concretely: compute each importer’s filtered direct depPaths, push relationships for those edges, then run a single walker over the union of filtered entry nodes to populate `componentsMap`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Exclude-peers aborts on parse 🐞 Bug ☼ Reliability
Description
When --exclude-peers runs without selectedProjectsGraph, sbom.handler() reads every importer
manifest via safeReadProjectManifestOnly() but does not catch parse/permission errors, so one
malformed/unreadable workspace manifest aborts SBOM generation even though the SBOM could otherwise
be produced from the lockfile.
Code

deps/compliance/commands/src/sbom/sbom.ts[R245-248]

+          const importerManifest = await safeReadProjectManifestOnly(importerDir)
+          if (importerManifest) {
+            byImporter[importerId] = peerNamesFromManifest(importerManifest)
+          }
Evidence
The --exclude-peers path calls safeReadProjectManifestOnly(importerDir) inside a Promise.all
without a try/catch; safeReadProjectManifestOnly only returns null for missing manifests and
rethrows all other errors, so a single bad manifest will fail the whole SBOM run.

deps/compliance/commands/src/sbom/sbom.ts[231-250]
workspace/project-manifest-reader/src/index.ts[24-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpm sbom --exclude-peers` (when `selectedProjectsGraph` is not provided) reads each importer’s manifest to determine peer names. Today, any error other than “manifest missing” thrown by `safeReadProjectManifestOnly()` will reject the surrounding `Promise.all(...)` and abort SBOM generation.
### Issue Context
`safeReadProjectManifestOnly()` only converts the “no manifest found” error to `null`; JSON/YAML parse errors, EACCES/EPERM, etc. are re-thrown. For `--exclude-peers`, these failures should be handled per-importer (skip that importer’s peer pruning or record a warning) rather than failing the whole command.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[231-250]
- workspace/project-manifest-reader/src/index.ts[24-34]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
7. Unbounded realpath fan-out 🐞 Bug ➹ Performance
Description
When --exclude-peers runs without selectedProjectsGraph, sbom.handler() does a Promise.all()
over every lockfile importer and calls realpath() for each importer path with no concurrency
limit. On large workspaces (or unusually large importer lists), this can saturate filesystem I/O /
libuv threadpool and make pnpm sbom --exclude-peers slow or intermittently fail under resource
pressure.
Code

deps/compliance/commands/src/sbom/sbom.ts[R226-246]

+      const lockfileRoot = await realpath(lockfileDir)
+      await Promise.all(
+        Object.keys(lockfile.importers).map(async (importerId) => {
+          // A crafted lockfile could carry an importer key that escapes the
+          // project, via `..` segments or a symlinked directory. Canonicalize
+          // with realpath and skip anything resolving outside the project root,
+          // so a manifest is never read from outside the tree.
+          let importerDir: string
+          try {
+            importerDir = await realpath(path.resolve(lockfileDir, importerId))
+          } catch {
+            return
+          }
+          const rel = path.relative(lockfileRoot, importerDir)
+          if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) return
+          const importerManifest = await safeReadProjectManifestOnly(importerDir)
+          if (importerManifest) {
+            byImporter[importerId] = peerNamesFromManifest(importerManifest)
+          }
+        })
+      )
Evidence
The SBOM handler launches Promise.all(Object.keys(lockfile.importers).map(async ...)) and performs
await realpath(...) for each importer before calling safeReadProjectManifestOnly(), so
realpath() is not throttled. The manifest reader’s internal limiter only covers the subsequent
manifest read step, not the realpath() calls.

deps/compliance/commands/src/sbom/sbom.ts[226-246]
workspace/project-manifest-reader/src/index.ts[22-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpm sbom --exclude-peers` (when no project graph is selected) scans every `lockfile.importers` entry and runs `realpath()` for each importer concurrently via `Promise.all()`. This is unbounded concurrency for filesystem operations and can degrade performance or reliability on large workspaces.
## Issue Context
`safeReadProjectManifestOnly()` already rate-limits manifest reads internally, but the preceding `realpath()` calls are outside that limiter, so the overall scan can still fan out heavily.
## Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[226-246]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Symlink escape manifest read 🐞 Bug ⛨ Security
Description
When --exclude-peers runs without selectedProjectsGraph, importer paths are validated using only
lexical path checks (path.resolve + startsWith), which do not prevent a symlinked importer
directory under lockfileDir from resolving to a location outside the intended project tree and
being read by safeReadProjectManifestOnly(). This can cause unintended local manifest reads (and
peer-pruning decisions) outside the workspace boundary in symlink-based layouts.
Code

deps/compliance/commands/src/sbom/sbom.ts[R225-233]

+      const lockfileRoot = path.resolve(lockfileDir)
+      await Promise.all(
+        Object.keys(lockfile.importers).map(async (importerId) => {
+          // A crafted lockfile could carry an importer key with `..` segments;
+          // never read a manifest outside the project root.
+          const importerDir = path.resolve(lockfileDir, importerId)
+          if (importerDir !== lockfileRoot && !importerDir.startsWith(lockfileRoot + path.sep)) return
+          const importerManifest = await safeReadProjectManifestOnly(importerDir)
+          if (importerManifest) {
Evidence
The new code computes and checks importerDir using path.resolve/startsWith and then reads a
manifest from that directory; safeReadProjectManifestOnly wraps readProjectManifestOnly, which
attempts to open package.json/package.json5/package.yaml in the provided directory and does
not apply any containment or realpath-based enforcement, so a symlinked path can still escape the
intended root.

deps/compliance/commands/src/sbom/sbom.ts[225-234]
workspace/project-manifest-reader/src/index.ts[24-34]
workspace/project-manifest-reader/src/index.ts[64-104]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`--exclude-peers` (no `selectedProjectsGraph`) resolves `importerDir` with `path.resolve()` and checks it is under `lockfileRoot` via a string prefix check. This prevents `..` traversal but does **not** prevent a symlink within `lockfileDir` (e.g. `packages` -> `/some/other/place`) from escaping the intended boundary.
### Issue Context
This code path then calls `safeReadProjectManifestOnly(importerDir)`, which reads `package.json`/`package.json5`/`package.yaml` from the provided directory and does not enforce any root containment itself.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[225-233]
### Suggested fix
- Canonicalize both the root and importer directories via `fs.promises.realpath()` (or equivalent) before performing the containment check.
- Perform the boundary test using `path.relative(realRoot, realImporter)` (reject if it starts with `'..'` or is absolute), or an equivalent robust check.
- Optionally: if `realpath()` fails for an importer, skip it (consistent with `safeReadProjectManifestOnly` returning null for missing manifests).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Importer path traversal read 🐞 Bug ⛨ Security
Description
When --exclude-peers runs without selectedProjectsGraph, importer IDs from the lockfile are
joined into filesystem paths (path.join(lockfileDir, importerId)) and read, allowing a crafted
lockfile importer key like .. to trigger manifest reads outside lockfileDir.
Code

deps/compliance/commands/src/sbom/sbom.ts[R225-230]

+      await Promise.all(
+        Object.keys(lockfile.importers).map(async (importerId) => {
+          const importerManifest = await safeReadProjectManifestOnly(path.join(lockfileDir, importerId))
+          if (importerManifest) {
+            byImporter[importerId] = peerNamesFromManifest(importerManifest)
+          }
Evidence
The new code reads manifests by joining lockfileDir and raw importerId strings from the
lockfile. getLockfileImporterId() can produce ..-containing importer IDs via path.relative(),
and the manifest reader reads package.json under whatever directory it’s given, making escaping
reads possible.

deps/compliance/commands/src/sbom/sbom.ts[225-231]
lockfile/fs/src/getLockfileImporterId.ts[6-8]
workspace/project-manifest-reader/src/index.ts[24-35]
workspace/project-manifest-reader/src/index.ts[64-67]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`sbom.ts` reads importer manifests from `path.join(lockfileDir, importerId)` where `importerId` comes from `Object.keys(lockfile.importers)`. If a lockfile contains importer keys with `..` segments (or otherwise escaping paths), this can cause reads of `package.json` outside the intended `lockfileDir` boundary.
### Issue Context
`getLockfileImporterId()` uses `path.relative()` and does not prevent `..` segments, and `safeReadProjectManifestOnly()` reads `package.json` in the provided directory without any containment checks. Adding `--exclude-peers` introduced a new path that reads manifests for every importer in the lockfile.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[219-233]
- lockfile/fs/src/getLockfileImporterId.ts[6-8]
### Suggested fix
- Before calling `safeReadProjectManifestOnly()`, resolve the candidate directory (e.g. `const dir = path.resolve(lockfileDir, importerId)`) and skip if it is not within `path.resolve(lockfileDir)` (prefix check using `path.relative()` or `startsWith(lockfileRoot + path.sep)`), while still allowing `importerId === '.'`.
- Optionally, also skip absolute `importerId` values.
- Add a test that uses a lockfile with an escaping importer key to ensure SBOM doesn’t read outside `lockfileDir`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit b35ac65

Results up to commit 17453cd


🐞 Bugs (9) 📘 Rule violations (0) 📜 Skill insights (0)


Action required
1. Peers leak via workspace links 🐞 Bug ≡ Correctness
Description
With --exclude-peers and --filter (selectedProjectsGraph present), buildSharedContext()
records peer names only for the selected importers, but collectSbomComponents() also walks extra
workspace importers reachable via link: dependencies. Peer dependencies declared in those
additional workspace packages will still be included in the SBOM, partially defeating
--exclude-peers.
Code

deps/compliance/commands/src/sbom/sbom.ts[R339-342]

+    if (opts.selectedProjectsGraph) {
+      for (const [projectDir, { package: project }] of Object.entries(opts.selectedProjectsGraph)) {
+        byImporter.set(getLockfileImporterId(lockfileDir, projectDir), peerNamesFromManifest(project.manifest))
+      }
Evidence
Peer-name collection is limited to selectedProjectsGraph, but SBOM traversal can include
additional importer IDs from resolveWorkspaceDeps.additionalImporterIds, and peer filtering is
keyed by importer ID; missing entries mean peers won’t be filtered for those walked importers.

deps/compliance/commands/src/sbom/sbom.ts[336-343]
deps/compliance/commands/src/sbom/sbom.ts[455-468]
deps/compliance/sbom/src/collectComponents.ts[63-75]
deps/compliance/sbom/src/collectComponents.ts[249-301]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
When `opts.excludePeers` is enabled and `opts.selectedProjectsGraph` is set, `excludePeerNamesByImporter` is populated only for the selected projects. However, SBOM generation may also walk additional importer IDs (workspace packages) discovered via `resolveWorkspaceDeps()` (i.e. `link:` dependencies). Those additional importers will not have their peer dependencies excluded.
## Issue Context
This shows up in workspaces when generating an SBOM for a filtered subset that depends on other workspace packages; those extra workspace packages are still included in the SBOM graph, but their own `peerDependencies` are not filtered.
## Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[327-385]
- deps/compliance/commands/src/sbom/sbom.ts[455-489]
### Suggested approach
- When `opts.excludePeers` is true and workspace graphs are available, populate `excludePeerNamesByImporter` from *all* known workspace manifests (e.g. `allProjectsGraph` as well as `selectedProjectsGraph`), not only the selected set.
- You already build `workspaceManifestsByImporterId` from both graphs; reuse it to compute peer name sets without extra filesystem reads.
- Alternatively (or additionally), in `generateSbomForProject()`, after `resolvedWorkspaceDeps` is computed, ensure every `additionalImporterId` has a peer-name entry (from `ctx.workspaceManifestsByImporterId`) before calling `collectSbomComponents()`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Peer pruning hides real deps 🐞 Bug ≡ Correctness
Description
collectSbomComponents() filters peer dependencies from an importer's root step after
lockfileWalkerGroupImporterSteps() has already added those depPaths to a shared cross-importer
"walked" set. If an excluded peer is the first importer to mention a depPath that another importer
includes as a real dependency, the later importer will skip it and the SBOM can omit that real
dependency entirely.
Code

deps/compliance/sbom/src/collectComponents.ts[R66-80]

+    importerWalkers.map(async ({ importerId, step }) => {
+      // Dropping a peer's direct entry here (rather than from the walker) prunes
+      // its exclusive transitive subtree for free: packages reachable only
+      // through the peer are never walked, while packages shared with a real
+      // dependency are still reached via that path.
+      const peerNames = opts.excludePeerNamesByImporter?.get(importerId)
+      const rootStep = (peerNames?.size)
+        ? {
+          ...step,
+          dependencies: step.dependencies.filter((dep) => {
+            const { name } = nameVerFromPkgSnapshot(dep.depPath, dep.pkgSnapshot)
+            return !name || !peerNames.has(name)
+          }),
+        }
+        : step
Evidence
The SBOM code filters peers after the importer steps are created, but the lockfile walker’s step()
adds each direct depPath to a shared walked Set across all importers. Therefore, when a peer dep
is filtered out, its depPath may already be in walked, preventing other importers from ever
emitting/walking it even when it is a non-peer dependency for them.

deps/compliance/sbom/src/collectComponents.ts[39-90]
lockfile/walker/src/index.ts[17-45]
lockfile/walker/src/index.ts[91-122]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`collectSbomComponents()` prunes peer deps by filtering `step.dependencies` *after* `lockfileWalkerGroupImporterSteps()` has already constructed steps using a shared `walked` set across importers. Because `step()` marks direct depPaths as walked up-front, a pruned peer depPath may be suppressed for later importers even if it is a real dependency there, causing missing components/relationships in the SBOM.
### Issue Context
- `lockfileWalkerGroupImporterSteps()` shares a single `walked` Set across all importer steps and calls `step()` immediately for each importer, which adds each direct depPath to `walked`.
- The new peer pruning in SBOM happens later by filtering `step.dependencies`.
### Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[39-91]
- lockfile/walker/src/index.ts[17-45]
- lockfile/walker/src/index.ts[91-122]
### Implementation direction
Choose one approach that ensures excluded peers do **not** get marked as walked globally before a non-peer path can include them:
1. **Per-importer walker state (SBOM-local):** when `excludePeerNamesByImporter` is present, build walker steps per importer with an isolated `walked` set (e.g., invoke the walker per importer rather than using the grouped walker that shares `walked`). Deduplicate components via `componentsMap` as today.
2. **Filter before walking:** compute the root entry depPaths to walk per importer (from the importer snapshot’s dependency refs) and exclude peers there (so the walker never sees/marks those depPaths).
3. **Walker enhancement:** extend `lockfileWalkerGroupImporterSteps()` to accept a per-importer “skip direct depPaths” set and ensure skipped depPaths are not added to `walked`.
Add a regression test with at least 2 importers where importer A lists package X as a peer (thus excluded) and importer B lists X as a normal dependency; verify X remains in SBOM when `--exclude-peers` is enabled.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Peer+dev not excluded 🐞 Bug ≡ Correctness
Description
peerNamesFromManifest() treats devDependencies as “regular”, so a dep listed in both
peerDependencies and devDependencies won’t be excluded even in --prod mode (where dev deps are
not included), causing --exclude-peers to keep common consumer-provided peers.
Code

deps/compliance/commands/src/sbom/sbom.ts[R278-288]

+function peerNamesFromManifest (manifest: ProjectManifest): Set<string> {
+  // A name declared as both a peer and a regular dependency is a real dependency
+  // the package pulls in itself, so keep it.
+  const regular = new Set([
+    ...Object.keys(manifest.dependencies ?? {}),
+    ...Object.keys(manifest.devDependencies ?? {}),
+    ...Object.keys(manifest.optionalDependencies ?? {}),
+  ])
+  return new Set(
+    Object.keys(manifest.peerDependencies ?? {}).filter((name) => !regular.has(name))
+  )
Evidence
The peer-exclusion set is computed by subtracting any peer name also present in dependencies,
devDependencies, or optionalDependencies, which means peers duplicated into devDeps are not
excluded. The repo contains fixtures where the same package is listed as both a peer and a dev
dependency, demonstrating this is a real and common pattern that will break --exclude-peers
especially under --prod.

deps/compliance/commands/src/sbom/sbom.ts[278-288]
pnpm/test/fixtures/workspace-with-circular-peers/modules/module5/package.json[4-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`peerNamesFromManifest()` currently considers `devDependencies` and `optionalDependencies` as “regular” unconditionally. In `--prod` (or `--no-optional`) runs, this prevents excluding peers that are duplicated into those non-included fields (a common pattern is `peerDependencies` + `devDependencies` for local testing).
### Issue Context
`--exclude-peers` is meant to omit consumer-provided peer deps from SBOM output. However, peers duplicated into `devDependencies` should only be treated as “regular” when dev deps are actually included in the SBOM run.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[175-178]
- deps/compliance/commands/src/sbom/sbom.ts[210-233]
- deps/compliance/commands/src/sbom/sbom.ts[278-289]
### Suggested fix
- Change `peerNamesFromManifest(manifest)` to accept the computed `include` flags.
- Build the `regular` set from only the dependency fields that are included (e.g., always `dependencies` if included; `devDependencies` only if `include.devDependencies` is true; `optionalDependencies` only if `include.optionalDependencies` is true).
- Update all call sites to pass `include` so `--prod` correctly excludes peer+dev duplicates, while default runs that include dev deps keep them as real dev deps.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
4. Redundant manifest scan 🐞 Bug ➹ Performance ⭐ New
Description
With --exclude-peers enabled and selectedProjectsGraph unset, buildSharedContext() scans every
lockfile importer via realpath() + safeReadProjectManifestOnly(), even when allProjectsGraph
is available and the command may later only walk a subset (notably in --split mode). This adds
avoidable filesystem I/O and latency to pnpm sbom --exclude-peers on large workspaces.
Code

deps/compliance/commands/src/sbom/sbom.ts[R327-383]

+  const lockfileDir = opts.lockfileDir ?? opts.dir
+
+  // peerDependencies are identified from the manifest, not the lockfile: with
+  // auto-install-peers they resolve into the importer's `dependencies` with no
+  // distinguishing marker. Map every walked importer to its peer names so the
+  // collector can drop them.
+  // Keyed by a Map, not a plain object: importer ids come from the lockfile
+  // (attacker-controlled in an untrusted clone), and a key like `__proto__`
+  // would corrupt a plain object's prototype.
+  let excludePeerNamesByImporter: Map<string, Set<string>> | undefined
+  if (opts.excludePeers) {
+    const byImporter = new Map<string, Set<string>>()
+    if (opts.selectedProjectsGraph) {
+      for (const [projectDir, { package: project }] of Object.entries(opts.selectedProjectsGraph)) {
+        byImporter.set(getLockfileImporterId(lockfileDir, projectDir), peerNamesFromManifest(project.manifest))
+      }
+    } else {
+      // No project graph was selected, so collectSbomComponents walks every
+      // importer in the lockfile. Resolve each importer's own manifest so peers
+      // in workspace packages are dropped too, not only those in the directory
+      // pnpm ran in. safeReadProjectManifestOnly returns null (rather than
+      // throwing) for an importer whose manifest is gone (e.g. a stale lockfile),
+      // and skips the installability check that would otherwise abort the SBOM.
+      const lockfileRoot = await realpath(lockfileDir)
+      // Bound the fan-out: a large workspace can have many importers, and
+      // reading every manifest at once would spike open file descriptors.
+      const limitManifestReads = pLimit(16)
+      await Promise.all(
+        Object.keys(lockfile.importers).map((importerId) => limitManifestReads(async () => {
+          // A crafted lockfile could carry an importer key that escapes the
+          // project, via `..` segments or a symlinked directory. Canonicalize
+          // with realpath and skip anything resolving outside the project root,
+          // so a manifest is never read from outside the tree.
+          let importerDir: string
+          try {
+            importerDir = await realpath(path.resolve(lockfileDir, importerId))
+          } catch {
+            return
+          }
+          const rel = path.relative(lockfileRoot, importerDir)
+          if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) return
+          // safeReadProjectManifestOnly tolerates a missing manifest but still
+          // throws on a malformed one (parse error). In an untrusted clone a
+          // single junk package.json must not abort the whole SBOM, so skip it
+          // (fail-open: an unparseable importer's peers just aren't filtered).
+          let importerManifest: ProjectManifest | null
+          try {
+            importerManifest = await safeReadProjectManifestOnly(importerDir)
+          } catch {
+            return
+          }
+          if (importerManifest) {
+            byImporter.set(importerId, peerNamesFromManifest(importerManifest))
+          }
+        }))
+      )
+    }
Evidence
handler() builds shared context before deciding split/non-split, so the new per-importer manifest
scan runs even when the command will later emit per-project SBOMs. The scan is triggered solely by
the absence of selectedProjectsGraph, even though allProjectsGraph is available and already used
to populate workspace manifests, making the additional filesystem work avoidable in common workspace
runs.

deps/compliance/commands/src/sbom/sbom.ts[187-197]
deps/compliance/commands/src/sbom/sbom.ts[327-383]
deps/compliance/commands/src/sbom/sbom.ts[396-402]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`buildSharedContext()` currently computes `excludePeerNamesByImporter` by reading manifests from disk for every `lockfile.importers` entry whenever `opts.selectedProjectsGraph` is not set. In workspaces, pnpm often already provides `opts.allProjectsGraph` (and the command already uses it to populate `workspaceManifestsByImporterId`), so the extra per-importer `realpath()` + manifest read is redundant and can be expensive. This cost is paid even when `handler()` later goes into `--split` and generates per-project SBOMs.

### Issue Context
- The expensive path is gated by `opts.excludePeers` and `!opts.selectedProjectsGraph`.
- `handler()` calls `buildSharedContext()` before deciding `shouldSplit`, so this scan happens even for split output.

### Fix Focus Areas
- Prefer deriving peer names from `opts.selectedProjectsGraph ?? opts.allProjectsGraph` when available (using the already-loaded `entry.package.manifest`).
- If you still need to handle stale lockfile importers not present in the graph, consider scanning *only* missing importerIds after seeding from the graph.
- Optionally defer any filesystem scanning until you know the set of importers that will actually be walked (especially in split mode).

- deps/compliance/commands/src/sbom/sbom.ts[187-197]
- deps/compliance/commands/src/sbom/sbom.ts[327-383]
- deps/compliance/commands/src/sbom/sbom.ts[396-402]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Per-importer walker hurts performance 🐞 Bug ➹ Performance
Description
When excludePeerNamesByImporter is set, collectSbomComponents() calls
lockfileWalkerGroupImporterSteps() once per importer, which removes the cross-importer
deduplication done by the walker’s shared walked set. On large workspaces with many importers and
shared subgraphs, pnpm sbom --exclude-peers can do substantially more traversal work than
necessary.
Code

deps/compliance/sbom/src/collectComponents.ts[R69-74]

+  // When excluding peers, walk each importer with its own `walked` set so one
+  // importer's peer can't suppress another's real dependency.
+  const importerWalkers = opts.excludePeerNamesByImporter
+    ? allImporterIds.flatMap((importerId) =>
+      lockfileWalkerGroupImporterSteps(opts.lockfile, [importerId], { include: opts.include }))
+    : lockfileWalkerGroupImporterSteps(opts.lockfile, allImporterIds, { include: opts.include })
Evidence
The new code invokes the walker once per importer when peers are excluded. The walker implementation
creates a single shared walked set per invocation and uses it to skip already-walked depPaths;
removing that shared set across importers is what increases traversal work.

deps/compliance/sbom/src/collectComponents.ts[69-75]
lockfile/walker/src/index.ts[17-44]
lockfile/walker/src/index.ts[91-123]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `--exclude-peers`, SBOM collection switches from a single `lockfileWalkerGroupImporterSteps(lockfile, allImporterIds, ...)` call to N separate calls (one per importer). Because `lockfileWalkerGroupImporterSteps()` dedupes traversal using a per-invocation `walked` set, this change can multiply work across importers.
## Issue Context
This only affects the `--exclude-peers` path, but that path is explicitly targeting published-library SBOM generation in workspaces, which can be large.
## Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[69-75]
- lockfile/walker/src/index.ts[17-45]
### Suggested approach
- Keep correctness (peers shouldn’t suppress other importers), but avoid repeated traversal by separating:
1) relationship edge creation per importer (after peer filtering), from
2) component discovery/traversal (which can still use a shared-walked traversal from the union of entry nodes).
- Concretely: compute each importer’s filtered direct depPaths, push relationships for those edges, then run a single walker over the union of filtered entry nodes to populate `componentsMap`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Exclude-peers aborts on parse 🐞 Bug ☼ Reliability
Description
When --exclude-peers runs without selectedProjectsGraph, sbom.handler() reads every importer
manifest via safeReadProjectManifestOnly() but does not catch parse/permission errors, so one
malformed/unreadable workspace manifest aborts SBOM generation even though the SBOM could otherwise
be produced from the lockfile.
Code

deps/compliance/commands/src/sbom/sbom.ts[R245-248]</cod...

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

feat(sbom): add --exclude-peers to omit peer dependencies
✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

Walkthroughs

Description
• Add pnpm sbom --exclude-peers to omit peer dependencies from generated SBOMs.
• Prune any transitive dependency subtrees reachable only through omitted peers.
• Add fixtures and tests covering default behavior, single-project, and workspace importers.
Diagram
graph TD
  A["pnpm sbom CLI"] --> B["sbom.ts handler"] --> C[("pnpm-lock.yaml")]
  B -. "--exclude-peers" .-> D["Read manifests & build peer-name map"] --> E["collectSbomComponents()"] --> F["Serialize CycloneDX/SPDX"] --> G["SBOM output"]
  C --> E
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Post-filter peers at serialization time
  • ➕ Minimal changes to lockfile walking/collection code
  • ➕ Could reuse existing collected component graph
  • ➖ Hard to safely prune exclusive transitive subtrees; you must also fix relationships/dependsOn
  • ➖ Higher risk of leaving dangling dependency edges or orphan components
2. Persist peer markers in the lockfile format
  • ➕ Would allow peer identification without reading manifests
  • ➕ Could enable richer handling beyond omission in the future
  • ➖ Requires lockfile schema changes and migration/back-compat considerations
  • ➖ Doesn’t help existing lockfiles; still need fallback behavior
3. Model peers explicitly via SBOM metadata/extensions
  • ➕ Keeps full dependency graph while indicating consumer-provided peers
  • ➕ Potentially useful for downstream tooling
  • ➖ CycloneDX 1.7 lacks a standard scope/relationship for peerDependencies; extensions reduce interoperability
  • ➖ Increases complexity and may be considered non-compliant by strict consumers

Recommendation: The implemented approach—deriving peers from manifests and filtering them at each importer’s root step—is the best fit for CycloneDX 1.7 constraints. Filtering at the root naturally prunes exclusive peer subtrees while preserving packages also reachable via real dependencies, and avoids generating non-standard SBOM semantics.

Grey Divider

File Changes

Enhancement (2)
sbom.ts Add '--exclude-peers' CLI option and peer-name collection +59/-2

Add '--exclude-peers' CLI option and peer-name collection

• Adds the 'excludePeers' option to 'pnpm sbom', wires it into CLI option parsing/help, and builds a per-importer set of peer dependency names from manifests. When no project filter is used, it reads each lockfile importer’s manifest via 'safeReadProjectManifestOnly' to exclude peers in workspace sub-packages as well.

deps/compliance/commands/src/sbom/sbom.ts


collectComponents.ts Filter peer roots per importer to prune exclusive subtrees +21/-2

Filter peer roots per importer to prune exclusive subtrees

• Extends 'collectSbomComponents' with 'excludePeerNamesByImporter' and filters each importer’s root dependencies before walking. This prevents traversal into peer-only subtrees while still allowing shared packages to be reached through non-peer paths.

deps/compliance/sbom/src/collectComponents.ts


Tests (7)
package.json Add single-project peer-dependency fixture manifest +12/-0

Add single-project peer-dependency fixture manifest

• Creates a test package manifest that declares a peer dependency alongside a real dependency for SBOM exclusion coverage.

deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/package.json


pnpm-lock.yaml Add lockfile fixture where peer is auto-installed into dependencies +40/-0

Add lockfile fixture where peer is auto-installed into dependencies

• Adds a lockfile with 'autoInstallPeers: true' where the peer appears under dependencies, enabling tests for manifest-based peer identification and subtree pruning.

deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/pnpm-lock.yaml


package.json Add workspace-root fixture manifest +8/-0

Add workspace-root fixture manifest

• Adds a workspace root package.json used to validate behavior when walking all importers without a selected project graph.

deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/package.json


package.json Add workspace sub-package fixture with peerDependency +8/-0

Add workspace sub-package fixture with peerDependency

• Defines a workspace package that declares a peer dependency, used to verify exclusion when 'pnpm sbom' runs from the workspace root without filtering.

deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/packages/pkg-a/package.json


pnpm-lock.yaml Add workspace lockfile fixture with multiple importers +43/-0

Add workspace lockfile fixture with multiple importers

• Provides a lockfile containing both workspace root and sub-package importers, with the peer resolved into the sub-package importer’s dependencies.

deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-lock.yaml


pnpm-workspace.yaml Add workspace definition for peer-workspace fixture +2/-0

Add workspace definition for peer-workspace fixture

• Declares workspace package globs so tests can simulate a multi-importer workspace layout.

deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-workspace.yaml


index.ts Add tests for default peer inclusion and '--exclude-peers' pruning +76/-0

Add tests for default peer inclusion and '--exclude-peers' pruning

• Adds coverage asserting peers are included by default, excluded with '--exclude-peers' (including exclusive transitive deps), and excluded when declared in workspace sub-packages during full-importer walks.

deps/compliance/commands/test/sbom/index.ts


Documentation (1)
sbom-exclude-peers.md Add changeset entry for 'sbom --exclude-peers' +7/-0

Add changeset entry for 'sbom --exclude-peers'

• Introduces a changeset documenting the new '--exclude-peers' flag and its rationale/behavior differences from 'pnpm list'.

.changeset/sbom-exclude-peers.md


Grey Divider

Qodo Logo

Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/commands/src/sbom/sbom.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deps/compliance/commands/src/sbom/sbom.ts`:
- Around line 226-227: The importerId values from lockfile.importers are
untrusted user-controlled input that are directly concatenated into a filesystem
path via path.join(lockfileDir, importerId) without validation, creating a path
traversal vulnerability. Before using importerId in the path.join call, validate
that the importerId does not contain path traversal sequences (such as `..` or
absolute paths) and ensure the resolved path remains within the intended
lockfileDir boundary. Consider using path.resolve() and checking that the final
path is still within lockfileDir, or normalize and validate the importerId to
reject any parent directory references or absolute paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 083d4640-5d7c-463e-b9ba-d7cc0fa28579

📥 Commits

Reviewing files that changed from the base of the PR and between d36b6f8 and 5dcd70c.

⛔ Files ignored due to path filters (2)
  • deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • .changeset/sbom-exclude-peers.md
  • deps/compliance/commands/src/sbom/sbom.ts
  • deps/compliance/commands/test/sbom/fixtures/with-peer-dependency/package.json
  • deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/package.json
  • deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/packages/pkg-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/with-peer-workspace/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/sbom/src/collectComponents.ts

Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
@Saturate Saturate force-pushed the feat/sbom-exclude-peers branch from 5dcd70c to 2938d2b Compare June 16, 2026 09:16
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 2938d2b

Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
@Saturate Saturate force-pushed the feat/sbom-exclude-peers branch from 2938d2b to c11b631 Compare June 16, 2026 10:12
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c11b631

Comment thread deps/compliance/commands/src/sbom/sbom.ts
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c0c0fbe

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 16, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 577dcd7

Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.877 ± 0.191 3.655 4.244 1.81 ± 0.12
pacquet@main 3.826 ± 0.139 3.641 4.060 1.79 ± 0.11
pnpr@HEAD 2.161 ± 0.130 1.937 2.382 1.01 ± 0.08
pnpr@main 2.141 ± 0.100 1.939 2.288 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.8771818994200005,
      "stddev": 0.19132658616800066,
      "median": 3.8908016551199998,
      "user": 3.72067974,
      "system": 3.294670600000001,
      "min": 3.65467230362,
      "max": 4.2440559676200005,
      "times": [
        4.03132368462,
        3.8747459636199997,
        3.6641170016199998,
        4.2440559676200005,
        3.90685734662,
        3.65467230362,
        3.6851574176199997,
        4.03214460662,
        3.76335660162,
        3.91538810062
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.82638520002,
      "stddev": 0.13911839516955748,
      "median": 3.82110589712,
      "user": 3.7134392399999996,
      "system": 3.2945211,
      "min": 3.6409695576199996,
      "max": 4.05983800062,
      "times": [
        3.82970993062,
        3.7244031766199996,
        3.81250186362,
        3.6409695576199996,
        4.05983800062,
        3.76435478162,
        3.6875266556199997,
        3.86179961162,
        4.05062953662,
        3.83211888562
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1605805230199997,
      "stddev": 0.1295399342889556,
      "median": 2.1502036236199995,
      "user": 2.73290714,
      "system": 2.8985735,
      "min": 1.93707991862,
      "max": 2.3823141456199997,
      "times": [
        2.1601854226199997,
        2.3305520976199996,
        2.18958454162,
        2.1977496246199997,
        1.93707991862,
        2.1402218246199998,
        2.3823141456199997,
        2.0976179386199996,
        2.13557670162,
        2.03492301462
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1409734233199997,
      "stddev": 0.09964787562666778,
      "median": 2.1329728276199997,
      "user": 2.72221864,
      "system": 2.8749508,
      "min": 1.93855521162,
      "max": 2.2877872186199997,
      "times": [
        2.24222738162,
        2.21415475262,
        2.09640031962,
        2.18517495362,
        2.11892101562,
        2.14326984462,
        2.1226758106199997,
        2.06056772462,
        1.93855521162,
        2.2877872186199997
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 655.2 ± 12.7 639.3 680.2 1.00
pacquet@main 670.7 ± 38.1 643.6 774.0 1.02 ± 0.06
pnpr@HEAD 764.5 ± 35.4 726.0 855.1 1.17 ± 0.06
pnpr@main 740.3 ± 81.1 691.2 964.6 1.13 ± 0.13
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6551662101200001,
      "stddev": 0.012668461411135258,
      "median": 0.6545348279200001,
      "user": 0.39927836,
      "system": 1.3264312,
      "min": 0.63925856392,
      "max": 0.6802232929200001,
      "times": [
        0.6444768619200001,
        0.65690770292,
        0.6453114479200001,
        0.65216195292,
        0.64756391792,
        0.63925856392,
        0.67176606392,
        0.65691123092,
        0.65708106592,
        0.6802232929200001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.67070147422,
      "stddev": 0.03814846376323452,
      "median": 0.6606652044200001,
      "user": 0.40160076,
      "system": 1.3403623,
      "min": 0.64363387192,
      "max": 0.77401236192,
      "times": [
        0.66260246992,
        0.64363387192,
        0.6442485839200001,
        0.64941134192,
        0.67044207792,
        0.65872793892,
        0.6792593049200001,
        0.77401236192,
        0.65469697892,
        0.66997981192
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.76453283022,
      "stddev": 0.03535497633176031,
      "median": 0.7573952419200001,
      "user": 0.4204175600000001,
      "system": 1.416814,
      "min": 0.7259610189200001,
      "max": 0.85507469292,
      "times": [
        0.73484744792,
        0.77588107892,
        0.75141612692,
        0.74978315892,
        0.75875962192,
        0.76541531992,
        0.7259610189200001,
        0.75603086192,
        0.77215897392,
        0.85507469292
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.7403419258199999,
      "stddev": 0.08112485985221674,
      "median": 0.7106749934200001,
      "user": 0.40275216,
      "system": 1.3669529999999999,
      "min": 0.6911921079200001,
      "max": 0.96458236592,
      "times": [
        0.7501824889200001,
        0.70122129292,
        0.7069867279200001,
        0.6911921079200001,
        0.71946338992,
        0.70252946092,
        0.70564923292,
        0.7472489319200001,
        0.96458236592,
        0.7143632589200001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.307 ± 0.063 4.220 4.383 1.96 ± 0.11
pacquet@main 4.354 ± 0.044 4.271 4.425 1.98 ± 0.11
pnpr@HEAD 2.222 ± 0.120 2.084 2.435 1.01 ± 0.08
pnpr@main 2.196 ± 0.123 2.058 2.433 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.306930792359999,
      "stddev": 0.06301262527906629,
      "median": 4.317674954059999,
      "user": 3.87723234,
      "system": 3.32025418,
      "min": 4.22008505256,
      "max": 4.38257837656,
      "times": [
        4.3489618515599995,
        4.22008505256,
        4.381729237559999,
        4.27023510956,
        4.3008865385599995,
        4.38257837656,
        4.24423158556,
        4.33446336956,
        4.35884259956,
        4.2272942025599995
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.35422847936,
      "stddev": 0.044474104524407455,
      "median": 4.35809049606,
      "user": 3.91893594,
      "system": 3.3388936799999995,
      "min": 4.271066429559999,
      "max": 4.42497500056,
      "times": [
        4.383454759559999,
        4.31944848956,
        4.33195580456,
        4.271066429559999,
        4.38514310356,
        4.35065408956,
        4.42497500056,
        4.3883707125599996,
        4.36552690256,
        4.32168950156
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.2216045899599997,
      "stddev": 0.12006813583144445,
      "median": 2.19313280856,
      "user": 2.58389384,
      "system": 2.8502824799999997,
      "min": 2.08384679456,
      "max": 2.43502399056,
      "times": [
        2.19440614156,
        2.43502399056,
        2.13552700556,
        2.14147658556,
        2.25201281356,
        2.34235337656,
        2.08384679456,
        2.1918594755600003,
        2.35064408656,
        2.08889562956
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.19553341856,
      "stddev": 0.12332112881635343,
      "median": 2.1636911435600004,
      "user": 2.57516194,
      "system": 2.83668058,
      "min": 2.05827288356,
      "max": 2.4330972545600003,
      "times": [
        2.4330972545600003,
        2.07073787356,
        2.1437336025600002,
        2.07080540656,
        2.2606010375600003,
        2.05827288356,
        2.32270473556,
        2.26799910456,
        2.15675662156,
        2.1706256655600003
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.408 ± 0.016 1.389 1.432 2.12 ± 0.03
pacquet@main 1.395 ± 0.017 1.370 1.427 2.10 ± 0.03
pnpr@HEAD 0.663 ± 0.004 0.659 0.673 1.00
pnpr@main 0.672 ± 0.034 0.650 0.757 1.01 ± 0.05
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.40835069774,
      "stddev": 0.016347953904528957,
      "median": 1.40270106334,
      "user": 1.40808898,
      "system": 1.7350784799999999,
      "min": 1.38892332634,
      "max": 1.43167121834,
      "times": [
        1.40167890534,
        1.42783682634,
        1.43058867034,
        1.43167121834,
        1.39684780934,
        1.39028018734,
        1.38892332634,
        1.41205099334,
        1.39990581934,
        1.4037232213400002
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.3951979574400002,
      "stddev": 0.0169237626025575,
      "median": 1.3910786158400001,
      "user": 1.3819017800000002,
      "system": 1.72898418,
      "min": 1.3703710853400002,
      "max": 1.42700109634,
      "times": [
        1.37980336634,
        1.38640978934,
        1.41757441934,
        1.39181288034,
        1.3960312803400001,
        1.42700109634,
        1.39034435134,
        1.4032715823400002,
        1.3893597233400001,
        1.3703710853400002
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.66322219924,
      "stddev": 0.004037665618183177,
      "median": 0.6629570838400001,
      "user": 0.34062467999999996,
      "system": 1.29610078,
      "min": 0.6592119323400001,
      "max": 0.6726939563400001,
      "times": [
        0.6637917063400001,
        0.6592119323400001,
        0.65942357734,
        0.6649205693400001,
        0.6612810073400001,
        0.6594703193400001,
        0.6726939563400001,
        0.66438867134,
        0.6649177913400001,
        0.6621224613400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.67165940124,
      "stddev": 0.03356372295961125,
      "median": 0.6572060423400001,
      "user": 0.33613928000000004,
      "system": 1.28068378,
      "min": 0.6495663903400001,
      "max": 0.75651856234,
      "times": [
        0.70137415134,
        0.67385844634,
        0.65904996934,
        0.6532645883400001,
        0.6553621153400001,
        0.6495663903400001,
        0.6543319793400001,
        0.6614811663400001,
        0.65178664334,
        0.75651856234
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.062 ± 0.065 2.997 3.201 4.44 ± 0.14
pacquet@main 3.125 ± 0.043 3.065 3.207 4.53 ± 0.13
pnpr@HEAD 0.695 ± 0.008 0.684 0.710 1.01 ± 0.03
pnpr@main 0.689 ± 0.017 0.662 0.710 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0619094478199997,
      "stddev": 0.06478268757153575,
      "median": 3.04130406082,
      "user": 1.7859942600000003,
      "system": 1.98966836,
      "min": 2.99691426482,
      "max": 3.2011891018200003,
      "times": [
        3.0473517288200003,
        3.14342437582,
        3.02077653182,
        3.07485386882,
        3.0162117088200002,
        3.03525639282,
        3.07159886782,
        3.0115176368200003,
        3.2011891018200003,
        2.99691426482
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.1247117832200004,
      "stddev": 0.043429851059313414,
      "median": 3.11386366182,
      "user": 1.8512819600000001,
      "system": 2.03305726,
      "min": 3.0650307588200003,
      "max": 3.20661986582,
      "times": [
        3.0650307588200003,
        3.10985631482,
        3.0820906258200003,
        3.1178710088200003,
        3.1056892208200004,
        3.10003126882,
        3.1427681568200003,
        3.1812672278200003,
        3.20661986582,
        3.13589338382
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6946072974199999,
      "stddev": 0.007706132167413201,
      "median": 0.6935387078199999,
      "user": 0.35597656,
      "system": 1.3326521599999999,
      "min": 0.68447040882,
      "max": 0.70970764882,
      "times": [
        0.70970764882,
        0.68447040882,
        0.6986277818200001,
        0.70111411782,
        0.68604552982,
        0.69492159982,
        0.69920470082,
        0.69043843782,
        0.69215581582,
        0.68938693282
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.68918460702,
      "stddev": 0.01651673644437011,
      "median": 0.68655845332,
      "user": 0.35251765999999995,
      "system": 1.3075549599999996,
      "min": 0.66183009382,
      "max": 0.71001798782,
      "times": [
        0.6817736238200001,
        0.70651399182,
        0.68233293482,
        0.70480096882,
        0.68929637282,
        0.70267410582,
        0.68382053382,
        0.66878545682,
        0.66183009382,
        0.71001798782
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12443
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,306.93 ms
(+2.35%)Baseline: 4,207.89 ms
5,049.47 ms
(85.29%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,061.91 ms
(+2.20%)Baseline: 2,996.01 ms
3,595.21 ms
(85.17%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,408.35 ms
(+6.54%)Baseline: 1,321.92 ms
1,586.30 ms
(88.78%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
3,877.18 ms
(-6.35%)Baseline: 4,139.95 ms
4,967.94 ms
(78.04%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
655.17 ms
(+6.88%)Baseline: 612.97 ms
735.57 ms
(89.07%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12443
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,221.60 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
694.61 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
663.22 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,160.58 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
764.53 ms
🐰 View full continuous benchmarking report in Bencher

Comment thread deps/compliance/sbom/src/collectComponents.ts Outdated
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 3bdede8

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 7c110d8

Saturate added 4 commits June 18, 2026 11:02
With auto-install-peers (the default), peer dependencies resolve into
the lockfile and are indistinguishable from a package's own deps. The
manifest's peerDependencies is the only signal, so the flag drops those
names (and any transitive subtree reachable only through them) from the
SBOM. CycloneDX 1.7 has no scope or relationship for consumer-provided
peers, so omission is the only spec-clean handling.

When no project is filtered, every importer in the lockfile is walked,
so each importer's own manifest is resolved (via safeReadProjectManifestOnly,
which tolerates a missing manifest) to catch peers declared in workspace
sub-packages too. Importer dirs are canonicalized with realpath and
checked against the project root, so a crafted lockfile cannot read a
manifest outside the tree via `..` or a symlinked directory.

The flag name matches pnpm list --exclude-peers; the SBOM flag is
stricter, pruning a peer's exclusive subtree rather than only hiding
leaf peers.
Without a filter, the peer scan reads every importer manifest in the
lockfile. Cap the fan-out with p-limit(16) so a large workspace does not
realpath and open every manifest at once. Addresses the review note about
the unbounded realpath fan-out.
- Do not abort the whole SBOM when one importer package.json is
  malformed. The no-filter peer scan reads every importer manifest, and
  safeReadProjectManifestOnly tolerates a missing manifest but still
  throws on a parse error; in an untrusted clone a single junk
  package.json would reject the Promise.all and fail the command. Catch
  it per importer and skip (fail-open: that importer peers are not
  filtered, which only over-includes, never deletes).
- Key the per-importer peer set by a Map rather than a plain object, so
  an attacker-controlled importer id like `__proto__` cannot reassign
  the container prototype.
The grouped lockfile walker shares one walked set across importers, so
each importer direct deps are claimed up front. With --exclude-peers, a
package that is a peer of one importer (filtered out) but a real
dependency of another was dropped: the first importer marked it walked,
so the second skipped it. Walk each importer with its own walked set
when excluding peers so one importer peer cannot suppress another
importer real dependency; componentsMap still deduplicates components
across importers.

Regression test: pkg-a declares is-odd as a peer, pkg-b as a real
dependency; is-odd must survive --exclude-peers (verified failing before
the fix).
@Saturate Saturate force-pushed the feat/sbom-exclude-peers branch from 7c110d8 to edd3fee Compare June 18, 2026 10:14
Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
Comment thread deps/compliance/sbom/src/collectComponents.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit edd3fee

Drop stray blank lines and restore the comments explaining why a workspace
importer with no resolved package info is skipped and why peer entries are
filtered from the importer's root step before walking.
Comment thread deps/compliance/commands/src/sbom/sbom.ts
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 17453cd

…runs

With --exclude-peers and a --filter, peer names were collected only for the
selected importers, but collectSbomComponents also walks the workspace packages
those importers reach through `link:` deps. Peers declared in the linked
packages therefore leaked into the SBOM.

Derive peer names from the in-memory project graph(s), reading from both
allProjectsGraph and selectedProjectsGraph (as workspaceManifestsByImporterId
already does), so every importer that may be walked is covered. This also drops
the redundant per-importer realpath + manifest disk scan whenever a graph is
available; the disk scan now runs only when no project graph exists.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit b35ac65

@zkochan zkochan merged commit 6c35a43 into pnpm:main Jun 20, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants