Problem Statement
When using rollup-plugin-sbom in a pnpm workspace monorepo, the plugin emits false-positive warnings for transitive dependencies:
(!) [plugin rollup-plugin-sbom] Missing package data for module
node_modules/.pnpm/@smithy_types@4.12.0/node_modules/@smithy/types/dist-es
in registry, this should not happen
The affected packages are missing from the generated SBOM, producing incomplete output.
Root Cause Analysis
The dependency info registry (DependencyInfoRegistry) is a Map<ModulePathString, DependencyInfo> keyed by the directory path of the resolved module ID. The flow is:
moduleParsed hook — called for each module Rollup parses. Computes modulePath = dirname(moduleInfo.id) and registers the package data under that key.
generateBundle hook — resolves the external module tree from the output bundle. For each module, looks up the registry using modulePath = dirname(moduleId).
In pnpm, packages are stored in .pnpm/<name>@<version>/node_modules/<pkg>/ and accessed via symlinks from the project's node_modules/. The same physical file can be resolved through different symlink chains:
- Path A (via
moduleParsed): test/fixtures/regression-251/node_modules/@aws-sdk/client-s3/.../node_modules/@smithy/types/dist-es/index.js
- Path B (via analyzer in
generateBundle): node_modules/.pnpm/@smithy+types@4.12.0/node_modules/@smithy/types/dist-es/index.js
These produce different modulePath strings, causing a registry miss. The fallback aggregation in generateBundle also fails in some cases because findValidPackageJson's .git boundary check terminates the upward walk before reaching the package's package.json in deeply nested .pnpm paths within monorepo subdirectories.
Proposed Solution
1. Normalize module paths via fs.realpathSync
File: src/helpers.ts
Change: Resolve symlinks in getModulePathFromModuleId before computing the directory path.
import { dirname } from "node:path";
import { realpathSync } from "node:fs";
export function getModulePathFromModuleId(moduleId: ModuleIdString): ModulePathString {
try {
return dirname(realpathSync(moduleId));
} catch {
return dirname(moduleId);
}
}
Rationale: Collapses all symlink paths to the canonical .pnpm/... path. Both moduleParsed and the analyzer in generateBundle will produce identical registry keys regardless of how the module was reached. The try/catch handles edge cases where the file doesn't exist on disk (virtual modules, in-memory transforms).
Impact: Zero behavioral change for npm/yarn users (their paths are already canonical). Fixes the registry mismatch for pnpm users.
2. Dual-register under package root path
File: src/dependency-info-registry.ts
Change: After findValidPackageJson succeeds, register the result under both the original modulePath and the resolved dependencyPackage.path.
registry.set(modulePath, info);
if (modulePath !== dependencyPackage.path) {
registry.set(dependencyPackage.path as ModulePathString, info);
}
Rationale: Multiple modules within the same package (e.g., @smithy/types/dist-es/auth/, @smithy/types/dist-es/http/) each have different modulePath values but share the same package.json. Without dual registration, each subdirectory triggers a separate findValidPackageJson call. Dual registration creates a cache hit for any sibling module, reducing I/O and preventing edge cases where a sibling's path wasn't individually resolved.
Impact: Minor performance improvement (fewer findValidPackageJson calls). No behavioral change for correctly resolving packages.
3. No changes to .git boundary check
File: src/package-finder.ts — no changes.
The .git boundary in findValidPackageJson was added to prevent catastrophic walk-up behavior in edge cases. It remains a valid safety net. With the realpathSync normalization in place, the .git boundary is no longer the root cause of the issue — the path mismatch was.
Files to Modify
| File |
Change |
Risk |
src/helpers.ts |
Add realpathSync to getModulePathFromModuleId |
Low — single function, try/catch fallback |
src/dependency-info-registry.ts |
Dual-register under package root path |
Low — additive only, no existing behavior changed |
src/package-finder.ts |
None |
— |
Verification Plan
- Build the
regression-251 fixture (test/fixtures/regression-251) with pnpm
- Confirm no "Missing package data" warnings for
@smithy/types or any other transitive dependency
- Confirm
@smithy/types appears as a component in the generated SBOM JSON/XML
- Run full test suite — all existing tests must pass
- Verify output SBOMs are unchanged for npm/yarn fixtures (no regression)
Out of Scope
- Rewriting the registry to key by
name@version instead of path (larger refactor, deferred)
- Adding explicit pnpm detection or special-casing (the
realpathSync approach is package-manager-agnostic)
- Changing the
.git boundary behavior
Problem Statement
When using
rollup-plugin-sbomin a pnpm workspace monorepo, the plugin emits false-positive warnings for transitive dependencies:The affected packages are missing from the generated SBOM, producing incomplete output.
Root Cause Analysis
The dependency info registry (
DependencyInfoRegistry) is aMap<ModulePathString, DependencyInfo>keyed by the directory path of the resolved module ID. The flow is:moduleParsedhook — called for each module Rollup parses. ComputesmodulePath = dirname(moduleInfo.id)and registers the package data under that key.generateBundlehook — resolves the external module tree from the output bundle. For each module, looks up the registry usingmodulePath = dirname(moduleId).In pnpm, packages are stored in
.pnpm/<name>@<version>/node_modules/<pkg>/and accessed via symlinks from the project'snode_modules/. The same physical file can be resolved through different symlink chains:moduleParsed):test/fixtures/regression-251/node_modules/@aws-sdk/client-s3/.../node_modules/@smithy/types/dist-es/index.jsgenerateBundle):node_modules/.pnpm/@smithy+types@4.12.0/node_modules/@smithy/types/dist-es/index.jsThese produce different
modulePathstrings, causing a registry miss. The fallback aggregation ingenerateBundlealso fails in some cases becausefindValidPackageJson's.gitboundary check terminates the upward walk before reaching the package'spackage.jsonin deeply nested.pnpmpaths within monorepo subdirectories.Proposed Solution
1. Normalize module paths via
fs.realpathSyncFile:
src/helpers.tsChange: Resolve symlinks in
getModulePathFromModuleIdbefore computing the directory path.Rationale: Collapses all symlink paths to the canonical
.pnpm/...path. BothmoduleParsedand the analyzer ingenerateBundlewill produce identical registry keys regardless of how the module was reached. Thetry/catchhandles edge cases where the file doesn't exist on disk (virtual modules, in-memory transforms).Impact: Zero behavioral change for npm/yarn users (their paths are already canonical). Fixes the registry mismatch for pnpm users.
2. Dual-register under package root path
File:
src/dependency-info-registry.tsChange: After
findValidPackageJsonsucceeds, register the result under both the originalmodulePathand the resolveddependencyPackage.path.Rationale: Multiple modules within the same package (e.g.,
@smithy/types/dist-es/auth/,@smithy/types/dist-es/http/) each have differentmodulePathvalues but share the samepackage.json. Without dual registration, each subdirectory triggers a separatefindValidPackageJsoncall. Dual registration creates a cache hit for any sibling module, reducing I/O and preventing edge cases where a sibling's path wasn't individually resolved.Impact: Minor performance improvement (fewer
findValidPackageJsoncalls). No behavioral change for correctly resolving packages.3. No changes to
.gitboundary checkFile:
src/package-finder.ts— no changes.The
.gitboundary infindValidPackageJsonwas added to prevent catastrophic walk-up behavior in edge cases. It remains a valid safety net. With therealpathSyncnormalization in place, the.gitboundary is no longer the root cause of the issue — the path mismatch was.Files to Modify
src/helpers.tsrealpathSynctogetModulePathFromModuleIdsrc/dependency-info-registry.tssrc/package-finder.tsVerification Plan
regression-251fixture (test/fixtures/regression-251) with pnpm@smithy/typesor any other transitive dependency@smithy/typesappears as a component in the generated SBOM JSON/XMLOut of Scope
name@versioninstead of path (larger refactor, deferred)realpathSyncapproach is package-manager-agnostic).gitboundary behavior