Skip to content

Improve symlink behaviour regarding warnings (i.E. pnpm) #274

Description

@janbiasi

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:

  1. moduleParsed hook — called for each module Rollup parses. Computes modulePath = dirname(moduleInfo.id) and registers the package data under that key.
  2. 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.tsno 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

  1. Build the regression-251 fixture (test/fixtures/regression-251) with pnpm
  2. Confirm no "Missing package data" warnings for @smithy/types or any other transitive dependency
  3. Confirm @smithy/types appears as a component in the generated SBOM JSON/XML
  4. Run full test suite — all existing tests must pass
  5. 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

Metadata

Metadata

Assignees

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions