Skip to content

feat(sbom): per-package SBOM generation with --out and --split#12097

Merged
zkochan merged 11 commits into
pnpm:mainfrom
Saturate:feat/sbom-workspace-support
Jun 17, 2026
Merged

feat(sbom): per-package SBOM generation with --out and --split#12097
zkochan merged 11 commits into
pnpm:mainfrom
Saturate:feat/sbom-workspace-support

Conversation

@Saturate

@Saturate Saturate commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Per-package SBOM generation for workspaces. Each package gets its own SBOM with the correct root component and full transitive dependency tree, including workspace inter-dependencies.

New flags

--out <path> writes SBOM to a file instead of stdout. Supports %s (package name) and %v (version) placeholders. When %s is present, generates one SBOM per workspace package:

pnpm sbom --sbom-format cyclonedx --out out/%s.cdx.json
pnpm sbom --sbom-format cyclonedx --out out/%s-%v.cdx.json
pnpm sbom --sbom-format cyclonedx --filter @scope/my-app --out my-app.cdx.json

--split outputs one compact JSON per line (NDJSON) to stdout, for piping into tools that process multiple SBOMs:

pnpm sbom --sbom-format cyclonedx --split
pnpm sbom --sbom-format cyclonedx --split --filter "./apps/*"

Per-package correctness

When --filter selects a single package, the SBOM root component uses that package's name, version, description, license, and author. Fields the package doesn't define fall back to the workspace root manifest.

Workspace inter-dependencies (workspace: protocol) and their transitive external dependencies are now included in the SBOM. Dev-only workspace deps are excluded with --prod. --lockfile-only skips workspace dep resolution to avoid disk reads beyond the lockfile.

Changes

  • --out and --split flags with %s/%v placeholder support
  • compact option on CycloneDX and SPDX serializers for NDJSON output
  • resolveWorkspaceDeps BFS finds workspace link deps and tracks source importer and dep type
  • SharedContext reads lockfile, root manifest, and store path once (shared across all packages in split mode)
  • Path segments sanitized to prevent traversal via crafted version strings
  • recursiveByDefault so --filter works in workspaces (bug in released pnpm, separate fix in fix(sbom): add recursiveByDefault so --filter works in workspaces #12187)

Depends on #12187.

@ultrox this should cover the per-workspace SBOM use case you described. Would be great to hear if it fits your Turborepo setup.

Summary by CodeRabbit

  • New Features

    • SBOM generation now supports --out (writes to disk using %s/version placeholders) and --split (generates per-workspace-package SBOMs; otherwise a single SBOM is produced).
    • Workspace filtering is reflected in the SBOM “root” metadata when a single package is selected.
  • Improvements

    • Workspace link: dependencies and their transitive workspace impacts are included in SBOM components/relationships.
    • SBOM outputs can be generated in more compact JSON formats; author metadata is normalized.
  • Documentation

    • Release metadata and SBOM behavior are documented, including workspace inter-dependency handling.
  • Tests

    • Expanded end-to-end coverage for workspace filtering, --prod, --lockfile-only, and output modes.

@coderabbitai

coderabbitai Bot commented Jun 1, 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e83644e-1623-4f8f-9a73-3223ac6ae8c6

📥 Commits

Reviewing files that changed from the base of the PR and between a9c9bcd and 7394078.

⛔ Files ignored due to path filters (3)
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !pnpm-lock.yaml
📒 Files selected for processing (17)
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/package.json
  • deps/compliance/commands/src/sbom/sbom.ts
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/serializeCycloneDx.ts
  • deps/compliance/sbom/src/serializeSpdx.ts
✅ Files skipped from review due to trivial changes (9)
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json
🚧 Files skipped from review as they are similar to previous changes (7)
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json
  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/serializeSpdx.ts
  • deps/compliance/sbom/src/serializeCycloneDx.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts

📝 Walkthrough

Walkthrough

This PR extends SBOM generation to support workspace-aware per-package output and workspace dependency collection. The command adds --out for writing individual SBOM files and --split for per-package NDJSON output, templates filenames with %s/%v, includes workspace: dependencies transitively, and uses filtered package metadata for the SBOM root.

Changes

SBOM workspace support with per-package output

Layer / File(s) Summary
Workspace dependency resolution & component collection
deps/compliance/sbom/src/collectComponents.ts, deps/compliance/sbom/src/index.ts
Adds WorkspacePackageInfo type and exported resolveWorkspaceDeps helper performing BFS over link: references; CollectSbomComponentsOptions supports workspacePackages and resolvedWorkspaceDeps to include workspace components with relationships and classify dev-only workspace links.
Compact JSON serialization
deps/compliance/sbom/src/serializeCycloneDx.ts, deps/compliance/sbom/src/serializeSpdx.ts
CycloneDxOptions and SpdxOptions now accept compact?: boolean to control JSON indentation; serializers conditionally apply indentation based on the flag.
CLI options and handler for split/output modes
deps/compliance/commands/src/sbom/sbom.ts
Adds --out and --split options; refactors handler to build shared context once, detect split mode via flag or %s placeholder, and route to split or single SBOM generation with file writes and NDJSON output.
Shared context, metadata extraction, and helpers
deps/compliance/commands/src/sbom/sbom.ts, deps/compliance/commands/package.json
Introduces ManifestLike and SharedContext types; buildSharedContext preloads lockfile, root manifest, and workspace manifests; generateSbomForProject derives root metadata from filtered project or root; adds helpers for license resolution, author/repository extraction, safe manifest reading, workspace package map construction, %s/%v path templating, and output containment checks; adds p-limit dependency.
Workspace test fixtures
deps/compliance/commands/test/sbom/fixtures/workspace-sbom*/, deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/
Adds two complete workspace hierarchies with root manifests, workspace configurations, and per-package manifests with workspace:* dependencies and external package references.
Test coverage
deps/compliance/commands/test/sbom/index.ts
Adds Jest tests validating --filter root component selection and workspace graph inclusion/exclusion, --prod dev-dependency filtering, --lockfile-only workspace resolution skipping, --out single/per-package file output with %s and %s-%v templating, --split NDJSON output, and split-mode validation errors.
Release notes
.changeset/sbom-workspace-root-component.md
Documents minor bumps and new SBOM behavior: per-package --out, --split NDJSON, filtered root component metadata, workspace dependency inclusion, and root fallback for author/repository/license.

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant Handler
  participant buildSharedContext
  participant resolveWorkspaceDeps
  participant collectSbomComponents
  participant Serializer
  participant FileSystem

  CLI->>Handler: parse options (--filter, --out, --split, --format)
  Handler->>buildSharedContext: load lockfile + root manifest (+storeDir)
  alt split mode (--split or %s in --out)
    Handler->>Handler: iterate workspace projects
    loop per matched project
      Handler->>resolveWorkspaceDeps: discover link: dependencies
      resolveWorkspaceDeps-->>Handler: WorkspaceLink[] + additionalImporterIds
      Handler->>collectSbomComponents: with workspacePackages + resolvedWorkspaceDeps
      collectSbomComponents-->>Handler: SBOM with workspace components
      Handler->>Serializer: SbomResult + compact/specVersion
      Serializer-->>Handler: serialized JSON
      Handler->>FileSystem: write file or emit NDJSON line
    end
  else single SBOM
    Handler->>collectSbomComponents: generate single SBOM
    collectSbomComponents-->>Handler: SBOM
    Handler->>Serializer: SbomResult + compact/specVersion
    Serializer-->>Handler: serialized JSON
    Handler->>FileSystem: write to --out or stdout
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • pnpm/pnpm#11389: Both PRs modify sbom CLI handling to validate/forward --sbom-spec-version into CycloneDX generation in deps/compliance/commands/src/sbom/sbom.ts and deps/compliance/sbom/src/serializeCycloneDx.ts.
  • pnpm/pnpm#12442: Updates workspace SBOM component collection to derive DepType from link: devOnly classification, which feeds into CycloneDX serialization logic that maps dev-only components to excluded scope.

Suggested reviewers

  • zkochan
🚥 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 PR title follows the Conventional Commits specification with 'feat' prefix and provides a clear summary of the main feature being introduced.
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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


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 force-pushed the feat/sbom-workspace-support branch from a5ccfbc to 0ba6cc8 Compare June 1, 2026 07:28
@Saturate Saturate changed the title feat(sbom): add monorepo workspace support feat(sbom): per-package SBOM generation with --out and --split Jun 4, 2026
@Saturate Saturate marked this pull request as ready for review June 4, 2026 11:37
@Saturate Saturate requested a review from zkochan as a code owner June 4, 2026 11:37
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Add per-package SBOM generation with --out and --split flags for workspaces

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add --out flag to write SBOM to file with %s and %v placeholders
• Add --split flag for NDJSON output with per-package SBOM generation
• Include workspace inter-dependencies and transitive deps in SBOM
• Use filtered package metadata as root component when --filter selects single package
• Add recursiveByDefault to enable --filter in workspace contexts
• Support --prod and --lockfile-only for workspace dependency filtering
Diagram
flowchart LR
  A["SBOM Command"] -->|"--out %s"| B["Per-Package Files"]
  A -->|"--split"| C["NDJSON to stdout"]
  A -->|"--filter"| D["Single Package Metadata"]
  D --> E["Root Component"]
  F["Workspace Deps"] --> G["Transitive Resolution"]
  G --> H["SBOM Components"]

Loading

Grey Divider

File Changes

1. deps/compliance/commands/src/sbom/sbom.ts ✨ Enhancement +261/-32

Add --out, --split flags and workspace support

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


2. deps/compliance/commands/test/sbom/index.ts 🧪 Tests +475/-0

Add comprehensive tests for workspace SBOM features

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


3. deps/compliance/sbom/src/collectComponents.ts ✨ Enhancement +125/-4

Add workspace dependency resolution and linking

deps/compliance/sbom/src/collectComponents.ts


View more (15)
4. deps/compliance/sbom/src/index.ts ✨ Enhancement +1/-1

Export workspace resolution utilities

deps/compliance/sbom/src/index.ts


5. deps/compliance/sbom/src/serializeCycloneDx.ts ✨ Enhancement +2/-1

Add compact option for NDJSON serialization

deps/compliance/sbom/src/serializeCycloneDx.ts


6. deps/compliance/sbom/src/serializeSpdx.ts ✨ Enhancement +6/-2

Add compact option for NDJSON serialization

deps/compliance/sbom/src/serializeSpdx.ts


7. .changeset/sbom-workspace-root-component.md 📝 Documentation +7/-0

Document per-package SBOM generation feature

.changeset/sbom-workspace-root-component.md


8. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json 🧪 Tests +9/-0

Add workspace fixture for app-a package

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json


9. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json 🧪 Tests +8/-0

Add workspace fixture for app-b package

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json


10. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json 🧪 Tests +8/-0

Add workspace fixture for shared-lib package

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json


11. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json 🧪 Tests +5/-0

Add workspace root fixture

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


12. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml 🧪 Tests +4/-0

Add workspace configuration fixture

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


13. deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-lock.yaml 🧪 Tests +9/-0

Add lockfile fixture for workspace

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


14. deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json 🧪 Tests +10/-0

Add workspace fixture with dev dependencies

deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json


15. deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json 🧪 Tests +7/-0

Add dev-tool workspace fixture

deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json


16. deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json 🧪 Tests +5/-0

Add workspace root fixture for dev deps

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


17. deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml 🧪 Tests +3/-0

Add workspace configuration for dev deps

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


18. deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-lock.yaml 🧪 Tests +9/-0

Add lockfile fixture for dev deps workspace

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


Grey Divider

Qodo Logo

coderabbitai[bot]
coderabbitai Bot previously requested changes Jun 4, 2026

@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: 4

🤖 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 @.changeset/sbom-workspace-root-component.md:
- Line 7: The PR adds new user-visible SBOM options but pacquet's CLI lacks a
corresponding sbom subcommand and flags—update the pacquet CLI to mirror pnpm by
adding an Sbom variant to the CliCommand enum (in
pacquet/crates/cli/src/cli_args.rs) and wire in flags matching pnpm: --out
(string with format placeholder), --split (bool), and --filter (package
selector) and ensure the command handlers that construct SBOM behavior consume
these flags and apply root-component metadata fallback logic; alternatively, if
an upstream pacquet commit already implements this parity, reference the exact
pacquet commit/PR that adds the sbom subcommand and flag wiring in the PR
description so the repo stays synchronized.

In `@deps/compliance/commands/src/sbom/sbom.ts`:
- Around line 179-192: The single-output branch writes opts.out verbatim so
placeholders like %s or %v aren't expanded; update the non-split path in the
code around serialOpts/shouldSplit/handleSplit/generateSbomForProject so it
applies the same placeholder-rendering used by split mode to produce a concrete
file path before calling fs.mkdirSync/fs.writeFileSync (i.e., compute an
expandedOutPath from opts.out using the same placeholder logic used by
handleSplit, use that expanded path for dirname creation and writing, and ensure
you don't change the split-detection logic).
- Around line 288-299: buildSharedContext currently reads rootManifest from
opts.dir (the filtered package) so license/author/repository/description
fallbacks point to the package instead of the actual workspace root; update the
code paths (where rootManifest is set and used at the return sites around
readProjectManifestOnly and later fallback logic at the blocks referenced) to
load and use rootProjectManifest and rootProjectManifestDir (or workspace root
manifest) as the fallback source when opts.dir is a workspace package—ensure
buildSharedContext and any fallback logic (including rootDescription resolution)
consistently prefer values from rootProjectManifest/rootProjectManifestDir
before using the package manifest so filtered-package runs correctly inherit
workspace root metadata.

In `@deps/compliance/sbom/src/collectComponents.ts`:
- Around line 55-62: The workspace importer deps
(workspaceDeps.additionalImporterIds) are being walked with parentPurl set to
rootPurl so direct deps of a reachable workspace package attach to the root;
change the walker initialization so each importer walk uses that importer's
component PURL as its starting parent instead of rootPurl — e.g., when building
importerWalkers via lockfileWalkerGroupImporterSteps, pass or compute the
correct parent PURL for each importerId (use the workspace component PURL
derived from workspaceDeps/importer id) so the walker functions (which reference
parentPurl) emit dependencies under the workspace package rather than the root;
apply the same fix at the other occurrence around lines 125-135.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 58b80bcf-8232-4775-9c74-ff4c86bf0918

📥 Commits

Reviewing files that changed from the base of the PR and between 54d2b57 and d003af6.

⛔ Files ignored due to path filters (2)
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (16)
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/src/sbom/sbom.ts
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/serializeCycloneDx.ts
  • deps/compliance/sbom/src/serializeSpdx.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Follow Standard Style with trailing commas, preferring functions over classes, and declaring functions after they are used (relying on hoisting)
Use a single options object instead of multiple parameters when a function needs more than two or three arguments
Follow Import Order: Standard libraries first, then external dependencies (alphabetically), then relative imports
Write self-documenting code where function names, parameters, and types explain what a function does without requiring prose comments
Do not write comments that restate what the code already says; refactor via renaming, splitting helpers, or restructuring instead
Do not repeat documentation at call sites that already exists in JSDoc on the callee; update JSDoc once for all call sites to benefit
Use JSDoc only for a function's contract (preconditions, postconditions, edge cases, why the function exists), not for re-narrating the body
Do not record past implementation shape, refactor history, or 'the previous code did X' framing in code; use git log and git blame instead
Write comments only when: the reason for code is non-obvious (hidden invariant, workaround for known bug, deliberate exception), or the right name doesn't fit (temporary technical constraint)

Files:

  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/serializeCycloneDx.ts
  • deps/compliance/sbom/src/serializeSpdx.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
🧠 Learnings (35)
📓 Common learnings
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: User-visible changes (CLI flags, defaults, environment variables, lockfile/manifest/state-file formats, error codes/messages, log emissions, store layout, hook semantics) in pnpm must be mirrored to pacquet in the same PR
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:11:04.272Z
Learning: In pacquet (pnpm/pnpm repo), `ResolvedPackage.optional` AND-folding intentionally mirrors pnpm's resolveDependencies.ts:1627-1648 revisit behavior: only the directly-visited package's `optional` flag is updated on revisit, not transitive descendants. pnpm CLI corrects stale optional flags via `copyDependencySubGraph` BFS in `lockfile/pruner/src/index.ts:160-205`. Pacquet does not yet have this pruner equivalent, so raw `node.optional` flows directly into snapshot/virtual-store via `dependencies_graph_to_lockfile.rs:409` → `create_virtual_store.rs:762` → `installability.rs:394`. A follow-up issue to port `copyDependencySubGraph` is planned.
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:11:04.272Z
Learning: In the pacquet Rust port (pnpm/pnpm repo), the `ResolvedPackage.optional` AND-folding on revisit intentionally mirrors pnpm's `resolveDependencies.ts:1627-1648` behavior: only the directly-revisited package's `optional` flag is updated; transitive descendants are not re-walked. pnpm CLI corrects stale optional flags downstream via `copyDependencySubGraph` BFS in `lockfile/pruner/src/index.ts:160-205`, which tracks a `nonOptional` set and re-stamps any package reachable by an all-non-optional path. Pacquet does not yet have this pruner equivalent, so the stale flags flow directly through `dependencies_graph_to_lockfile.rs:409` → `create_virtual_store.rs:762` → `installability.rs:394`. A follow-up to port `copyDependencySubGraph` is planned; until then, do not flag the resolver-layer optional propagation gap as a bug in pacquet PRs — it is intentional parity with pnpm's resolver layer.
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11789
File: pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs:145-146
Timestamp: 2026-05-21T00:33:05.035Z
Learning: In `pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs`, the guard `bare.starts_with("workspace:.")` is intentionally broad — matching pnpm upstream's identical `startsWith('workspace:.')` check at `resolving/npm-resolver/src/index.ts`. All dot-prefixed workspace forms including `workspace:.foo` are intentionally passed through to the local-resolver, which handles them as `link:`-style directory specs via its prefix-stripping regex. Do not suggest narrowing this to `workspace:./` or `workspace:../` only.
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: User-visible changes (CLI flags, defaults, environment variables, lockfile/manifest/state-file formats, error codes/messages, log emissions, store layout, hook semantics) in pnpm must be mirrored to pacquet in the same PR

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json
  • deps/compliance/sbom/src/index.ts
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-29T18:03:15.372Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pacquet/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:15.372Z
Learning: Match how the same feature is implemented in the TypeScript pnpm CLI — any change in pacquet must match pnpm's behavior, logic, edge cases, config resolution, error messages, file/lockfile formats, and existing tests

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Always explicitly include 'pnpm' in changeset files with appropriate version bump (patch, minor, or major)

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
📚 Learning: 2026-05-29T18:03:24.797Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pnpr/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:24.797Z
Learning: Applies to pnpr/**/pnpr/**/Cargo.toml : Declare new shared dependencies in the root [workspace.dependencies] and use { workspace = true } in pnpr crate's Cargo.toml

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
📚 Learning: 2026-05-21T00:33:05.035Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11789
File: pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs:145-146
Timestamp: 2026-05-21T00:33:05.035Z
Learning: In `pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs`, the guard `bare.starts_with("workspace:.")` is intentionally broad — matching pnpm upstream's identical `startsWith('workspace:.')` check at `resolving/npm-resolver/src/index.ts`. All dot-prefixed workspace forms including `workspace:.foo` are intentionally passed through to the local-resolver, which handles them as `link:`-style directory specs via its prefix-stripping regex. Do not suggest narrowing this to `workspace:./` or `workspace:../` only.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-29T18:03:15.372Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pacquet/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:15.372Z
Learning: Do not change lockfile format, store layout, `.npmrc` semantics, or CLI surface unless pnpm changed them first

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
📚 Learning: 2026-05-20T23:08:06.093Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:08:06.093Z
Learning: Pacquet (pnpm's Rust port) has a cardinal rule: "match pnpm exactly — do not fix pnpm quirks unless the same fix has landed in pnpm first." Review comments should not suggest behavioral deviations from upstream pnpm, even when the upstream behavior appears buggy. If a real bug is identified, it must be fixed upstream first.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
📚 Learning: 2026-05-21T00:33:05.035Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11789
File: pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs:145-146
Timestamp: 2026-05-21T00:33:05.035Z
Learning: In `pacquet/crates/resolving-npm-resolver/src/resolve_from_workspace.rs`, the guard `bare.starts_with("workspace:.")` is intentionally broad — matching pnpm upstream's identical `startsWith('workspace:.')` check. All dot-prefixed workspace forms including `workspace:.foo` are intentionally handed off to the local-resolver, which handles them as `link:`-style directory specs via its prefix-stripping regex. Do not suggest narrowing this to `workspace:./` or `workspace:../` only.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-06-01T08:59:48.719Z
Learnt from: KSXGitHub
Repo: pnpm/pnpm PR: 12093
File: pacquet/crates/cli/src/cli_args/run/recursive.rs:290-315
Timestamp: 2026-06-01T08:59:48.719Z
Learning: In pacquet's recursive run implementation (`pacquet/crates/cli/src/cli_args/run/recursive.rs`), the `pnpm-exec-summary.json` format for failed package entries correctly includes `prefix` and `message` fields in addition to `status` and `duration`. This matches pnpm's `ActionFailure` variant in `cli/utils/src/recursiveSummary.ts` and the direct serialization in `exec/commands/src/exec.ts`. There is no `ExecutionStatusInSummary` type in pnpm. The only intentional divergence is omitting the JS `error` field, whose `JSON.stringify` output is non-deterministic due to non-enumerable `Error` properties.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
📚 Learning: 2026-05-24T16:07:54.784Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11904
File: pacquet/crates/package-manager/src/install.rs:556-560
Timestamp: 2026-05-24T16:07:54.784Z
Learning: In pacquet's `is_modules_yaml_consistent` (pacquet/crates/package-manager/src/install.rs), `enableGlobalVirtualStore` is intentionally NOT checked as a separate field. Upstream pnpm's `validateModules.ts` does not persist or check `enableGlobalVirtualStore` in `.modules.yaml` either. Drift on this setting is caught indirectly: toggling `enableGlobalVirtualStore` changes `config.effective_virtual_store_dir()` (GVS-on → `<store>/v11/links`, GVS-off → `<project>/node_modules/.pnpm`), so the existing `modules.virtual_store_dir == config.effective_virtual_store_dir()` comparison in `is_modules_yaml_consistent` already detects the mismatch and prevents the short-circuit. Do not flag the absence of an explicit `enableGlobalVirtualStore` field as a bug.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Extract and refactor common code into shared packages rather than duplicating it across the monorepo

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml
  • deps/compliance/sbom/src/index.ts
📚 Learning: 2026-05-05T23:03:04.286Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11479
File: __utils__/scripts/package.json:6-9
Timestamp: 2026-05-05T23:03:04.286Z
Learning: The pattern cross-env NODE_OPTIONS="$NODE_OPTIONS ..." in package.json scripts is an established convention in the pnpm/pnpm repository and is used across many packages (e.g., fs/hard-link-dir, worker, __utils__/scripts). Do not flag this as a cross-platform issue in individual files; if a change is needed, apply it as a repo-wide change in a separate PR. Scope this guidance to all package.json files in the repo; use the minimatch pattern '**/package.json' to identify relevant files and review changes at the repository level rather than per-file.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json
  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json
📚 Learning: 2026-05-07T20:38:01.796Z
Learnt from: camcima
Repo: pnpm/pnpm PR: 11159
File: deps/compliance/license-checker/src/utils.ts:42-59
Timestamp: 2026-05-07T20:38:01.796Z
Learning: In `deps/compliance/license-checker/src/utils.ts`, `collectDirectDeps` intentionally uses name-only keys (not `nameversion`) for the shallow-depth filter. This is a deliberate design decision: being over-inclusive (auditing extra versions of a direct dep) is safer for compliance than being under-inclusive. Tightening to `nameversion` would require unsafe `(lockfile.packages as any)?.[ref]` casts through untyped lockfile internals. Do not flag this as a bug.

Applied to files:

  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-14T09:04:00.133Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11622
File: resolving/npm-resolver/test/publishedBy.test.ts:350-354
Timestamp: 2026-05-14T09:04:00.133Z
Learning: In the pnpm/pnpm repository, ESLint is the authoritative style linter. Do not raise review findings for missing trailing commas in multiline function calls (e.g., `fs.writeFileSync(...)`) when this repo’s ESLint configuration does not report them and lint passes. Prefer deferring to the ESLint results for this specific trailing-comma rule rather than enforcing it manually in code review.

Applied to files:

  • deps/compliance/sbom/src/index.ts
  • deps/compliance/sbom/src/serializeCycloneDx.ts
  • deps/compliance/sbom/src/serializeSpdx.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Version pnpm CLI patch for bug fixes, internal refactors, and changes that don't require documentation; minor for new features/settings that should be documented; major for breaking changes

Applied to files:

  • .changeset/sbom-workspace-root-component.md
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Create a changeset file in .changeset directory for changes affecting published packages, with explicit version bump types (patch, minor, major)

Applied to files:

  • .changeset/sbom-workspace-root-component.md
📚 Learning: 2026-05-24T21:11:04.272Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:11:04.272Z
Learning: In the pacquet Rust port (pnpm/pnpm repo), the `ResolvedPackage.optional` AND-folding on revisit intentionally mirrors pnpm's `resolveDependencies.ts:1627-1648` behavior: only the directly-revisited package's `optional` flag is updated; transitive descendants are not re-walked. pnpm CLI corrects stale optional flags downstream via `copyDependencySubGraph` BFS in `lockfile/pruner/src/index.ts:160-205`, which tracks a `nonOptional` set and re-stamps any package reachable by an all-non-optional path. Pacquet does not yet have this pruner equivalent, so the stale flags flow directly through `dependencies_graph_to_lockfile.rs:409` → `create_virtual_store.rs:762` → `installability.rs:394`. A follow-up to port `copyDependencySubGraph` is planned; until then, do not flag the resolver-layer optional propagation gap as a bug in pacquet PRs — it is intentional parity with pnpm's resolver layer.

Applied to files:

  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/sbom/src/collectComponents.ts
📚 Learning: 2026-05-24T21:11:04.272Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11915
File: pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs:553-617
Timestamp: 2026-05-24T21:11:04.272Z
Learning: In pacquet (pnpm/pnpm repo), `ResolvedPackage.optional` AND-folding intentionally mirrors pnpm's resolveDependencies.ts:1627-1648 revisit behavior: only the directly-visited package's `optional` flag is updated on revisit, not transitive descendants. pnpm CLI corrects stale optional flags via `copyDependencySubGraph` BFS in `lockfile/pruner/src/index.ts:160-205`. Pacquet does not yet have this pruner equivalent, so raw `node.optional` flows directly into snapshot/virtual-store via `dependencies_graph_to_lockfile.rs:409` → `create_virtual_store.rs:762` → `installability.rs:394`. A follow-up issue to port `copyDependencySubGraph` is planned.

Applied to files:

  • .changeset/sbom-workspace-root-component.md
  • deps/compliance/sbom/src/collectComponents.ts
📚 Learning: 2026-05-26T21:01:06.666Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11966
File: .changeset/require-tarball-integrity.md:6-6
Timestamp: 2026-05-26T21:01:06.666Z
Learning: In pnpm lockfile-related release notes/docs (especially changeset markdown), preserve URL hostnames exactly as they appear in pnpm-lock.yaml tarball resolution entries—keep hosts like `codeload.github.com`, `bitbucket.org`, and `gitlab.com` in lowercase. Do not “correct” them to title-case/preserve brand capitalization (e.g., LanguageTool rules like `GITHUB` capitalization) because these are literal URL fragments, not platform brand names.

Applied to files:

  • .changeset/sbom-workspace-root-component.md
📚 Learning: 2026-05-19T19:23:00.981Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11752
File: pacquet/crates/config/src/lib.rs:1062-1073
Timestamp: 2026-05-19T19:23:00.981Z
Learning: In `pacquet/crates/config/src/lib.rs`, `modules_dir` does not need a `!virtual_store_dir_explicit` guard on its workspace re-anchor because `modules_dir` is in pnpm's `excludedPnpmKeys` (filtered out by `WorkspaceSettings::clear_workspace_only_fields`) and therefore can only be set by workspace yaml (applied immediately after) or env vars (applied later in the cascade) — not by global `config.yaml`. `virtual_store_dir`, by contrast, IS settable from global config and requires the `if !virtual_store_dir_explicit` guard to survive the workspace-root re-anchor.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-06-02T14:39:24.423Z
Learnt from: KSXGitHub
Repo: pnpm/pnpm PR: 11938
File: pacquet/crates/config/src/lib.rs:959-966
Timestamp: 2026-06-02T14:39:24.423Z
Learning: In the pnpm/pnpm pacquet Rust port (`pacquet/crates/config/src/lib.rs`), `Config.extra_bin_paths` is intentionally left empty (`Vec::new()`) until workspace fan-out support for `run`/`exec` is implemented. It mirrors pnpm's `Config.extraBinPaths` (the workspace-root `node_modules/.bin`), which is also empty outside a workspace. Populating it before the workspace-root is resolved would put a non-existent path on `PATH`, so it should only be derived once workspace support lands. Do not flag this as a bug or missing derivation in reviews.

Applied to files:

  • deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml
📚 Learning: 2026-06-02T13:18:30.659Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 12134
File: pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs:311-325
Timestamp: 2026-06-02T13:18:30.659Z
Learning: In pacquet's lockfile resolution verifier (`pacquet/crates/resolving-npm-resolver/src/create_npm_resolution_verifier.rs`), URL-keyed tarball dependencies do NOT need a separate `non_semver_version` field in `VerifyCtx`. Unlike the TypeScript side (which derives `version` from `snapshot.version` and threads `nonSemverVersion` separately), pacquet's `collect_candidates` takes `version` from the lockfile key suffix. For a URL-keyed dep the key is `name@<url>`, so `ctx.version` is the URL string, which fails `node_semver::Version::parse(ctx.version)` and the existing guard `if node_semver::Version::parse(ctx.version).is_err() { return ResolutionVerification::Ok; }` already skips the registry lookup correctly. Adding a `non_semver_version` field to `VerifyCtx` for this purpose would be inert.

Applied to files:

  • deps/compliance/sbom/src/collectComponents.ts
📚 Learning: 2026-05-29T18:03:15.372Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pacquet/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:15.372Z
Learning: Applies to pacquet/**/tests/**/*.rs : Port relevant pnpm tests to Rust tests whenever they translate when porting behavior from pnpm

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-20T21:18:56.391Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11778
File: pacquet/crates/resolving-local-resolver/tests/resolve.rs:365-372
Timestamp: 2026-05-20T21:18:56.391Z
Learning: In `pacquet/crates/resolving-local-resolver/tests/resolve.rs`, the test `fail_when_resolving_from_not_existing_directory_an_injected_dependency` intentionally uses `injected: false`. The test is a verbatim port of the upstream pnpm TypeScript test (resolving/local-resolver/test/index.ts at ef87f3ccff). The `injected` flag only affects the file/link protocol choice for plain directory paths; when the `file:` scheme is explicit in the bare specifier, the flag has no effect on the resolution code path. The misleading test name is inherited from upstream.

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-05T23:03:30.044Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11479
File: __utils__/scripts/package.json:6-9
Timestamp: 2026-05-05T23:03:30.044Z
Learning: In the pnpm/pnpm repository, the pattern `cross-env NODE_OPTIONS="$NODE_OPTIONS ..." jest` is an intentional, established convention used across many package.json files (e.g., `fs/hard-link-dir`, `worker`, `__utils__/scripts`). Do not flag this as a cross-platform issue in individual files; any fix should be applied repo-wide as a separate change.

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Applies to **/*.test.{ts,tsx} : Use util.types.isNativeError() instead of instanceof Error for error type checking in Jest tests

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-25T12:36:42.202Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-05-25T12:36:42.202Z
Learning: Build the pnpm bundle (pnpm/dist/pnpm.mjs) by running `pnpm --filter pnpm run compile` before running e2e tests

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-23T16:55:36.507Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11878
File: pacquet/crates/cli/tests/lockfile_verification.rs:158-162
Timestamp: 2026-05-23T16:55:36.507Z
Learning: In `pacquet/crates/cli/tests/lockfile_verification.rs`, the `trust_lockfile_skips_verification` and `trust_lockfile_cli_flag_skips_verification` tests intentionally do NOT assert `output.status.success()`. The hand-rolled fixture lockfile uses a placeholder integrity hash (`sha512-AAA…`), so the install always fails the downstream tarball integrity check regardless of the supply-chain gate. The contract being tested is "gate-skipped, not install-succeeded"; asserting success would require generating a real lockfile via the `generate_lockfile` pattern (see `hoist.rs`) which is considered not worth the extra wiring for an opt-out smoke test.

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-29T18:03:15.372Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pacquet/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:15.372Z
Learning: Applies to pacquet/**/*.rs : Tests are documentation — do not duplicate test scenarios, edge cases, failure modes, or worked examples in prose when they are already captured by tests

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-24T08:18:06.019Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11895
File: pnpm/test/deploy.ts:91-95
Timestamp: 2026-05-24T08:18:06.019Z
Learning: In the pnpm/pnpm repository, integration tests that hit the real `registry.npmjs.org` (e.g., for `pacquet` or `pnpm/pacquet`) do NOT use a runtime env-var gate (such as `PNPM_RUN_PUBLIC_REGISTRY_TESTS`). They simply pass `--config.registry=https://registry.npmjs.org/` directly to `execPnpm` and set a higher timeout. This is the established pattern, as seen in `pnpm/test/install/pacquet.ts` and `pnpm/test/deploy.ts`. Do not suggest adding env-var guards for these tests.

Applied to files:

  • deps/compliance/commands/test/sbom/index.ts
📚 Learning: 2026-05-20T21:18:55.266Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11778
File: pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs:253-278
Timestamp: 2026-05-20T21:18:55.266Z
Learning: In `pacquet/crates/resolving-local-resolver/src/parse_bare_specifier.rs`, the `resolve_path` function intentionally short-circuits absolute specifiers verbatim (returns them unchanged without normalizing `..` components), mirroring the upstream TypeScript `resolvePath` in `resolving/local-resolver/src/parseBareSpecifier.ts` at ef87f3ccff. The OS resolves `..` at `fs.read` time. Do not suggest normalizing the absolute branch — it would invent behavior pnpm doesn't have, violating the pacquet AGENTS.md cardinal rule of fidelity to upstream.

Applied to files:

  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-29T18:03:15.372Z
Learnt from: CR
Repo: pnpm/pnpm PR: 0
File: pacquet/AGENTS.md:0-0
Timestamp: 2026-05-29T18:03:15.372Z
Learning: Do not add features, flags, or behaviors that pnpm does not have

Applied to files:

  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-26T18:31:14.579Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11967
File: .changeset/git-fetcher-reject-non-sha-commits.md:2-2
Timestamp: 2026-05-26T18:31:14.579Z
Learning: In the pnpm monorepo, the `fetching/` directory contains multiple separate npm packages each with their own scoped name using a dot-separator convention, e.g., `pnpm/fetching.git-fetcher` (declared in `fetching/git-fetcher/package.json`) and `pnpm/fetching.tarball-fetcher`. There is no top-level `pnpm/fetching` package. Changesets targeting the git-fetcher should use `"pnpm/fetching.git-fetcher"`.

Applied to files:

  • deps/compliance/commands/src/sbom/sbom.ts
📚 Learning: 2026-05-20T22:49:17.652Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11787
File: pacquet/crates/catalogs-resolver/src/lib.rs:156-167
Timestamp: 2026-05-20T22:49:17.652Z
Learning: In pacquet's `catalogs-resolver` crate (`pacquet/crates/catalogs-resolver/src/lib.rs`), the protocol detection pattern `catalog_lookup.split(':').next().unwrap_or("")` is an intentional byte-for-byte port of pnpm's upstream JavaScript `getProtocol`/split logic in `catalogs/resolver/src/resolveFromCatalog.ts#L95`. A bare value like `"workspace"` (without a colon) is deliberately classified as the `"workspace"` protocol, matching upstream behavior. pacquet's cardinal rule is to match upstream pnpm behavior including quirks; any behavioral change must land in pnpm first and then be ported here.

Applied to files:

  • deps/compliance/commands/src/sbom/sbom.ts
🔇 Additional comments (10)
deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/app/package.json (1)

1-10: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/dev-tool/package.json (1)

1-7: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/package.json (1)

1-5: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom-dev/pnpm-workspace.yaml (1)

1-3: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-a/package.json (1)

1-9: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/app-b/package.json (1)

1-8: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/package.json (1)

1-5: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/pnpm-workspace.yaml (1)

1-4: LGTM!

deps/compliance/commands/test/sbom/fixtures/workspace-sbom/shared-lib/package.json (1)

1-8: LGTM!

deps/compliance/commands/test/sbom/index.ts (1)

2-2: LGTM!

Also applies to: 11-11, 282-754

Comment thread .changeset/sbom-workspace-root-component.md
Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
Comment thread deps/compliance/sbom/src/collectComponents.ts Outdated
@zkochan zkochan force-pushed the feat/sbom-workspace-support branch from 7268a44 to 106c8ca Compare June 16, 2026 21:52
@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 (11) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Silent manifest read failure 🐞 Bug ≡ Correctness
Description
readManifestSafe() swallows all manifest read errors and buildWorkspacePackagesMap() then omits that
workspace package, but the lockfile walker can still traverse that importer ID via
additionalImporterIds. This can produce an SBOM that includes dependencies from the workspace
package while attributing them to the root (and omitting the workspace package
component/relationship entirely).
Code

deps/compliance/commands/src/sbom/sbom.ts[R499-504]

+async function readManifestSafe (dir: string): Promise<{ name?: string, version?: string, license?: string, description?: string, author?: string | { name?: string }, repository?: string | { url?: string } } | undefined> {
+  try {
+    return await readProjectManifestOnly(dir)
+  } catch {
+    return undefined
+  }
Evidence
Manifest read errors are converted into undefined, which causes the workspace package to be
dropped from the workspacePackages map; however, traversal still uses additionalImporterIds, and
when package info is missing the walker falls back to rootPurl as the parent, misattributing
relationships.

deps/compliance/commands/src/sbom/sbom.ts[475-485]
deps/compliance/commands/src/sbom/sbom.ts[499-504]
deps/compliance/sbom/src/collectComponents.ts[55-60]
deps/compliance/sbom/src/collectComponents.ts[129-137]

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 workspace dependency resolution is enabled, `buildWorkspacePackagesMap()` relies on reading each reachable workspace package manifest. If a manifest read fails, `readManifestSafe()` returns `undefined`, the package is dropped from `workspacePackages`, but `collectSbomComponents()` may still walk that importer’s dependency tree (because it’s in `resolvedWorkspaceDeps.additionalImporterIds`). The result is a structurally incorrect SBOM (deps attributed to root; missing workspace component).
### Issue Context
This occurs in the new workspace dependency flow:
- `generateSbomForProject()` resolves workspace link importers and passes both `resolvedWorkspaceDeps` and `workspacePackages` to `collectSbomComponents()`.
- `collectSbomComponents()` walks `allImporterIds` regardless of whether corresponding `workspacePackages` entries exist.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[459-505]
- deps/compliance/sbom/src/collectComponents.ts[55-60]
- deps/compliance/sbom/src/collectComponents.ts[129-147]
### Suggested fix
Choose one consistent strategy:
1) **Fail loudly (preferred for correctness):** if a reachable importer manifest cannot be read/parsed, throw a `PnpmError` that names the importerId/path and explains SBOM would be incomplete.
2) **Degrade gracefully but consistently:** if manifest can’t be read, do not traverse that importer in `collectSbomComponents` (filter `additionalImporterIds` to those present in `workspacePackages`), and optionally emit a warning that workspace deps were skipped.
Avoid the current mixed state where the importer is traversed but the workspace package node/edge is missing.

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


2. Lockfile path traversal read ✓ Resolved 🐞 Bug ⛨ Security
Description
buildWorkspacePackagesMap() reads manifests from path.join(lockfileDir, importerId) where
importerId ultimately comes from lockfile-derived workspace link traversal, so a crafted
pnpm-lock.yaml can cause pnpm sbom to read package manifests outside the workspace root. This can
disclose metadata from arbitrary accessible directories (that contain a parseable package manifest)
into SBOM output when sbom runs in CI/automation against untrusted repos.
Code

deps/compliance/commands/src/sbom/sbom.ts[R461-468]

+  const entries = await Promise.all(
+    reachableImporterIds.map(async (importerId): Promise<[ProjectId, WorkspacePackageInfo] | null> => {
+      const selected = selectedEntriesMap.get(importerId)
+      const manifest = selected
+        ? selected.manifest
+        : await readManifestSafe(path.join(lockfileDir, importerId))
+
+      if (!manifest?.name || !manifest.version) return null
Evidence
The code derives additional importer IDs from normalized link: paths in the lockfile and then
reads manifests by joining those importer IDs onto lockfileDir, which can escape the workspace
root if the lockfile contains traversal segments. The manifest reader reads package.json from
whatever directory it is given.

deps/compliance/commands/src/sbom/sbom.ts[447-469]
deps/compliance/sbom/src/collectComponents.ts[227-269]
workspace/project-manifest-reader/src/index.ts[54-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
`buildWorkspacePackagesMap()` constructs manifest paths with `path.join(lockfileDir, importerId)`. Because `importerId` can be influenced via lockfile contents (workspace `link:` graph traversal), a malicious lockfile can introduce `..` segments or absolute paths that escape `lockfileDir`, leading to out-of-workspace manifest reads.
### Issue Context
- `resolveWorkspaceDeps()` normalizes `link:` targets with `path.posix.normalize(...)`, which can collapse `../` sequences into traversal.
- `readProjectManifestOnly()` reads `path.join(projectDir, 'package.json')`, so an escaped `projectDir` becomes an arbitrary filesystem read of a `package.json`-like file.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[447-469]
- deps/compliance/sbom/src/collectComponents.ts[227-269]
### Suggested fix approach
1. In `resolveWorkspaceDeps()`, reject `targetId` values that are absolute, start with `..`, or contain `..` path segments after normalization.
2. In `buildWorkspacePackagesMap()`, resolve `abs = path.resolve(lockfileDir, importerId)` and enforce `path.relative(lockfileDir, abs)` does not start with `..` and is not absolute; otherwise skip or throw a PnpmError.
3. Add a regression test with a crafted lockfile importer key containing `../` to ensure the command refuses to read outside `lockfileDir`.

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


3. Out path traversal ✓ Resolved 🐞 Bug ⛨ Security
Description
--out builds filePath by substituting %s/%v and only strips path separators, so a crafted
package version like .. can escape the intended output directory when the pattern uses
directories around placeholders (e.g. out/%v/%s.json). This enables overwriting arbitrary files
writable by the current user when running pnpm sbom in automation/CI against an untrusted
workspace.
Code

deps/compliance/commands/src/sbom/sbom.ts[R246-256]

+    if (opts.out) {
+      const filePath = opts.out
+        .replaceAll('%s', sanitizePathSegment(sanitizePackageName(manifest.name)))
+        .replaceAll('%v', sanitizePathSegment(manifest.version ?? '0.0.0'))
+      const fileDir = path.dirname(filePath)
+      if (!createdDirs.has(fileDir)) {
+        fs.mkdirSync(fileDir, { recursive: true })
+        createdDirs.add(fileDir)
+      }
+      fs.writeFileSync(filePath, output)
+      files.push(filePath)
Evidence
The output path is constructed from %s/%v replacements and written to disk; the sanitizer only
replaces separator-like characters and does not prevent .. from being used as a directory name
when the template itself includes / around the placeholder.

deps/compliance/commands/src/sbom/sbom.ts[246-256]
deps/compliance/commands/src/sbom/sbom.ts[492-494]

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

## Issue description
`--out` supports `%s` and `%v` placeholders, but `sanitizePathSegment()` only replaces path separator characters. If a workspace package has `version: ".."` (or `"."`) and the output template places `%v` as its own directory segment (e.g. `out/%v/%s.json`), the resolved path becomes `out/../...` and writes outside the intended output directory.
### Issue Context
This affects both single-SBOM `--out` and workspace split output (implicit split when `--out` includes `%s`, or explicit `--split`).
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[246-256]
- deps/compliance/commands/src/sbom/sbom.ts[188-194]
- deps/compliance/commands/src/sbom/sbom.ts[488-494]
### Suggested fix
- Make `sanitizePathSegment()` explicitly reject or neutralize `.` and `..` (and potentially empty/whitespace-only) segments.
- After substitution, normalize the path and fail if any path segment equals `.` or `..` (or if the normalized path escapes a user-intended base directory, if you decide to define one).
- Add a regression test that uses an output template with directories (e.g. `out/%v/%s.json`) and a manifest version of `..` to ensure the command errors instead of writing outside the target tree.

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


View more (2)
4. Wrong root manifest ✓ Resolved 🐞 Bug ≡ Correctness
Description
buildSharedContext() reads rootManifest from opts.dir, so when pnpm is invoked from a
workspace subdirectory (or with -C/--dir), the fallback metadata (author/repository/license) comes
from the wrong manifest and license scanning can target the wrong directory. This contradicts the
intended behavior that missing fields should fall back to the workspace root manifest.
Code

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

+async function buildSharedContext (opts: SbomCommandOptions): Promise<SharedContext> {
const lockfile = await readWantedLockfile(opts.lockfileDir ?? opts.dir, {
ignoreIncompatible: true,
})
Evidence
The command receives rootProjectManifest/rootProjectManifestDir from config, and the PR claims
fallback should come from the root manifest; however the shared context always reads the manifest
from opts.dir. Config reader defines rootProjectManifestDir as lockfileDir/workspaceDir/dir,
which is the correct workspace-root anchor.

deps/compliance/commands/src/sbom/sbom.ts[48-53]
deps/compliance/commands/src/sbom/sbom.ts[278-301]
config/reader/src/index.ts[417-420]
.changeset/sbom-workspace-root-component.md[7-7]

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()` uses `readProjectManifestOnly(opts.dir)` to populate `rootManifest`. In workspaces, `opts.dir` may be a package subdirectory (or set via `-C/--dir`), so the "root" manifest used for fallback (and `LICENSE` scanning) is not the workspace root.
### Issue Context
The PR explicitly states that author/repository/license should fall back to the root manifest when missing in the selected package.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[272-302]
- config/reader/src/index.ts[417-420]
### Suggested fix
- Prefer `opts.rootProjectManifest` / `opts.rootProjectManifestDir` when building shared context, and only fall back to reading from disk if `rootProjectManifest` is undefined.
- Example: `const rootManifest = opts.rootProjectManifest ?? await readProjectManifestOnly(opts.rootProjectManifestDir)`.
- Ensure `resolveRootLicense(..., dir)` uses `opts.rootProjectManifestDir` for the fallback license scan directory (not `opts.dir`).
- Add a test that runs sbom from a subdir (`dir` != workspace root) and asserts fallback fields come from the workspace root manifest.

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


5. Lockfile-only still traverses ✓ Resolved 🐞 Bug ≡ Correctness
Description
collectSbomComponents() always calls resolveWorkspaceDeps() when resolvedWorkspaceDeps is
undefined, so --lockfile-only still discovers link: importers and can traverse their dependency
trees. This violates the intended --lockfile-only behavior of skipping workspace dependency
resolution and can include extra components/work beyond the selected importer(s).
Code

deps/compliance/sbom/src/collectComponents.ts[R55-60]

+  const workspaceDeps = opts.resolvedWorkspaceDeps ?? resolveWorkspaceDeps(opts.lockfile, importerIds, opts.include)
+  const allImporterIds = [...importerIds, ...workspaceDeps.additionalImporterIds]
+
const importerWalkers = lockfileWalkerGroupImporterSteps(
opts.lockfile,
-    importerIds,
+    allImporterIds,
Evidence
The command-side code disables workspace dep resolution for lockfileOnly, but the library side
recomputes it anyway via opts.resolvedWorkspaceDeps ?? resolveWorkspaceDeps(...). The test comment
also documents the expected behavior that workspace deps are not resolved in lockfile-only mode.

deps/compliance/commands/src/sbom/sbom.ts[347-353]
deps/compliance/sbom/src/collectComponents.ts[55-60]
deps/compliance/commands/test/sbom/index.ts[554-558]

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

## Issue description
In lockfile-only mode, `generateSbomForProject()` deliberately sets `resolvedWorkspaceDeps` to `undefined` to skip workspace dependency resolution. However, `collectSbomComponents()` treats `undefined` as a signal to compute `resolveWorkspaceDeps()` itself, making the lockfile-only guard ineffective.
### Issue Context
This can cause traversal of linked workspace importers and inclusion of their external dependencies even when the CLI/test expectation is that workspace deps are not resolved in lockfile-only mode.
### Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[47-62]
- deps/compliance/commands/src/sbom/sbom.ts[347-360]
- deps/compliance/commands/test/sbom/index.ts[554-558]
### Suggested fix
- Only resolve workspace deps inside `collectSbomComponents()` when explicitly requested:
- e.g. `const workspaceDeps = opts.resolvedWorkspaceDeps ?? (opts.workspacePackages ? resolveWorkspaceDeps(...) : { links: [], additionalImporterIds: [] })`.
- Or add an explicit `resolveWorkspaceLinks?: boolean` flag and set it to false for `--lockfile-only`.
- Add/extend a test to ensure lockfile-only does not include transitive external deps reachable only through workspace links.

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



Remediation recommended

6. Out %s needs workspace 🐞 Bug ≡ Correctness
Description
handler() treats any --out path containing %s as split mode and routes to handleSplit(),
which throws SBOM_NO_PROJECTS when no workspace project graph is present. This makes %s
placeholder substitution unusable for single-project repos despite the help text advertising %s/%v
placeholders for --out.
Code

deps/compliance/commands/src/sbom/sbom.ts[R179-218]

+  const ctx = await buildSharedContext(opts)
+  const serialOpts = { format, sbomType, sbomSpecVersion }
+  const shouldSplit = opts.split || (opts.out != null && opts.out.includes('%s'))
+
+  if (shouldSplit) {
+    return handleSplit(opts, serialOpts, ctx)
+  }
+
+  const { output, rootName, rootVersion } = await generateSbomForProject(opts, serialOpts, ctx)
+
+  if (opts.out) {
+    const filePath = opts.out
+      .replaceAll('%s', sanitizePathSegment(sanitizePackageName(rootName)))
+      .replaceAll('%v', sanitizePathSegment(rootVersion))
+    fs.mkdirSync(path.dirname(filePath), { recursive: true })
+    fs.writeFileSync(filePath, output)
+    return { output: filePath, exitCode: 0 }
+  }
+
+  return { output, exitCode: 0 }
+}
+
+interface SerializeOptions {
+  format: SbomFormat
+  sbomType: SbomComponentType
+  sbomSpecVersion: string | undefined
+}
+
+async function handleSplit (
+  opts: SbomCommandOptions,
+  serialOpts: SerializeOptions,
+  ctx: SharedContext
+): Promise<{ output: string, exitCode: number }> {
+  const projectsGraph = opts.selectedProjectsGraph ?? opts.allProjectsGraph
+  if (!projectsGraph) {
+    throw new PnpmError(
+      'SBOM_NO_PROJECTS',
+      'No workspace projects found. --split requires a workspace.'
+    )
+  }
Evidence
The help text documents %s/%v as general placeholders for --out, but the implementation forces
split mode whenever %s appears and then errors if no workspace graph exists. Outside of recursive
workspace mode, pnpm does not populate selectedProjectsGraph/allProjectsGraph, so this path will
throw for single-project repos.

deps/compliance/commands/src/sbom/sbom.ts[132-138]
deps/compliance/commands/src/sbom/sbom.ts[179-218]
pnpm/src/main.ts[248-306]

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

## Issue description
`--out` supports `%s` (name) and `%v` (version) placeholders, but the current logic treats any `--out` containing `%s` as “split mode” and requires a workspace projects graph, throwing in non-workspace/single-package repos.
### Issue Context
This is caused by `shouldSplit` being computed purely from `opts.split` or `opts.out.includes('%s')`, and `handleSplit()` hard-requiring `selectedProjectsGraph`/`allProjectsGraph`.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[179-218]
### Suggested fix
- Change `shouldSplit` to only auto-enable split mode for `%s` when a workspace graph is actually present (e.g. `opts.selectedProjectsGraph || opts.allProjectsGraph`).
- In non-workspace mode, keep the single-SBOM path and allow `%s/%v` to be interpolated from `rootName/rootVersion`.

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


7. Prototype importer IDs accepted 🐞 Bug ⛨ Security
Description
resolveWorkspaceDeps() uses targetId in lockfile.importers and direct indexing, which consults
the prototype chain; inherited keys like toString/constructor can be treated as “present” even
if not in the lockfile. With a crafted link: reference, this can enqueue unintended importer IDs
and trigger extra traversal and manifest read attempts that were supposed to be gated on lockfile
importer existence.
Code

deps/compliance/sbom/src/collectComponents.ts[R246-274]

+  for (let head = 0; head < queue.length; head++) {
+    const importerId = queue[head]
+    const snapshot = lockfile.importers[importerId]
+    if (!snapshot) continue
+
+    const devDepNames = new Set(Object.keys(snapshot.devDependencies ?? {}))
+    const prodDeps = {
+      ...(include?.dependencies !== false ? snapshot.dependencies : {}),
+      ...(include?.optionalDependencies !== false ? snapshot.optionalDependencies : {}),
+    }
+    const allDeps: Record<string, string> = {
+      ...prodDeps,
+      ...(include?.devDependencies !== false ? snapshot.devDependencies : {}),
+    }
+
+    for (const [depName, reference] of Object.entries(allDeps)) {
+      if (!reference.startsWith('link:')) continue
+
+      const linkPath = reference.slice(5)
+      const targetId = path.posix.normalize(
+        importerId === ('.' as ProjectId) ? linkPath : path.posix.join(importerId, linkPath)
+      ) as ProjectId
+
+      // A crafted lockfile can point a `link:` target outside the workspace root;
+      // such importer IDs must never be followed, as they later become filesystem reads.
+      if (path.posix.isAbsolute(targetId) || targetId === '..' || targetId.startsWith('../')) continue
+
+      if (!(targetId in lockfile.importers)) continue
+
Evidence
resolveWorkspaceDeps() explicitly uses in to test membership in lockfile.importers, which
includes prototype-chain properties. The lockfile reader/converter builds importers as a standard
JS object (not null-prototype), so inherited keys exist and can satisfy in.

deps/compliance/sbom/src/collectComponents.ts[236-283]
lockfile/fs/src/lockfileFormatConverters.ts[134-161]

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

## Issue description
`resolveWorkspaceDeps()` checks importer existence with the `in` operator and reads importers via `lockfile.importers[importerId]`. Because `lockfile.importers` is a normal object, `in` returns true for inherited keys (prototype chain), allowing unintended importer IDs to be followed.
### Issue Context
Lockfiles are repo-controlled/untrusted inputs. Even if impact is limited to spurious traversal/reads, this is avoidable and makes behavior depend on `Object.prototype`.
### Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[236-283]
### Suggested fix
- Replace `targetId in lockfile.importers` with an own-property check, e.g. `Object.prototype.hasOwnProperty.call(lockfile.importers, targetId)`.
- Similarly, when pulling `snapshot`, consider skipping importers unless they are own-properties of `lockfile.importers` (or ensure the importers object is created with a null prototype upstream).

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


8. Redundant license dir probing 🐞 Bug ➹ Performance
Description
resolveRootLicense() unconditionally calls resolveLicenseFromDir(), which probes all common LICENSE
filenames via filesystem access checks even when the manifest already declares a usable license; the
new per-package generation path calls this once per emitted package (and may also fall back to the
workspace-root manifest). On large workspaces, this multiplies filesystem I/O on the new --split /
--out %s hot path and can noticeably slow SBOM generation.
Code

deps/compliance/commands/src/sbom/sbom.ts[R439-444]

+async function resolveRootLicense (manifest: Parameters<typeof resolveLicenseFromDir>[0]['manifest'], dir: string): Promise<string | undefined> {
+  const info = await resolveLicenseFromDir({ manifest, dir })
+  if (info && info.name !== 'Unknown' && (!info.licenseFile || isSpdxLicenseExpression(info.name))) {
+    return info.name
+  }
+  return undefined
Evidence
The per-project SBOM code calls resolveRootLicense() for each package root component, and
resolveRootLicense() delegates to resolveLicenseFromDir(). The resolver implementation shows it
always probes each candidate LICENSE filename with access() before consulting the manifest, so
repeated calls scale filesystem work with the number of emitted SBOMs.

deps/compliance/commands/src/sbom/sbom.ts[344-347]
deps/compliance/commands/src/sbom/sbom.ts[439-444]
deps/compliance/license-resolver/src/index.ts[180-191]

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

## Issue description
`resolveRootLicense()` always calls `resolveLicenseFromDir()`, which performs filesystem probes for each candidate LICENSE filename. With per-package SBOM generation this happens once per package, even when the package manifest already provides a usable SPDX license value.
## Issue Context
`resolveLicenseFromDir()` probes `LICENSE_FILES` using `access()` before it calls `resolveLicense()` (which is where the manifest license is actually consulted). This makes per-package mode add avoidable filesystem I/O.
## Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[344-347]
- deps/compliance/commands/src/sbom/sbom.ts[439-444]
- deps/compliance/license-resolver/src/index.ts[180-191]
## Suggested fix
1. In `resolveRootLicense()`, fast-path when the manifest already has a license string that is acceptable for SBOM output (e.g. valid SPDX expression and not a `SEE LICENSE` sentinel), and only call `resolveLicenseFromDir()` when you actually need to scan disk.
2. (Optional but impactful for split mode) Cache the workspace-root resolved license in `SharedContext` so the fallback `resolveRootLicense(rootManifest, rootManifestDir)` doesn’t re-probe the same directory for every package.

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


View more (9)
9. Quadratic collision check 🐞 Bug ➹ Performance
Description
In handleSplit(), output-path collision detection uses files.includes(filePath) inside the
per-package loop, making split file output O(n²) in the number of emitted workspace packages. Large
workspaces can see avoidable CPU time in the new --split --out/--out ...%s... hot path.
Code

deps/compliance/commands/src/sbom/sbom.ts[R254-259]

+      if (files.includes(filePath)) {
+        throw new PnpmError(
+          'SBOM_OUT_PATH_COLLISION',
+          `Multiple workspace packages resolve to the same output path "${filePath}". Include %v in the --out pattern to disambiguate.`
+        )
+      }
Evidence
The new split file-output path stores generated paths in an array and performs a linear membership
check (includes) for every package before pushing the new path, leading to quadratic behavior as
the workspace size grows.

deps/compliance/commands/src/sbom/sbom.ts[227-266]

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

## Issue description
`handleSplit()` tracks generated file paths in an array and uses `includes()` to detect collisions, which makes the check linear per package and quadratic overall.
### Issue Context
This code runs for `--split --out ...` and also for `--out` patterns containing `%s` (since that implies split mode). On large workspaces, this becomes a noticeable overhead.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[227-266]
### Suggested fix
- Replace `const files: string[] = []` + `files.includes(filePath)` with `const files = new Set<string>()`.
- Use `if (files.has(filePath)) throw ...;` then `files.add(filePath)`.
- If you still need stable ordering for the final summary, keep a separate `filesList: string[]` only for printing, while using the Set for membership.

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


10. Split repeats workspace scanning 🐞 Bug ➹ Performance
Description
In split mode, handleSplit() calls generateSbomForProject() once per workspace package, and each
call re-runs workspace-link resolution (resolveWorkspaceDeps) and rebuilds a workspace package
manifest map from disk. Runtime and I/O scale with (number of emitted packages) × (size of each
package’s reachable workspace-link closure), which can make pnpm sbom --split/per-package --out
slow on large monorepos.
Code

deps/compliance/commands/src/sbom/sbom.ts[R233-245]

+  for (const [dir, entry] of entries) {
+    const manifest = entry.package.manifest
+    if (!manifest.name) continue
+
+    const singleProjectGraph = { [dir as keyof typeof projectsGraph]: entry }
+
+    // eslint-disable-next-line no-await-in-loop
+    const { output } = await generateSbomForProject(
+      { ...opts, selectedProjectsGraph: singleProjectGraph as typeof projectsGraph, allProjectsGraph: undefined, split: false, out: undefined },
+      serialOpts,
+      ctx,
+      compact
+    )
Evidence
The split loop invokes SBOM generation per project, and SBOM generation recomputes workspace-link
reachability and (when not lockfile-only) reads workspace manifests to build metadata, which is
repeated for every emitted SBOM.

deps/compliance/commands/src/sbom/sbom.ts[233-245]
deps/compliance/commands/src/sbom/sbom.ts[361-374]
deps/compliance/commands/src/sbom/sbom.ts[459-494]

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

## Issue description
`--split` iterates workspace packages and generates SBOMs one-by-one. Each iteration recomputes workspace-link reachability and rebuilds workspace package metadata by reading manifests, creating repeated work across outputs.
### Issue Context
- `handleSplit()` loops all projects and calls `generateSbomForProject()` for each.
- `generateSbomForProject()` runs `resolveWorkspaceDeps()` and then `buildWorkspacePackagesMap()`, which may read many manifests from disk.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[233-245]
- deps/compliance/commands/src/sbom/sbom.ts[361-374]
- deps/compliance/commands/src/sbom/sbom.ts[459-494]
### Suggested fix
Implement reuse across split iterations, for example:
- Build a global `workspacePackages` map once from `projectsGraph` (it already contains manifests for all workspace packages) and pass it down, so you don’t re-read manifests per output.
- Precompute a workspace-link adjacency list once from the lockfile importers (source importer -> list of link targets + devOnly), then for each package perform BFS over that adjacency list rather than re-scanning importer dependency objects repeatedly.
- If you keep the current structure, at minimum cache `buildWorkspacePackagesMap()` results keyed by `lockfileDir` + `importerId` to avoid rereading the same manifests across SBOMs.

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


11. Control-char path injection 🐞 Bug ⛨ Security
Description
sanitizePathSegment() doesn’t strip control characters, so a crafted workspace package name/version
containing newline characters can create confusing filenames and inject extra lines into the
--split --out “Generated … SBOMs” output. This can mislead CI logs or downstream parsers when
running pnpm sbom against an untrusted workspace.
Code

deps/compliance/commands/src/sbom/sbom.ts[R511-516]

+function sanitizePathSegment (value: string): string {
+  const sanitized = value.replace(/[/\\:*?"<>|]/g, '-')
+  // `.`, `..`, or a blank value would let a crafted name/version escape or replace
+  // the intended output directory once interpolated into an `--out` template.
+  return sanitized === '.' || sanitized === '..' || sanitized.trim() === '' ? '-' : sanitized
+}
Evidence
File paths are built from manifest-derived name/version using sanitizePackageName() +
sanitizePathSegment() and then printed in the generated-files summary; because control characters
are not removed, they can propagate to the filesystem path and to stdout.

deps/compliance/commands/src/sbom/sbom.ts[189-195]
deps/compliance/commands/src/sbom/sbom.ts[248-266]
deps/compliance/commands/src/sbom/sbom.ts[272-276]
deps/compliance/commands/src/sbom/sbom.ts[511-516]

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

## Issue description
`sanitizePathSegment()` currently replaces only a small set of filesystem metacharacters, but it allows control characters (notably `\n`/`\r`). Those values flow into filenames via `--out` interpolation and are later printed verbatim in the `--split --out` summary, enabling log/console output injection.
### Issue Context
This is triggered by workspace package metadata (`manifest.name` / `manifest.version`) when using `--out` (single mode) or `--split --out` (summary output).
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[189-195]
- deps/compliance/commands/src/sbom/sbom.ts[248-266]
- deps/compliance/commands/src/sbom/sbom.ts[272-276]
- deps/compliance/commands/src/sbom/sbom.ts[507-516]
### Suggested fix
- Extend `sanitizePathSegment()` to replace control characters (at least `\r`, `\n`, `\t`, and other ASCII control chars `\x00-\x1F\x7F`) with `-`.
- Consider also collapsing/normalizing whitespace so filenames and the printed summary cannot span multiple lines.

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


12. High parallel traversal fanout 🐞 Bug ➹ Performance
Description
collectSbomComponents() executes all importer walkers concurrently via Promise.all, and the PR
expands the walked importer set to include workspace-linked additionalImporterIds. In large
workspaces this increases parallel traversal/store-metadata activity significantly and can cause
resource pressure (CPU/IO) and instability.
Code

deps/compliance/sbom/src/collectComponents.ts[R55-65]

+  const workspaceDeps = opts.resolvedWorkspaceDeps
+    ?? (opts.lockfileOnly
+      ? { links: [], additionalImporterIds: [] }
+      : resolveWorkspaceDeps(opts.lockfile, importerIds, opts.include))
+  const allImporterIds = [...importerIds, ...workspaceDeps.additionalImporterIds]
+
const importerWalkers = lockfileWalkerGroupImporterSteps(
opts.lockfile,
-    importerIds,
+    allImporterIds,
{ include: opts.include }
)
Evidence
The code appends workspace-linked importer IDs to the traversal set and then processes the whole set
concurrently; this increases parallel work compared to the pre-workspace path where filtered runs
typically had only 1 importer walker.

deps/compliance/sbom/src/collectComponents.ts[55-65]
deps/compliance/sbom/src/collectComponents.ts[129-148]

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

## Issue description
The PR expands traversal from `importerIds` to `allImporterIds = importerIds + workspaceDeps.additionalImporterIds`, and then runs all importer walkers concurrently (`Promise.all(importerWalkers.map(...))`). For big monorepos, that can greatly increase parallel traversal (and store metadata reads when enabled), leading to slowdowns or resource pressure.
### Issue Context
This is most relevant when generating an SBOM for a filtered package that links to many workspace packages, because the additional importers can be large.
### Fix Focus Areas
- deps/compliance/sbom/src/collectComponents.ts[55-65]
- deps/compliance/sbom/src/collectComponents.ts[129-148]
### Suggested fix
- Introduce a concurrency limit around processing `importerWalkers` (e.g., via `p-limit` or an internal queue) rather than `Promise.all` over the entire list.
- Keep per-importer traversal as-is, but bound the number of importers traversed in parallel (e.g., 4–16), since each traversal may recurse and/or hit the store index.

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


13. SBOM filename collisions 🐞 Bug ≡ Correctness
Description
sanitizePackageName() collapses distinct package names (e.g. "@a/b" and "a-b") to the same
placeholder value, so per-package SBOM generation can silently overwrite one package’s SBOM file
with another’s when using --out with %s. This yields incorrect SBOM artifacts (potentially in
CI/compliance pipelines) without any error.
Code

deps/compliance/commands/src/sbom/sbom.ts[R498-500]

+function sanitizePackageName (name: string): string {
+  return name.replace(/^@/, '').replace(/\//g, '-')
+}
Evidence
Per-package writes substitute %s using sanitizePackageName(), which removes the scope marker and
turns / into -, so different names can become identical and the later writeFileSync()
overwrites the earlier output path.

deps/compliance/commands/src/sbom/sbom.ts[247-257]
deps/compliance/commands/src/sbom/sbom.ts[498-500]

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

## Issue description
`sanitizePackageName()` flattens scoped names by stripping `@` and replacing `/` with `-`, which is not injective. Distinct package names can map to the same `%s` substitution, causing silent file overwrites during per-package SBOM output.
### Issue Context
This affects the new per-package output modes:
- `--split --out <pattern-with-%s>`
- implicit split when `--out` contains `%s`
### Fix Focus Areas
- Make the placeholder substitution for `%s` unique for every valid npm package name (including scoped names), while still producing a filesystem-safe segment.
- Consider using an encoding that preserves uniqueness (e.g., `encodeURIComponent(name)` plus safe character replacements) instead of lossy transformations.
- Optionally detect collisions (track written paths and throw if a duplicate path is about to be written).
#### Code pointers
- deps/compliance/commands/src/sbom/sbom.ts[247-257]
- deps/compliance/commands/src/sbom/sbom.ts[498-500]

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


14. O(n^2) BFS queue 🐞 Bug ➹ Performance
Description
resolveWorkspaceDeps() uses Array#shift() inside its BFS loop, making traversal O(n^2) in the number
of visited importer IDs and adding avoidable CPU cost on large workspaces/lockfiles. This runs on
the SBOM workspace-dependency resolution path (when not using --lockfile-only).
Code

deps/compliance/sbom/src/collectComponents.ts[R235-242]

+  const links: WorkspaceLink[] = []
+  const visited = new Set<string>(importerIds)
+  const queue = [...importerIds]
+  const additionalImporterIds: ProjectId[] = []
+
+  while (queue.length > 0) {
+    const importerId = queue.shift()!
+    const snapshot = lockfile.importers[importerId]
Evidence
The BFS loop repeatedly dequeues via queue.shift(), which is linear-time per operation in JS
arrays and therefore degrades for large queues.

deps/compliance/sbom/src/collectComponents.ts[235-277]

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

## Issue description
`resolveWorkspaceDeps()` implements BFS with `queue.shift()`, which is O(n) per dequeue on JS arrays. Over many importers this becomes O(n^2) overhead.
### Issue Context
This function is used to discover workspace `link:` deps and their transitive workspace links for SBOM generation.
### Fix Focus Areas
- Implement the queue with a moving index (`let i = 0; while (i < queue.length) { const importerId = queue[i++] }`) or a small deque implementation.
- Preserve current semantics (visited set, additionalImporterIds order not strictly important).
#### Code pointers
- deps/compliance/sbom/src/collectComponents.ts[235-277]

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


15. Unbounded manifest read concurrency ✓ Resolved 🐞 Bug ☼ Reliability
Description
buildWorkspacePackagesMap() uses Promise.all() across all reachable workspace importer IDs and
reads each manifest, which can trigger a burst of thousands of concurrent filesystem reads in large
workspaces. This risks EMFILE/ENOMEM failures and performance degradation on the new workspace SBOM
hot path.
Code

deps/compliance/commands/src/sbom/sbom.ts[R461-479]

+  const entries = await Promise.all(
+    reachableImporterIds.map(async (importerId): Promise<[ProjectId, WorkspacePackageInfo] | null> => {
+      const selected = selectedEntriesMap.get(importerId)
+      const manifest = selected
+        ? selected.manifest
+        : await readManifestSafe(path.join(lockfileDir, importerId))
+
+      if (!manifest?.name || !manifest.version) return null
+
+      return [importerId, {
+        name: manifest.name,
+        version: manifest.version,
+        license: typeof manifest.license === 'string' ? manifest.license : undefined,
+        description: manifest.description,
+        author: extractAuthor(manifest),
+        repository: extractRepository(manifest),
+      }]
+    })
+  )
Evidence
The SBOM code reads many manifests concurrently via Promise.all, while the manifest reader package
provides a dedicated limited-concurrency function (pLimit(4)), implying unbounded reads are
undesirable.

deps/compliance/commands/src/sbom/sbom.ts[461-489]
workspace/project-manifest-reader/src/index.ts[22-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
`buildWorkspacePackagesMap()` uses `Promise.all(reachableImporterIds.map(...readProjectManifestOnly...))` without any concurrency limiting. On large workspaces this can overwhelm the filesystem/OS (e.g., EMFILE) and slow SBOM generation.
### Issue Context
The repo already contains a concurrency-limited manifest read helper (`safeReadProjectManifestOnly`) indicating this is a known risk area.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[461-489]
- workspace/project-manifest-reader/src/index.ts[22-34]
### Suggested fix approach
- Use `p-limit` in `buildWorkspacePackagesMap()` (e.g., 4–16) to bound concurrent manifest reads.
- Alternatively, import and use `safeReadProjectManifestOnly()` if feasible for this package, mapping `null` to `undefined`.
- Add a stress-ish test (or at least a unit test) that asserts the limiter is used (e.g., via instrumentation or by checking that reads are scheduled through a limiter wrapper).

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


16. Versionless workspace deps omitted ✓ Resolved 🐞 Bug ≡ Correctness
Description
buildWorkspacePackagesMap() drops workspace packages that lack a version, so workspace link
dependencies without versions won't be emitted as components/relationships and their external
dependencies may be attributed to the root instead of the workspace package. This produces an
incomplete/incorrect workspace dependency graph in the SBOM for common private/internal packages
that omit version.
Code

deps/compliance/commands/src/sbom/sbom.ts[R468-469]

+      if (!manifest?.name || !manifest.version) return null
+
Evidence
Root version already falls back when missing, but workspace packages are filtered out when version
is absent; the workspace link component/relationship creation is gated on workspacePackages
entries and parent attribution falls back to root when workspace info is missing.

deps/compliance/commands/src/sbom/sbom.ts[334-336]
deps/compliance/commands/src/sbom/sbom.ts[461-469]
deps/compliance/sbom/src/collectComponents.ts[66-112]
deps/compliance/sbom/src/collectComponents.ts[126-134]

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

## Issue description
`buildWorkspacePackagesMap()` requires `manifest.version` and returns `null` when missing. However, SBOM generation already supports missing versions for the root (defaults to `0.0.0`), so excluding versionless workspace packages causes missing workspace components and incorrect relationship attribution.
### Issue Context
- Workspace packages are represented via `workspacePackages` + workspace link relationships; missing entries skip link components.
- The dependency walker still traverses additional workspace importer IDs, and when `workspacePackages` lacks info it falls back to `rootPurl` as the parent.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[334-336]
- deps/compliance/commands/src/sbom/sbom.ts[461-477]
- deps/compliance/sbom/src/collectComponents.ts[66-112]
- deps/compliance/sbom/src/collectComponents.ts[126-134]
### Suggested fix approach
- Change the guard to only require `manifest.name`; use `const version = manifest.version ?? '0.0.0'` (consistent with root defaulting).
- Populate `WorkspacePackageInfo.version` with the fallback so `buildPurl()` and relationships work.
- Consider adding a test fixture workspace package without `version` to verify:
- the workspace package itself appears as a component
- its external deps are connected under that workspace component rather than the root.

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


17. Split buffers all outputs 🐞 Bug ➹ Performance
Description
handleSplit() accumulates all SBOM JSON strings in memory (ndjsonLines) and only joins at the
end, which can cause high memory use or OOM on large workspaces. This is on the new --split hot
path for monorepos.
Code

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

+  const entries = Object.entries(projectsGraph)
+  const ndjsonLines: string[] = []
+  const files: string[] = []
+  const compact = !opts.out
+  const createdDirs = new Set<string>()
+
+  for (const [dir, entry] of entries) {
+    const manifest = entry.package.manifest
+    if (!manifest.name) continue
+
+    const singleProjectGraph = { [dir as keyof typeof projectsGraph]: entry }
+
+    // eslint-disable-next-line no-await-in-loop
+    const { output } = await generateSbomForProject(
+      { ...opts, selectedProjectsGraph: singleProjectGraph as typeof projectsGraph, allProjectsGraph: undefined, split: false, out: undefined },
+      serialOpts,
+      ctx,
+      compact
+    )
+
+    if (opts.out) {
+      const filePath = opts.out
+        .replaceAll('%s', sanitizePathSegment(sanitizePackageName(manifest.name)))
+        .replaceAll('%v', sanitizePathSegment(manifest.version ?? '0.0.0'))
+      const fileDir = path.dirname(filePath)
+      if (!createdDirs.has(fileDir)) {
+        fs.mkdirSync(fileDir, { recursive: true })
+        createdDirs.add(fileDir)
+      }
+      fs.writeFileSync(filePath, output)
+      files.push(filePath)
+    } else {
+      ndjsonLines.push(output)
+    }
+  }
+
+  if (opts.out) {
+    return {
+      output: `Generated ${files.length} SBOMs:\n${files.map((f) => `  ${f}`).join('\n')}`,
+      exitCode: 0,
+    }
+  }
+
+  return { output: ndjsonLines.join('\n'), exitCode: 0 }
+}
Evidence
The implementation explicitly pushes every per-package SBOM string into an array and concatenates
them, which scales linearly in memory with the number/size of SBOMs.

deps/compliance/commands/src/sbom/sbom.ts[226-270]

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

## Issue description
`--split` currently buffers every generated SBOM (potentially large JSON) into `ndjsonLines` before joining. For large workspaces, this increases peak RSS substantially.
### Issue Context
This affects `pnpm sbom --split` (NDJSON stdout). File output mode (`--split --out`) already writes each SBOM immediately.
### Fix Focus Areas
- deps/compliance/commands/src/sbom/sbom.ts[226-270]
### Suggested fix
- When `opts.out` is not set (stdout NDJSON mode), write each SBOM line directly as it is produced (e.g. `process.stdout.write(output + '\n')`) and avoid storing `ndjsonLines`.
- Return a small summary string (or empty output) from the handler rather than the full NDJSON buffer.
- Add a test that ensures output is still valid NDJSON, but does not require the handler to return the entire concatenated string.

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


Grey Divider

Qodo Logo

Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/sbom/src/collectComponents.ts Outdated
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 a9c9bcd

Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
@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 4ea59bb

Comment thread deps/compliance/commands/src/sbom/sbom.ts
Comment thread deps/compliance/sbom/src/collectComponents.ts
@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 4.333 ± 0.165 4.091 4.560 2.04 ± 0.13
pacquet@main 4.204 ± 0.107 4.088 4.427 1.98 ± 0.12
pnpr@HEAD 2.122 ± 0.112 1.932 2.277 1.00
pnpr@main 2.166 ± 0.128 2.030 2.374 1.02 ± 0.08
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.33309694706,
      "stddev": 0.1647017853570764,
      "median": 4.36848863236,
      "user": 3.9878318399999997,
      "system": 3.53405938,
      "min": 4.09149161236,
      "max": 4.55971461536,
      "times": [
        4.5109913293599995,
        4.09149161236,
        4.35541975636,
        4.55971461536,
        4.417466861359999,
        4.10205471036,
        4.15255075636,
        4.41485219236,
        4.34487012836,
        4.38155750836
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.2043125659600005,
      "stddev": 0.10682301753174021,
      "median": 4.18344040036,
      "user": 3.9641756399999992,
      "system": 3.5438295799999997,
      "min": 4.0882977903599995,
      "max": 4.42669314836,
      "times": [
        4.42669314836,
        4.20344195536,
        4.2137493483599995,
        4.28652349036,
        4.11834195536,
        4.30260394936,
        4.16343884536,
        4.11838576436,
        4.12164941236,
        4.0882977903599995
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.12228607956,
      "stddev": 0.11190447147131097,
      "median": 2.1402907108599996,
      "user": 2.64920634,
      "system": 2.97274108,
      "min": 1.9324428423600002,
      "max": 2.2771839683599997,
      "times": [
        2.20131835836,
        2.19323662636,
        2.1290057493599996,
        2.01411706036,
        2.21863815036,
        2.15157567236,
        2.2771839683599997,
        1.98263603236,
        1.9324428423600002,
        2.1227063353599998
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1664264305600005,
      "stddev": 0.1276723832251958,
      "median": 2.1216238233599998,
      "user": 2.63904414,
      "system": 2.9736089799999994,
      "min": 2.0297729903599997,
      "max": 2.37357457236,
      "times": [
        2.37357457236,
        2.1462901613599996,
        2.09695748536,
        2.33527845236,
        2.14945788636,
        2.05202449836,
        2.31921880536,
        2.08250503236,
        2.0297729903599997,
        2.07918442136
      ]
    }
  ]
}

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

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 624.7 ± 12.2 612.3 642.2 1.00
pacquet@main 646.6 ± 50.4 619.6 786.0 1.03 ± 0.08
pnpr@HEAD 681.5 ± 16.7 653.2 699.5 1.09 ± 0.03
pnpr@main 687.5 ± 26.7 652.2 734.4 1.10 ± 0.05
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6247373162,
      "stddev": 0.01223387560020348,
      "median": 0.6188255063000001,
      "user": 0.3780213,
      "system": 1.3138781,
      "min": 0.6122629223,
      "max": 0.6422488973,
      "times": [
        0.6194246263000001,
        0.6236082623,
        0.6141591703,
        0.6414835223,
        0.6422488973,
        0.6172243763,
        0.6122629223,
        0.6166947383,
        0.6420402603,
        0.6182263863
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.6465583151000001,
      "stddev": 0.05040650302491906,
      "median": 0.6291261068,
      "user": 0.3734084,
      "system": 1.3252814,
      "min": 0.6195670753,
      "max": 0.7860262223000001,
      "times": [
        0.6586874393000001,
        0.6210236063000001,
        0.6298157323,
        0.6345961683,
        0.6284364813000001,
        0.6413445133000001,
        0.6195670753,
        0.6217309103,
        0.6243550023000001,
        0.7860262223000001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6814613193,
      "stddev": 0.016674539521310622,
      "median": 0.6857611868,
      "user": 0.37091679999999994,
      "system": 1.3665986000000003,
      "min": 0.6531855423,
      "max": 0.6994847813,
      "times": [
        0.6619760423000001,
        0.6670513783,
        0.6994847813,
        0.6985530493000001,
        0.6531855423,
        0.6740877243000001,
        0.6802799583,
        0.6912424153000001,
        0.6969768153,
        0.6917754863000001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6875101109,
      "stddev": 0.026730686153557077,
      "median": 0.6793291443,
      "user": 0.39107719999999996,
      "system": 1.3473103000000002,
      "min": 0.6521993783000001,
      "max": 0.7344452143000001,
      "times": [
        0.7344452143000001,
        0.6821183133000001,
        0.6765399753,
        0.6758468363000001,
        0.6944657803000001,
        0.6564524473000001,
        0.7102775873,
        0.7188163483000001,
        0.6739392283000001,
        0.6521993783000001
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.264 ± 0.069 4.174 4.391 1.95 ± 0.11
pacquet@main 4.264 ± 0.043 4.180 4.336 1.95 ± 0.11
pnpr@HEAD 2.197 ± 0.147 2.019 2.416 1.00 ± 0.09
pnpr@main 2.190 ± 0.117 2.055 2.385 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.264090858240001,
      "stddev": 0.06874075785068832,
      "median": 4.264026363639999,
      "user": 3.7885456999999993,
      "system": 3.388741600000001,
      "min": 4.17436224414,
      "max": 4.39083408014,
      "times": [
        4.35239669514,
        4.21126444414,
        4.26252189314,
        4.20243348514,
        4.17436224414,
        4.26553083414,
        4.39083408014,
        4.26613702014,
        4.30205536314,
        4.21337252314
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.263507668339999,
      "stddev": 0.043127559128454326,
      "median": 4.26024050064,
      "user": 3.7816162999999996,
      "system": 3.3856376,
      "min": 4.17954686514,
      "max": 4.3364360391400005,
      "times": [
        4.29405963214,
        4.30417446414,
        4.25677379914,
        4.2322311611400005,
        4.17954686514,
        4.25250332414,
        4.26370720214,
        4.27478309714,
        4.3364360391400005,
        4.24086109914
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.1968462890400007,
      "stddev": 0.14657713411986031,
      "median": 2.1635732556400002,
      "user": 2.4930117999999997,
      "system": 2.9208235,
      "min": 2.01866301514,
      "max": 2.41590996214,
      "times": [
        2.01866301514,
        2.34689642314,
        2.3508689521400004,
        2.08305509214,
        2.41590996214,
        2.09603652814,
        2.23110998314,
        2.0869804811400003,
        2.29098908914,
        2.04795336414
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.1903939926400002,
      "stddev": 0.11729265409896569,
      "median": 2.1534324166400003,
      "user": 2.480596,
      "system": 2.9218843,
      "min": 2.05467516314,
      "max": 2.3848028071400003,
      "times": [
        2.3381417791400003,
        2.05467516314,
        2.09357267114,
        2.15782806814,
        2.2595128841400003,
        2.11599859314,
        2.06828777514,
        2.3848028071400003,
        2.28208342014,
        2.14903676514
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.393 ± 0.022 1.366 1.433 2.13 ± 0.06
pacquet@main 1.389 ± 0.020 1.362 1.423 2.12 ± 0.05
pnpr@HEAD 0.670 ± 0.048 0.637 0.803 1.02 ± 0.08
pnpr@main 0.654 ± 0.014 0.635 0.676 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.39339588346,
      "stddev": 0.02159119339594864,
      "median": 1.39585780126,
      "user": 1.3927168599999997,
      "system": 1.7429561599999999,
      "min": 1.36640817926,
      "max": 1.43288795826,
      "times": [
        1.39077692526,
        1.4009386772599999,
        1.3793997602599999,
        1.41028607226,
        1.40979639826,
        1.43288795826,
        1.40140995826,
        1.37482470526,
        1.36723020026,
        1.36640817926
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.38867008996,
      "stddev": 0.0199881854485022,
      "median": 1.3870546397599999,
      "user": 1.36732316,
      "system": 1.74562226,
      "min": 1.36187244926,
      "max": 1.42303228326,
      "times": [
        1.40727134726,
        1.38102207226,
        1.39050919226,
        1.3836000872599998,
        1.42303228326,
        1.39917470726,
        1.3633711582599999,
        1.36187244926,
        1.40443604226,
        1.37241156026
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.66982843266,
      "stddev": 0.047713013450584645,
      "median": 0.6565513517600001,
      "user": 0.34289345999999993,
      "system": 1.2957151599999999,
      "min": 0.6368800932600001,
      "max": 0.8027627392600001,
      "times": [
        0.6555067372600001,
        0.6368800932600001,
        0.8027627392600001,
        0.65008619726,
        0.6527549902600001,
        0.65759596626,
        0.6668408512600001,
        0.66893874626,
        0.6447980042600001,
        0.6621200012600001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6539819678600001,
      "stddev": 0.013560620124998932,
      "median": 0.65230340626,
      "user": 0.33653456000000004,
      "system": 1.2921858599999998,
      "min": 0.6352175152600001,
      "max": 0.6762284932600001,
      "times": [
        0.6574818252600001,
        0.65274953826,
        0.64134768626,
        0.6440027662600001,
        0.6352175152600001,
        0.6762284932600001,
        0.6476097672600001,
        0.6518572742600001,
        0.6574885992600001,
        0.6758362132600001
      ]
    }
  ]
}

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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.029 ± 0.033 2.997 3.110 4.47 ± 0.30
pacquet@main 3.061 ± 0.072 2.976 3.198 4.52 ± 0.32
pnpr@HEAD 0.691 ± 0.005 0.680 0.696 1.02 ± 0.07
pnpr@main 0.677 ± 0.045 0.649 0.803 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0293610975200003,
      "stddev": 0.03294176498455949,
      "median": 3.0203432791199996,
      "user": 1.8022027599999997,
      "system": 1.96482156,
      "min": 2.9967180776199998,
      "max": 3.10975399662,
      "times": [
        3.0342493876199996,
        2.9967180776199998,
        2.99803795962,
        3.01691661462,
        3.05083759562,
        3.00964131762,
        3.01807185762,
        3.10975399662,
        3.0367694676199997,
        3.0226147006199997
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.0608972310199998,
      "stddev": 0.07156377200897597,
      "median": 3.03159156512,
      "user": 1.8508644599999997,
      "system": 1.9620173600000002,
      "min": 2.97623429162,
      "max": 3.1978067916199997,
      "times": [
        3.0444629116199997,
        3.0365490086199998,
        3.02663412162,
        3.01408200762,
        2.97623429162,
        3.02107116362,
        3.01751600262,
        3.1978067916199997,
        3.11722677162,
        3.1573892396199996
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.6905373415200001,
      "stddev": 0.005102945150751648,
      "median": 0.6917412931200001,
      "user": 0.35558305999999995,
      "system": 1.31619896,
      "min": 0.6803382866200001,
      "max": 0.6964001856200001,
      "times": [
        0.6958374116200001,
        0.6917817396200001,
        0.6938398946200001,
        0.6917008466200001,
        0.68894437362,
        0.69418974462,
        0.68601217262,
        0.6803382866200001,
        0.6964001856200001,
        0.6863287596200001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6771469396199999,
      "stddev": 0.044960376880998736,
      "median": 0.66503326312,
      "user": 0.34398745999999997,
      "system": 1.3037417599999999,
      "min": 0.64901574362,
      "max": 0.8027682236200001,
      "times": [
        0.6709259226200001,
        0.67275655962,
        0.6567678436200001,
        0.6577595906200001,
        0.6569558106200001,
        0.65914060362,
        0.6728450976200001,
        0.8027682236200001,
        0.6725340006200001,
        0.64901574362
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12097
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,264.09 ms
(+1.62%)Baseline: 4,196.12 ms
5,035.34 ms
(84.68%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,029.36 ms
(+0.88%)Baseline: 3,002.98 ms
3,603.58 ms
(84.07%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,393.40 ms
(+5.33%)Baseline: 1,322.84 ms
1,587.41 ms
(87.78%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,333.10 ms
(+5.03%)Baseline: 4,125.69 ms
4,950.83 ms
(87.52%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
624.74 ms
(+0.67%)Baseline: 620.59 ms
744.71 ms
(83.89%)
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan force-pushed the feat/sbom-workspace-support branch from 4ea59bb to d01192d Compare June 16, 2026 23:48
@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

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@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 7f539fa

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

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12097
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,196.85 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
690.54 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
669.83 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,122.29 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
681.46 ms
🐰 View full continuous benchmarking report in Bencher

Saturate and others added 8 commits June 17, 2026 08:08
When --filter selects a single workspace, the SBOM root component now
uses that workspace's name, version, description, license, and author
instead of the workspace root's metadata. Author, repository, and
license fall back to the root manifest when the workspace package
doesn't define them.

Workspace inter-dependencies (workspace: protocol) and their transitive
dependencies are now included in the SBOM. Dev-only workspace deps are
correctly excluded when --prod is used, and lockfile-only mode skips
workspace resolution entirely to avoid unexpected disk reads.
Without recursiveByDefault, the sbom command in a workspace didn't
enter the recursive code path that populates selectedProjectsGraph from
--filter. The handler received no filter info and always used the
workspace root manifest for the root component.
Add --out <path> to write SBOM to a file instead of stdout. Supports
%s (package name) and %v (version) placeholders. When %s is present,
generates one SBOM per workspace package automatically.

Add --split to output NDJSON (one compact JSON per line) to stdout,
for piping into tooling that processes multiple SBOMs.

Add compact option to CycloneDX and SPDX serializers so NDJSON lines
are single-line JSON without re-parsing.

Sanitize %s and %v values to prevent path traversal in output paths.
…nt relationships

Two bugs found by CodeRabbit:

1. --out with %v but no %s wrote a literal %v filename. Now both %s
   and %v are expanded in the single-output path using the SBOM root
   component's name and version.

2. The lockfile walker used rootPurl as parent for every importer,
   including additional workspace dep importers. This caused
   shared-lib's external deps (like is-odd) to appear as direct
   deps of the root package. Now each importer's walk uses the
   correct parent PURL based on whether it's an original or
   workspace dep importer.
…metadata

Read the root manifest from rootProjectManifest/rootProjectManifestDir instead
of opts.dir so license/author/repository/description fall back to the workspace
root when a filtered package omits them. Add the missing rootDescription
fallback. Fix the per-package CycloneDX test assertions to match the standard
group/name split for scoped names.
…kspace deps

- Neutralize '.', '..' and blank segments in --out path templates so a crafted
  package name/version cannot escape the output directory.
- Skip lockfile link: targets that resolve outside the workspace root, and guard
  the workspace manifest reads with a containment check, preventing arbitrary
  package.json reads from a malicious lockfile.
- Honor --lockfile-only inside collectSbomComponents so workspace links and their
  transitive deps are no longer traversed.
- Include workspace packages that omit a version (default to 0.0.0, matching the
  root component).
- Bound workspace manifest reads with p-limit to avoid EMFILE on large monorepos.
…orkspace BFS

- Throw SBOM_OUT_PATH_COLLISION when two workspace packages sanitize to the same
  --out path instead of silently overwriting one SBOM with another.
- Replace the resolveWorkspaceDeps BFS queue.shift() (O(n) per dequeue) with a
  moving head index for O(n) traversal on large workspaces.
@zkochan zkochan force-pushed the feat/sbom-workspace-support branch from 7f539fa to 6c4dca9 Compare June 17, 2026 06:09
…workspace importers

- Strip ASCII control characters (incl. newlines) in sanitizePathSegment so a
  crafted package name/version cannot inject lines into the --split --out summary
  or produce confusing filenames.
- Skip walking a reachable workspace importer when its package info is missing
  (e.g. unreadable manifest) instead of misattributing its deps to the root.
- Bound importer-walker fan-out with p-limit to avoid resource pressure on large
  workspaces.
Comment thread deps/compliance/commands/src/sbom/sbom.ts Outdated
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 6c4dca9

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 1126b25

…Set for path collisions

- Read workspace package manifests once into SharedContext (keyed by importer id)
  from the project graph, so split mode no longer re-reads them from disk for
  every emitted SBOM.
- Track written --out paths in a Set instead of Array#includes to make collision
  detection O(1) per package rather than O(n2) overall.
Comment thread deps/compliance/commands/src/sbom/sbom.ts
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 de542db

…, cache root license

- Only treat %s in --out as per-package (split) mode when a workspace project
  graph is present; in a single-project repo %s/%v interpolate from the root
  component on the single-output path. Adds a regression test.
- Use an own-property check for lockfile importer existence so a crafted link:
  target cannot follow inherited keys (e.g. toString) via the prototype chain.
- Fast-path resolveRootLicense when the manifest already declares an SPDX license
  and cache the workspace-root license in SharedContext, avoiding redundant
  on-disk LICENSE probing per emitted SBOM.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

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

@zkochan

zkochan commented Jun 17, 2026

Copy link
Copy Markdown
Member

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@zkochan zkochan merged commit 1495cb0 into pnpm:main Jun 17, 2026
33 of 35 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