Skip to content

fix: injectWorkspacePackages crashes — lean resolution defense-in-depth + lifecycle re-import#11662

Merged
zkochan merged 23 commits into
pnpm:mainfrom
Eyalm321:fix/graph-builder-missing-resolution
May 19, 2026
Merged

fix: injectWorkspacePackages crashes — lean resolution defense-in-depth + lifecycle re-import#11662
zkochan merged 23 commits into
pnpm:mainfrom
Eyalm321:fix/graph-builder-missing-resolution

Conversation

@Eyalm321

@Eyalm321 Eyalm321 commented May 15, 2026

Copy link
Copy Markdown
Contributor

Fixes two independent crashes hitting pnpm install --frozen-lockfile on workspaces with injectWorkspacePackages: true (or dependenciesMeta.*.injected), surfaced via turbo prune --docker pipelines.

Bug 1 — peer-variant snapshot missing resolution (lean, defense-in-depth)

A peer-variant injected workspace snapshot (@scope/pkg@file:packages/pkg(peerA@1)(peerB@2)) inherits its resolution from the base packages: entry (@scope/pkg@file:packages/pkg). When a tool prunes the lockfile and drops that base entry, readers that deref pkgSnapshot.resolution crash with the cryptic:

Cannot use 'in' operator to search for 'directory' in undefined

The root cause is upstream of pnpm: the pruner (e.g. turbo prune) emits an internally inconsistent lockfile. Fixed at the source in vercel/turborepo#12825 (retain the base entry for peer-variant injected deps; minimal repro in vercel/turborepo#12824) — empirically verified to produce a correct pruned lockfile for a real multi-service workspace.

pnpm side (this PR): one lean normalization at the read layer — in convertToLockfileObject, where base→variant inheritance already happens via Object.assign. When the base entry is absent, reconstruct the directory resolution from the file: depPath. This is reconstruction, not guessing: for a workspace file: dep the directory is the depPath suffix — exactly what pnpm's own writer emits. It is defense-in-depth, not load-bearing: with a well-formed lockfile (turbo#12825 or any correct input) the branch never fires. Because the normalization sits at the single shared read layer, it also covers the sibling Cannot use 'in' operator … 'integrity' in undefined on the pnpm deploy path (same resolution === undefined root, different deref site).

Per review feedback: the earlier per-reader inheritOrSynthesizeResolution helper across 5 call sites is removed; normalization lives in exactly one place (convertToLockfileObject), and the readers are back to main.

Bug 2 — lifecycle re-import wipes .bin/<tool> (pure pnpm; the substantive fix)

runLifecycleHooksConcurrently re-imports an injected workspace package into its targets after prepare/postinstall. The 2022 scanDir-into-filesMap workaround (#4299) fed target-internal paths to importPackage; once #11088 made importIndexedDir's makeEmptyDir fast path the default, that path wipes the target's node_modules before copying, so the re-import dies with ERR_PNPM_ENOENT on node_modules/.bin/<tool>.

Fix: drop the scanDir workaround and pass keepModulesDir: true so importIndexedDir skips the destructive fast path and preserves the target's existing node_modules (bin symlinks + transitive deps) via its staging/move path. Stays on storeController.importPackage, so source files keep their hardlinks (no copy-loop regression). Net reduction vs main: the scanDir helper and the node:fs / FilesMap imports are removed.

Tests

  • The deps-restorer regression fixture peer-variant-missing-resolution omits the base packages: entry, so it encodes the actual pruned shape and reproduces the crash on main: reverting the convertToLockfileObject change yields resolution: undefined for the peer-variant (→ the lockfileToDepGraph crash); with this PR it is reconstructed as { type: 'directory', directory: … }.
  • A lockfile.fs unit test pins the heuristic boundary: a directory resolution is synthesized for a pruned file: peer-variant but never for a file: tarball.
  • A deps-installer regression test covers the Bug 2 re-import (injected dep with a prepare script + a bin-having dependency).

Validation

End-to-end on a real injectWorkspacePackages monorepo (turbo prune --dockerpnpm install --frozen-lockfile), on services that crash on both bugs with stock pnpm:

Pairs with vercel/turborepo#12825 (Bug 1 root cause; minimal repro vercel/turborepo#12824). Tracks #11663.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed crashes when injectWorkspacePackages: true is enabled with pruned lockfiles containing peer-dependency variants.
    • Resolved node_modules/.bin tool references being lost during lifecycle hook re-import, preventing ENOENT errors.

Review Change Stack

@Eyalm321 Eyalm321 requested a review from zkochan as a code owner May 15, 2026 01:15
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR fixes two crashes when injectWorkspacePackages: true is enabled and the lockfile has been pruned: reconstructs missing peer-variant snapshot resolution during lockfile conversion, and preserves existing node_modules during lifecycle re-import by replacing per-target merging logic with a keepModulesDir flag.

Changes

Lockfile resolution reconstruction for peer variants

Layer / File(s) Summary
Lockfile resolution reconstruction logic
lockfile/fs/src/lockfileFormatConverters.ts
Import parse and conditionally backfill missing snapshot.resolution when converting lockfile objects, deriving directory resolution from file: depPath values (excluding tarballs).
Resolution reconstruction tests
lockfile/fs/test/lockfileV6Converters.test.ts
Add test cases verifying convertToLockfileObject() reconstructs directory resolution for local file: references while excluding tarballs.
Resolution regression test and fixtures
installing/deps-restorer/test/index.ts, installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/*
Add workspace fixtures with peer-dependency variants and a regression test asserting headlessInstall completes without crashing on missing resolution.

Lifecycle re-import with node_modules preservation

Layer / File(s) Summary
Lifecycle hook re-import with keepModulesDir
exec/lifecycle/src/runLifecycleHooksConcurrently.ts
Remove filesystem and FilesMap imports; change re-import to use storeController.importPackage(..., { keepModulesDir: true }) and reuse shared filesResponse across all targetDirs instead of per-target merging.
Regression test for injected prepare script with bin deps
installing/deps-installer/test/install/injectLocalPackages.ts
Add test verifying injected packages with prepare scripts and bin-providing dependencies retain both built outputs and .bin symlinks during re-import; includes detailed comment on prior failure mode.
Release notes documenting both fixes
.changeset/injectworkspacepackages-prune-crashes.md
Changeset entries and release notes for patch releases of pnpm, @pnpm/lockfile.fs, and @pnpm/exec.lifecycle addressing missing-resolution and node_modules-preservation crashes.

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Suggested labels

area: lockfile

Suggested reviewers

  • zkochan

Poem

🐰 A rabbit hops through the lockfile maze,
Reconstructing paths through pruned-down haze,
While lifecycle hooks rewind their dance,
Preserving bins with a careful glance!
No more crashes when variants hide—
All node_modules come along for the ride. 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the two main bug fixes in the changeset: defense-in-depth handling of missing peer-variant resolutions and lifecycle re-import preservation of node_modules.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

Inline comments:
In `@installing/deps-restorer/test/index.ts`:
- Around line 937-943: The test incorrectly uses
expect(...).resolves.not.toThrow() on the promise returned by headlessInstall;
to fix, remove the expect wrapper and simply await the call to
headlessInstall(await testDefaults({ lockfileDir: workspaceFixture, projects
})), so the test will naturally fail on rejection; locate the code using
headlessInstall and testDefaults in this block and replace the
expect-resolves-not.toThrow pattern with a direct await of the async call.
🪄 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: 52178303-9b14-44a0-be3a-b3e6ef61c917

📥 Commits

Reviewing files that changed from the base of the PR and between ae703b1 and 27df41a.

⛔ Files ignored due to path filters (1)
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • .changeset/guard-missing-resolution-graph-builder.md
  • deps/graph-builder/src/lockfileToDepGraph.ts
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yaml
  • installing/deps-restorer/test/index.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with modifications: trailing commas are used, functions are preferred over classes, functions are declared after they are used (hoisting is relied upon).
Functions should have no more than two or three arguments. If a function needs more parameters, use a single options object instead.
Maintain import order: (1) Standard libraries, (2) External dependencies (sorted alphabetically), (3) Relative imports.

Files:

  • installing/deps-restorer/test/index.ts
  • deps/graph-builder/src/lockfileToDepGraph.ts
🧠 Learnings (2)
📚 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:

  • installing/deps-restorer/test/index.ts
  • deps/graph-builder/src/lockfileToDepGraph.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:

  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json
  • installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json
🔇 Additional comments (6)
deps/graph-builder/src/lockfileToDepGraph.ts (1)

217-221: LGTM!

installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json (1)

1-8: LGTM!

installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json (1)

1-5: LGTM!

installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json (1)

1-8: LGTM!

installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yaml (1)

1-3: LGTM!

.changeset/guard-missing-resolution-graph-builder.md (1)

1-6: LGTM!

Comment thread installing/deps-restorer/test/index.ts Outdated
Eyalm321 added a commit to Eyalm321/pnpm that referenced this pull request May 15, 2026
Per CodeRabbit review on pnpm#11662 — `.resolves.not.toThrow()`
doesn't behave as intended here: after `.resolves` the matcher operates
on the resolved value (an `InstallationResult`), not on a function, so
`.toThrow()` would never validate the rejection path. Just awaiting the
call makes jest fail naturally on rejection, which is what the
regression test actually wants.
@Eyalm321 Eyalm321 force-pushed the fix/graph-builder-missing-resolution branch from 27df41a to c9f9535 Compare May 15, 2026 01:57
@Eyalm321 Eyalm321 marked this pull request as draft May 15, 2026 02:55
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Scenario: Frozen Lockfile

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.590 ± 0.126 2.456 2.811 1.04 ± 0.05
pacquet@main 2.502 ± 0.036 2.450 2.549 1.00
pnpm 5.061 ± 0.046 4.990 5.137 2.02 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.59044337616,
      "stddev": 0.12593109985407194,
      "median": 2.5419470523600003,
      "user": 2.7252233,
      "system": 3.8479513399999994,
      "min": 2.45621426186,
      "max": 2.81110186186,
      "times": [
        2.69959797386,
        2.77596965886,
        2.81110186186,
        2.47605767286,
        2.56920740386,
        2.54529241486,
        2.5303142148599997,
        2.53860168986,
        2.45621426186,
        2.50207660886
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.50242462946,
      "stddev": 0.03561222864646472,
      "median": 2.5193300278599997,
      "user": 2.7050105,
      "system": 3.828222540000001,
      "min": 2.45011532386,
      "max": 2.54878430786,
      "times": [
        2.54878430786,
        2.52628777086,
        2.51944551786,
        2.5218433558599997,
        2.4720824858599997,
        2.45682038486,
        2.53643735686,
        2.4732152528599998,
        2.51921453786,
        2.45011532386
      ]
    },
    {
      "command": "pnpm",
      "mean": 5.0607146373599985,
      "stddev": 0.04633669082894605,
      "median": 5.05465680136,
      "user": 8.6133407,
      "system": 4.37550224,
      "min": 4.99039563986,
      "max": 5.13744548286,
      "times": [
        5.12557617886,
        5.13744548286,
        5.04568296486,
        5.0466644688599995,
        5.01240336186,
        5.08832976686,
        5.06459201186,
        4.99039563986,
        5.03340736386,
        5.06264913386
      ]
    }
  ]
}

Scenario: Frozen Lockfile (Hot Cache)

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 732.0 ± 69.8 690.2 928.1 1.00
pacquet@main 737.4 ± 40.5 704.6 843.1 1.01 ± 0.11
pnpm 2858.8 ± 70.8 2731.1 2958.7 3.91 ± 0.38
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.7320099452200001,
      "stddev": 0.06976888963825756,
      "median": 0.71076510352,
      "user": 0.40023106,
      "system": 1.59586134,
      "min": 0.6902077230200001,
      "max": 0.92810994002,
      "times": [
        0.92810994002,
        0.7162931220200001,
        0.73056485802,
        0.7199996110200001,
        0.70886699902,
        0.70757488702,
        0.6902077230200001,
        0.70039331102,
        0.71266320802,
        0.7054257930200001
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.73736851042,
      "stddev": 0.040474134043276586,
      "median": 0.7258678565200001,
      "user": 0.42576815999999995,
      "system": 1.61701974,
      "min": 0.7045763530200001,
      "max": 0.8431368800200001,
      "times": [
        0.73368487002,
        0.71415062602,
        0.7119912770200001,
        0.7045763530200001,
        0.7180508430200001,
        0.75991033202,
        0.7339704560200001,
        0.7175930090200001,
        0.7366204580200001,
        0.8431368800200001
      ]
    },
    {
      "command": "pnpm",
      "mean": 2.8588213333199994,
      "stddev": 0.07083105548526193,
      "median": 2.87763221252,
      "user": 3.5329385599999994,
      "system": 2.36106624,
      "min": 2.73114709502,
      "max": 2.95868412902,
      "times": [
        2.8897703180199996,
        2.8732525820199997,
        2.95868412902,
        2.73114709502,
        2.7671774660199997,
        2.85847603702,
        2.89437351602,
        2.88201184302,
        2.9274490960199997,
        2.8058712510199997
      ]
    }
  ]
}

@Eyalm321 Eyalm321 force-pushed the fix/graph-builder-missing-resolution branch from 2e55cb9 to 2a39d62 Compare May 15, 2026 07:25
@Eyalm321 Eyalm321 changed the title fix(graph-builder): guard against missing resolution on peer-variant snapshots fix: 3 injectWorkspacePackages install crashes — peer-variant resolution + lifecycle re-import May 15, 2026
@Eyalm321 Eyalm321 marked this pull request as ready for review May 15, 2026 08:36

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

🧹 Nitpick comments (1)
installing/deps-installer/test/install/injectLocalPackages.ts (1)

2017-2024: 💤 Low value

Test comment is inconsistent with the actual implementation.

The comment states the fix uses keepModulesDir: true with importIndexedDir move-or-merge, but the actual implementation in runLifecycleHooksConcurrently.ts uses direct file mirroring via mirrorFilesIntoTarget + fetchFromDir. Consider updating this comment to accurately describe the fix.

📝 Suggested comment update
 // Regression test: when an injected workspace package has a prepare script
 // AND any dep with a bin, the re-import after the script crashed with
 // `ENOENT: copyfile '...node_modules/.bin/<tool>'`. `runLifecycleHooksConcurrently`
-// scanned the existing injected node_modules and added absolute paths under
-// it to the filesMap; the importer's fast path then wiped the target before
-// reading from those paths. Fixed by passing `keepModulesDir: true` and
-// letting `importIndexedDir` move-or-merge node_modules across the staging
-// dir.
+// scanned the existing injected node_modules and added absolute paths under
+// it to the filesMap; the importer's fast path then wiped the target before
+// reading from those paths. Fixed by replacing the import-package flow with
+// a direct file mirror: fetchFromDir retrieves the source tree (excluding
+// node_modules) and mirrorFilesIntoTarget overlays files into the injected
+// target, preserving the existing node_modules with its bin symlinks.
🤖 Prompt for 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.

In `@installing/deps-installer/test/install/injectLocalPackages.ts` around lines
2017 - 2024, Update the test comment to accurately reflect the implemented fix:
replace the statement that the fix uses keepModulesDir: true and
importIndexedDir's move-or-merge behavior with a description that
runLifecycleHooksConcurrently actually mirrors files into the target and
re-imports using mirrorFilesIntoTarget combined with fetchFromDir (rather than
relying on importIndexedDir), and mention that this approach prevents the ENOENT
during re-import by preserving injected node_modules paths used by the importer.
🤖 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.

Nitpick comments:
In `@installing/deps-installer/test/install/injectLocalPackages.ts`:
- Around line 2017-2024: Update the test comment to accurately reflect the
implemented fix: replace the statement that the fix uses keepModulesDir: true
and importIndexedDir's move-or-merge behavior with a description that
runLifecycleHooksConcurrently actually mirrors files into the target and
re-imports using mirrorFilesIntoTarget combined with fetchFromDir (rather than
relying on importIndexedDir), and mention that this approach prevents the ENOENT
during re-import by preserving injected node_modules paths used by the importer.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e90c3ad-427d-47c2-9869-5b9a6a84f8b8

📥 Commits

Reviewing files that changed from the base of the PR and between c9f9535 and 2a39d62.

📒 Files selected for processing (6)
  • .changeset/fix-lifecycle-reimport-mirror.md
  • .changeset/guard-missing-resolution-graph-builder.md
  • deps/graph-builder/src/lockfileToDepGraph.ts
  • exec/lifecycle/src/runLifecycleHooksConcurrently.ts
  • installing/deps-installer/test/install/injectLocalPackages.ts
  • releasing/commands/src/deploy/createDeployFiles.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-lifecycle-reimport-mirror.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/guard-missing-resolution-graph-builder.md
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with modifications: trailing commas are used, functions are preferred over classes, functions are declared after they are used (hoisting is relied upon).
Functions should have no more than two or three arguments. If a function needs more parameters, use a single options object instead.
Maintain import order: (1) Standard libraries, (2) External dependencies (sorted alphabetically), (3) Relative imports.

Files:

  • releasing/commands/src/deploy/createDeployFiles.ts
  • deps/graph-builder/src/lockfileToDepGraph.ts
  • installing/deps-installer/test/install/injectLocalPackages.ts
  • exec/lifecycle/src/runLifecycleHooksConcurrently.ts
🧠 Learnings (1)
📚 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:

  • releasing/commands/src/deploy/createDeployFiles.ts
  • deps/graph-builder/src/lockfileToDepGraph.ts
  • installing/deps-installer/test/install/injectLocalPackages.ts
  • exec/lifecycle/src/runLifecycleHooksConcurrently.ts
🔇 Additional comments (5)
deps/graph-builder/src/lockfileToDepGraph.ts (1)

193-219: LGTM!

Also applies to: 242-247

releasing/commands/src/deploy/createDeployFiles.ts (1)

74-93: LGTM!

exec/lifecycle/src/runLifecycleHooksConcurrently.ts (2)

76-99: LGTM!


106-142: LGTM!

installing/deps-installer/test/install/injectLocalPackages.ts (1)

2025-2099: LGTM!

@Eyalm321

Copy link
Copy Markdown
Contributor Author

Note for triage: CodeRabbit auto-applied area: cli/dlx, but this PR doesn't touch dlx/CLI invocation. The matching upstream labels would be:

  • area: monorepoinjectWorkspacePackages is a workspace feature
  • area: deploy — Bug 1's createDeployFiles crash + fix
  • area: lifecycle-scripts — Bug 2's re-import after prepare/postinstall

(I can't relabel myself — flagging for whoever picks this up.)

@Eyalm321 Eyalm321 marked this pull request as draft May 15, 2026 09:02
@Eyalm321 Eyalm321 marked this pull request as ready for review May 15, 2026 09:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
lockfile/utils/test/inheritOrSynthesizeResolution.ts (1)

34-38: ⚡ Quick win

Add a regression test for file: tarball refs.

Given the helper’s intended behavior, add a case that ensures file:...tgz depPaths are not synthesized into directory resolutions.

Suggested test addition
 test('returns the input untouched when resolution cannot be inherited or synthesized', () => {
   // Non-file:, non-peer-variant depPath with no base entry to inherit from.
   const input = {} as PackageSnapshot
   expect(inheritOrSynthesizeResolution('foo@1.0.0', input, undefined)).toBe(input)
 })
+
+test('does not synthesize directory resolution for file: tarball refs', () => {
+  const input = {} as PackageSnapshot
+  const depPath = 'foo@file:./foo-1.0.0.tgz(peer@2.0.0)' as DepPath
+  expect(inheritOrSynthesizeResolution(depPath, input, { [depPath]: input })).toBe(input)
+})
🤖 Prompt for 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.

In `@lockfile/utils/test/inheritOrSynthesizeResolution.ts` around lines 34 - 38,
Add a regression test that calls inheritOrSynthesizeResolution with a depPath
that is a file tarball (e.g. 'file:some/path/pkg.tgz') and an input
PackageSnapshot with no base entry, asserting that the function returns the
original input (not a synthesized directory resolution); specifically, add a
case alongside the existing test that uses
inheritOrSynthesizeResolution('file:...tgz', input, undefined) and expects
toBe(input) to ensure file:...tgz refs are not converted into directory
resolutions.
🤖 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 `@lockfile/utils/src/inheritOrSynthesizeResolution.ts`:
- Around line 1-4: The code path in inheritOrSynthesizeResolution that treats
any depPath starting with "file:" as a directory should be tightened: in the
branch where depPath is used to synthesize a { type: 'directory' } resolution
(look for the logic inside the inheritOrSynthesizeResolution function that
checks depPath/file: refs), only synthesize a directory when the file: ref is a
local-directory reference (e.g., not a tarball or archive). Change the condition
so it validates the file: ref is a directory form by rejecting known archive
extensions (for example .tgz, .tar, .tar.gz, .zip) or by resolving the path and
stat-ing it to confirm it is a directory before returning { type: 'directory' };
otherwise fall back to the non-directory resolution path. Ensure this update
targets the depPath/file: handling branch so tarball file: refs are not
classified as type: 'directory'.

---

Nitpick comments:
In `@lockfile/utils/test/inheritOrSynthesizeResolution.ts`:
- Around line 34-38: Add a regression test that calls
inheritOrSynthesizeResolution with a depPath that is a file tarball (e.g.
'file:some/path/pkg.tgz') and an input PackageSnapshot with no base entry,
asserting that the function returns the original input (not a synthesized
directory resolution); specifically, add a case alongside the existing test that
uses inheritOrSynthesizeResolution('file:...tgz', input, undefined) and expects
toBe(input) to ensure file:...tgz refs are not converted into directory
resolutions.
🪄 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: d7bb5cee-8c85-4328-a8d6-cb8ce6158c07

📥 Commits

Reviewing files that changed from the base of the PR and between 6f64082 and d4feabc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • .changeset/guard-missing-resolution-graph-builder.md
  • deps/compliance/sbom/src/collectComponents.ts
  • deps/graph-builder/src/lockfileToDepGraph.ts
  • deps/inspection/tree-builder/src/getPkgInfo.ts
  • installing/deps-restorer/src/lockfileToHoistedDepGraph.ts
  • lockfile/utils/src/index.ts
  • lockfile/utils/src/inheritOrSynthesizeResolution.ts
  • lockfile/utils/test/inheritOrSynthesizeResolution.ts
  • releasing/commands/package.json
  • releasing/commands/src/deploy/createDeployFiles.ts
  • releasing/commands/tsconfig.json
✅ Files skipped from review due to trivial changes (2)
  • releasing/commands/package.json
  • releasing/commands/tsconfig.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • .changeset/guard-missing-resolution-graph-builder.md
  • deps/graph-builder/src/lockfileToDepGraph.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use Standard Style with modifications: trailing commas are used, functions are preferred over classes, functions are declared after they are used (hoisting is relied upon).
Functions should have no more than two or three arguments. If a function needs more parameters, use a single options object instead.
Maintain import order: (1) Standard libraries, (2) External dependencies (sorted alphabetically), (3) Relative imports.

Files:

  • lockfile/utils/test/inheritOrSynthesizeResolution.ts
  • lockfile/utils/src/index.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • installing/deps-restorer/src/lockfileToHoistedDepGraph.ts
  • deps/inspection/tree-builder/src/getPkgInfo.ts
  • lockfile/utils/src/inheritOrSynthesizeResolution.ts
  • releasing/commands/src/deploy/createDeployFiles.ts
🧠 Learnings (1)
📚 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:

  • lockfile/utils/test/inheritOrSynthesizeResolution.ts
  • lockfile/utils/src/index.ts
  • deps/compliance/sbom/src/collectComponents.ts
  • installing/deps-restorer/src/lockfileToHoistedDepGraph.ts
  • deps/inspection/tree-builder/src/getPkgInfo.ts
  • lockfile/utils/src/inheritOrSynthesizeResolution.ts
  • releasing/commands/src/deploy/createDeployFiles.ts
🔇 Additional comments (5)
lockfile/utils/src/index.ts (1)

3-3: LGTM!

installing/deps-restorer/src/lockfileToHoistedDepGraph.ts (1)

19-23: LGTM!

Also applies to: 183-193

deps/compliance/sbom/src/collectComponents.ts (1)

3-3: LGTM!

Also applies to: 101-104

deps/inspection/tree-builder/src/getPkgInfo.ts (1)

11-14: LGTM!

Also applies to: 77-87, 99-106

releasing/commands/src/deploy/createDeployFiles.ts (1)

14-14: LGTM!

Also applies to: 75-81

Comment thread lockfile/utils/src/inheritOrSynthesizeResolution.ts Outdated
@Eyalm321

Eyalm321 commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

Working on fixes for the failed tests @btea @zkochan

Edit:

Good to go.

@Eyalm321

Copy link
Copy Markdown
Contributor Author

Should be good now @btea @zkochan 🙏🏻

@Eyalm321

Copy link
Copy Markdown
Contributor Author

The zizmor latest via PyPI check is failing, but it's a preexisting issue unrelated to this PR. It reports 3 medium ref-version-mismatch findings on garnet-org/action@9e819143... in .github/workflows/test.yml and update-latest.yml (the # v2 comment doesn't match the SHA's actual tag v2.0.1).

This PR doesn't touch any workflow files — all changes are in deps/, installing/, lockfile/, exec/, and releasing/. All other checks (Compile & Lint, CodeQL, Audit, and the full Node 22/24/26 ubuntu/windows test matrix) pass. Any PR against main would currently hit this. Happy to leave it to maintainers since it's outside this PR's scope (zizmor reports an available auto-fix for it).

zkochan pushed a commit to Eyalm321/pnpm that referenced this pull request May 17, 2026
Per CodeRabbit review on pnpm#11662 — `.resolves.not.toThrow()`
doesn't behave as intended here: after `.resolves` the matcher operates
on the resolved value (an `InstallationResult`), not on a function, so
`.toThrow()` would never validate the rejection path. Just awaiting the
call makes jest fail naturally on rejection, which is what the
regression test actually wants.
@zkochan zkochan force-pushed the fix/graph-builder-missing-resolution branch from d3af249 to 7e18676 Compare May 17, 2026 10:44
@zkochan

zkochan commented May 17, 2026

Copy link
Copy Markdown
Member

Reviewed this with a fresh eye and want to share a couple of concerns before this lands. I'll group by bug.

Bug 1 (resolution) — most of the helper is solving a problem that doesn't exist

The format converter at lockfile/fs/src/lockfileFormatConverters.ts:141-149 already inherits resolution from the base entry at load time:

for (const [depPath, pkg] of Object.entries(lockfile.snapshots ?? {})) {
  const pkgId = removeSuffix(depPath)
  packages[depPath as DepPath] = Object.assign(pkg, lockfile.packages?.[pkgId])
}

I verified two things locally:

  1. Loaded the fixture in this PR (installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml) through convertToLockfileObject — the variant snapshot's resolution is populated after load.
  2. Reverted both inheritOrSynthesizeResolution(...) and the belt-and-suspenders guard in lockfileToDepGraph.ts (back to the exact main-branch code), rebuilt, ran test/index.ts -t 'peer-variant'it still passes.

So the fixture as written doesn't reproduce the bug it claims to fix. It passes on main without any of this PR's code.

The bug is only actually reachable when the base entry is missing from the file's packages section — i.e., a pruned lockfile (turbo prune --docker and similar). I confirmed this by simulating a pruned shape: only then does pkgSnapshot.resolution come out undefined after the converter.

That means in inheritOrSynthesizeResolution:

  • The "inherit from base" branch is redundant with the converter — it re-does the lockfile.packages?.[removeSuffix(depPath)] lookup that already happened at load.
  • Only the "synthesize from file: depPath" branch does real work — and it's a heuristic. Synthesizing { type: 'directory', directory: <slice of depPath> } from a file: prefix could install from the wrong place if the lockfile is genuinely malformed rather than just pruned.

I'd suggest one of two approaches, picking whichever matches pnpm's stance on pruned lockfiles:

  • If pruned lockfiles are supported: do the synthesis once inside convertToLockfileObject (the place that already does inheritance), drop the helper and the 5 per-iteration calls.
  • If pruned lockfiles are not supported: throw a clear BROKEN_LOCKFILE at load time (base entry missing for variant X, lockfile appears pruned), and the iteration sites can keep their current direct pkgSnapshot.resolution access.

Either way, normalization at the read layer (one place) is cleaner than a helper with a "every iteration site must remember to call this" contract.

Also: with the current shape, please update the fixture to actually omit the base entry — otherwise the regression test isn't guarding against the bug it's named after.

Bug 2 (lifecycle re-import) — replacement bypasses a tested abstraction

The wipe-then-copy crash is real (#11088 made the fast path default, and the old lifecycle code uniquely mixed target-internal paths from scanDir into the filesMap passed to importPackage, which the fast path then wiped).

But replacing storeController.importPackage with a copyFileSync loop has costs:

  • Silently regresses package-import-method=hardlink (default) for this caller — re-imported source files become plain copies instead of hardlinks.
  • Loses the atomic completion semantics that the indexed-pkg-importer provides.
  • Adds a parallel file-mirror code path in exec/lifecycle/ that has to be maintained alongside the canonical one.

A smaller fix is at the caller: keep importPackage for the source files (which is what it's designed to do), and stop putting target-internal scanDir entries into the filesMap. Those files are already in place from the initial install — there's no reason to re-import them. The fast path's wipe is only a problem because we asked it to copy paths-inside-the-target; if we don't ask, it won't fail. lockfile/scanDir of the existing node_modules was a workaround for the staging path's old behavior, not a requirement of re-import.

If keepModulesDir: true is unavoidable for some reason and breaks under EXDEV via moveOrMergeModulesDirscopySync, that's its own bug worth fixing at fs/indexed-pkg-importer instead of routing around it here.

Other notes

  • Pushed a small cleanup (f6c8786) removing a stale // eslint-disable-line @typescript-eslint/no-explicit-any on a catch (err: unknown) — the disable targeted no-explicit-any but there's no any involved.
  • The PR's stated CodeRabbit follow-ups (.resolves.not.toThrow removed, refIsLocalDirectory gating, tarball-ref test) all look addressed in the current head.

Happy to iterate on either approach. The Bug 2 fix as-is is at least localized and the tests pass, so it's not blocking; the Bug 1 architecture is the bigger thing I'd want to revisit.


Written by an agent (Claude Code, claude-opus-4-7).

@Eyalm321

Copy link
Copy Markdown
Contributor Author

I'm going to do some deeper tests and come back with a conclusive solution

@Eyalm321 Eyalm321 marked this pull request as draft May 17, 2026 21:24
@Eyalm321 Eyalm321 changed the title fix: 3 injectWorkspacePackages install crashes — peer-variant resolution + lifecycle re-import fix: injectWorkspacePackages crashes — lean resolution defense-in-depth + lifecycle re-import May 17, 2026
@Eyalm321 Eyalm321 marked this pull request as ready for review May 18, 2026 03:31
@Eyalm321

Copy link
Copy Markdown
Contributor Author

Thanks @zkochan for the detailed review — pushed e5d38eeb8 addressing both bugs along the lines you suggested.

Bug 1 (resolution)

  • Architecture: synthesis now happens once in convertToLockfileObject (the single load-time normalization point that already does the base-entry inheritance). The standalone helper and the per-reader guards are gone and the readers are back to main — this is your "do the synthesis once inside convertToLockfileObject, drop the helper" option.
  • Fixture: you were right that it didn't reproduce the bug. installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml no longer ships the base pkg-a@file:packages/pkg-a entry, so it's the actual pruned shape now. Re-running your experiment against the corrected fixture: revert the convertToLockfileObject change and the variant comes back from the converter with resolution: undefined (→ Cannot use 'in' operator … in undefined in lockfileToDepGraph); with this PR it's reconstructed as { type: 'directory', directory: 'packages/pkg-a' }. So it now fails on main and only passes with the change.
  • Added a lockfile.fs unit test (lockfileV6Converters.test.ts) asserting the directory resolution is synthesized for a pruned file: peer-variant but never for a file: tarball, to pin the heuristic boundary you flagged.

On your alternative (throw BROKEN_LOCKFILE at load instead of synthesizing): I kept synthesis because turbo prune --docker producing exactly this shape is a routine workflow, and the reconstructed { type: 'directory', directory } is precisely what pnpm's own writer emits for an injected workspace dep — lossless reconstruction, not a guess. The turbo-side root cause is also being fixed upstream, so this stays defense-in-depth rather than load-bearing. Glad to switch to the hard-error approach instead if you'd rather pnpm reject pruned lockfiles outright.

Bug 2 (lifecycle re-import)

  • Reduced to the smaller fix you described: keep storeController.importPackage, drop the scanDir-into-filesMap workaround, pass keepModulesDir: true. No copy-loop; source files keep their hardlinks.
  • Caveat, to be upfront: keepModulesDir: true routes the target's existing node_modules through importIndexedDir's staging path, which under EXDEV falls back to copySync via moveOrMergeModulesDirs. That's pre-existing importer behavior, not introduced here — happy to harden the cross-device path at fs/indexed-pkg-importer in a separate change if you'd prefer.
  • Also corrected the stale test comment that still described the abandoned mirror approach, and made the injected-package assertion explicit.

Full suite is on CI. Happy to iterate further on either — especially the Bug 1 direction if you want to go the hard-error route.

Boshen added a commit to rolldown/plugins that referenced this pull request May 18, 2026
…nd approve native builds

The `file:../../babel` references in the 4 benchmark packages trigger a
pnpm 11 crash during the lifecycle re-import step (pnpm/pnpm#11663 /
pnpm/pnpm#11662, still in draft). Aligning with the other plugins'
`workspace:*` pattern sidesteps the bug; benchmarks must now run after
`pnpm build`, same as every other workspace plugin.

Also fills in the `allowBuilds` template pnpm 11 prompts for, allowing
`@swc/core` (native bindings for benchmarks) and `playwright-chromium`
(Chromium download for examples e2e).
Eyalm321 and others added 22 commits May 19, 2026 23:41
…snapshots

Peer-dep variant snapshots in the lockfile (e.g.
`pkg@file:packages/pkg(peer@2.0.0)`) inherit their resolution from the
base entry and may legally omit `resolution` themselves. The previous
code in `buildGraphFromPackages` accessed `pkgSnapshot.resolution`
directly with the `in` operator, which crashes with
`TypeError: Cannot use 'in' operator to search for 'directory' in undefined`
when `resolution` is unset.

Guarding the access mirrors the pattern in `@pnpm/deps.graph-hasher`,
which already handles missing resolution defensively. A snapshot
without `resolution` is treated as "not a directory dep" — the base
entry still drives directory-dep detection through its own snapshot.
Per CodeRabbit review on pnpm#11662 — `.resolves.not.toThrow()`
doesn't behave as intended here: after `.resolves` the matcher operates
on the resolved value (an `InstallationResult`), not on a function, so
`.toThrow()` would never validate the rejection path. Just awaiting the
call makes jest fail naturally on rejection, which is what the
regression test actually wants.
Peer-dep variant snapshots in the lockfile (e.g.
`pkg@1.0.0(peer@2.0.0)`) inherit their `resolution` from the base entry
— pnpm's lockfile writer omits `resolution` on variants to avoid
duplication. The previous commit guarded the `'directory' in
pkgSnapshot.resolution` access against `undefined`, but downstream code
(`pkgSnapshotToResolution`, the fetch path, etc.) still expected a
fully-formed snapshot and crashed on the same shape.

Inherit the base entry's resolution onto the variant snapshot early in
`buildGraphFromPackages` so every downstream access sees a complete
snapshot. If the base entry has been pruned by upstream tooling (e.g.
`turbo prune --docker` only keeps the variant referenced by the
consumer) and is therefore not in `lockfile.packages`, synthesize a
directory resolution from the depPath's `file:` prefix instead of
letting the fall-through path treat the workspace dir as a tarball
(which then crashes with EISDIR at fetch time).
…eateDeployFiles

`createDeployFiles` iterates `lockfile.packages` and passes each
snapshot to `convertPackageSnapshot`, which dereferences
`inputSnapshot.resolution` with `'integrity' in inputResolution`. Peer-
dep variant snapshots have no `resolution` of their own (they inherit
from the base entry), so the access throws
`Cannot use 'in' operator to search for 'integrity' in undefined`
during `pnpm deploy`.

Apply the same inherit-from-base + synthesize-directory-from-depPath
pattern used in `buildGraphFromPackages` so the variant has a complete
resolution before reaching `convertPackageSnapshot`. Same root cause,
different code path.
The original changeset described only the graph-builder guard. The
follow-up commits cover the same root cause in createDeployFiles, so
the changeset now also bumps @pnpm/releasing.commands and explains the
inherit-from-base + synthesize-directory pattern that addresses the
multiple unguarded access sites in one go.
…ipts

When `runLifecycleHooksConcurrently` re-imports a workspace package into
its injected targets after running prepare/postinstall, it has to
preserve the existing `node_modules/` inside each target — set up
during the initial install (bin symlinks, transitive deps) — while
overlaying the script's output (typically a freshly-built `dist/`).

The previous approach was a manual `scanDir` over `<targetDir>/node_modules`
that fed absolute paths into the re-import's `filesMap`. That worked in
2022 (pnpm#4299) when `importIndexedDir` always staged-and-renamed, but
pnpm#11088 made the fast path (write directly into the final dir) the
default. The fast path's `makeEmptyDirSync(targetDir)` wipes the very
files the scanned entries point at, so `tryImportIndexedDir`'s first
copy throws:

    ERR_PNPM_ENOENT on <targetDir>/node_modules/.bin/<tool>

Reproduces on any workspace package that has a `prepare`/`postinstall`
script + a dep with a bin entry, when injected via
`injectWorkspacePackages: true` or `dependenciesMeta.<dep>.injected`.

Fix: skip the `importPackage` round-trip entirely. After lifecycle
scripts complete, iterate the source's `filesMap` (from `fetchFromDir`,
which already excludes `node_modules/`) and copy each entry directly
into each `targetDir`. The target's existing `node_modules/` is never
touched — its bin links + transitive deps stay intact — and there's no
tmp-dir staging swap to trip over `.bin/<tool>` symlinks.

Regression test in `injectLocalPackages.ts`: an injected workspace
package with a `prepublishOnly` script + `@pnpm.e2e/hello-world-js-bin`
dep. Before the fix this throws `ERR_PNPM_ENOENT` on
`.bin/hello-world-js-bin`; after the fix both the script's output and
the bin symlink survive into the injected copy.
The regression test's preamble still described the abandoned
`keepModulesDir: true` + move-or-merge approach. Rewrite to match
the mirror-based implementation that shipped in the parent commit
(fetchFromDir + mirrorFilesIntoTarget, no importPackage round-trip).
…ly to all readers

The inherit/synthesize logic for peer-variant snapshots (which legally omit
`resolution` because they inherit it from the base entry) was previously
inlined in `buildGraphFromPackages` and `createDeployFiles`. Auditing every
caller of `pkgSnapshotToResolution` surfaced four more iteration sites that
dereference `pkgSnapshot.resolution` directly on a raw `lockfile.packages`
entry and therefore had the same latent crash:

  - `lockfileToHoistedDepGraph` — `pnpm install --node-linker=hoisted`
  - `collectSbomComponents` — `pnpm sbom`
  - `getPkgInfo` — `pnpm list` / tree view
  - (graph-builder + createDeployFiles, already fixed inline)

Extract the inherit/synthesize logic into a new exported helper in
`@pnpm/lockfile.utils` and call it at every iteration site. Downstream
code paths — `pkgSnapshotToResolution`, `convertPackageSnapshot`,
`'directory' in resolution`, `'integrity' in resolution` — now all see a
fully-formed snapshot regardless of which command entered the loop.

The synthesize-from-`file:` fallback (covers pruned lockfiles, e.g.
`turbo prune --docker` keeping only the variant entry) is now documented
in the helper to discourage future readers from widening it to other
`file:` shapes (`file:./local-tarball.tgz` etc., which reach a different
code path).

Unit tests for the helper added in `lockfile/utils/test/`. The existing
fixture-based regression test in `installing/deps-restorer/test/index.ts`
continues to cover the install-path end-to-end.
Synthesizing `{ type: 'directory' }` resolution for any depPath starting
with `file:` was too wide — `file:./foo.tgz`, `file:./foo.tar.gz`, and
`file:./foo.tar` are local-tarball refs that reach a different code path
in pnpm. Misclassifying them as directories would route them through
local-package install code with a `directory` field they don't have on
disk.

Use the existing `refIsLocalDirectory` helper (which is `startsWith('file:')`
minus the local-tarball extensions) so the synthesis branch only fires for
true workspace-directory refs.

Also expands the JSDoc with the helper's usage contract: every iteration
site that touches `pkgSnapshot.resolution` directly or via a downstream
utility (`pkgSnapshotToResolution`, `convertPackageSnapshot`, etc.) must
route its raw `lockfile.packages[depPath]` entry through this helper first.
Downstream utilities assume their input is already normalized.

Regression test added for the three local-tarball extensions.
Two follow-ups from the upstream CI run on canora.11:

1. `mirrorFilesIntoTarget` only mkdir'd intermediate sub-directories
   (collected via `path.dirname(relPath)`), so any workspace package with
   only top-level files (e.g. `main.js`) ended up calling unlinkSync
   against a path whose parent target dir didn't exist yet — failing
   three pre-existing `inject local packages and relink them after build`
   tests on Linux. Pre-create the target dir itself before the sub-dir
   loop so top-level relPaths are covered.

2. The catch around unlinkSync used `err instanceof Error` to narrow
   before reading `.code`. That check is unreliable across bundler /
   realm boundaries (the bundled pnpm CLI is one artifact running under
   Node's experimental-vm-modules in tests, where Error identity can
   diverge). Match the pattern used elsewhere in the pnpm tree
   (bins.linker, modules-yaml, bins.resolver) and cast directly to
   `NodeJS.ErrnoException`, which is what those call sites do.

Also corrects the bin-having-dep regression test to use `prepare`
(which pnpm runs during install) instead of `prepublishOnly` (only
fires during publish). The original test name is unchanged — the test
still asserts the same behaviour, just with a lifecycle script that
actually runs in the install path being tested.
…ture

The fixture's lockfile was minimal — it had the pkg-a base + peer-variant
entries (the shape that triggered the original `'directory' in undefined`
TypeError in graph-builder) but no `peer@1.0.0` entry. That was enough
before Bug 1's fix, because graph-builder crashed early. Now that the
graph-builder + helper changes let the install path proceed, the next
stage (`filterLockfileByImportersAndEngine`) walks the dependency graph
and throws `LockfileMissingDependencyError: no entry for 'peer@1.0.0'`.

Add a stub directory-resolution entry pointing at the existing
`packages/peer/` workspace package so the graph walker finds it.
The disable comment targeted @typescript-eslint/no-explicit-any but the
catch is typed `unknown`, not `any`. No suppression needed.
Per the PR review (zkochan); pairs with the turbo-side root-cause fix
vercel/turborepo#12819.

Bug 1 (peer-variant resolution): normalize once at the read layer in
convertToLockfileObject — when a pruned lockfile dropped the base
packages entry, synthesize the directory resolution from the file:
depPath (reconstruction of exactly what pnpm's writer emits, not a
guess). This makes inheritOrSynthesizeResolution + its 5 reader call
sites inert idempotent guards (resolution is always populated before
they run); the active normalization now lives solely in the converter.
Mechanical removal of the now-dead helper is a trivial follow-up.

Bug 2 (lifecycle re-import .bin ENOENT): drop the pnpm#4299 scanDir-into-
filesMap workaround and pass keepModulesDir: true so importIndexedDir
skips the pnpm#11088 makeEmptyDir fast path and preserves the target's
existing node_modules via its staging/move path. Stays on
storeController.importPackage so source files keep hardlinks — replaces
the copy-loop mirror.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the inheritOrSynthesizeResolution helper + its test + all 5
reader call-sites, reverting them to direct snapshot access. With the
single read-layer synthesize in convertToLockfileObject every reader
already sees a fully-formed snapshot; the per-reader helper was
redundant. Matches the PR description and zkochan's review. Bug-2
lifecycle fix and the regression fixtures/tests are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 4 reader files (sbom collect, graph-builder, tree-builder getPkgInfo,
deps-restorer hoisted) are reverted fully to upstream — the leftover
helper-era scaffolding (rawPkgSnapshot renames, pkgPackages plumbing,
the standalone `'directory'/'integrity' in …` null-guards) is removed.
Bug-1 now lives ONLY in convertToLockfileObject. The unused
@pnpm/lockfile.utils dep is removed from releasing.commands
(package.json + tsconfig + pnpm-lock). The two stale changesets
(describing the abandoned mirror + helper approaches, wrong package
bumps) are replaced with one accurate changeset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s; my prior revert wrongly stripped it → compile error). getPkgInfo now pristine vs upstream.
The deps-restorer regression fixture still shipped the base
`pkg-a@file:packages/pkg-a` packages entry, so convertToLockfileObject
inherited `resolution` from it and the synthesize branch was never
exercised — the test passed on main without the fix (tautological, as
raised in review). Drop the base entry so the fixture is the real
pruned shape (`turbo prune --docker` output).

- lockfile.fs: add a convertToLockfileObject unit test asserting a
  directory resolution is synthesized for a pruned `file:` peer-variant
  but never for a `file:` tarball.
- deps-installer: correct the stale injectLocalPackages comment that
  described an abandoned mirrorFilesIntoTarget approach instead of the
  shipped `keepModulesDir: true` importPackage path, and make its
  project-1 existence check an explicit `expect(...).not.toThrow()`.
vercel/turborepo#12819 was closed; the live root-cause fix (retain the
base packages entry for peer-variant injected deps) is tracked at
vercel/turborepo#12825. Keeps the source comment consistent with the
PR description.
The peer-variant resolution synthesis in `convertToLockfileObject` skips
local tarballs via an `endsWith` chain against `.tgz` / `.tar.gz` / `.tar`,
but `parseBareSpecifier`'s `isFilename = /\.(?:tgz|tar.gz|tar)$/i` matches
case-insensitively. A `file:vendor/pkg.TGZ` ref with a dropped base entry
would have been synthesized as `{ type: 'directory', directory: ... }`,
diverging from how the resolver classifies the same ref at install time.

Replace the chain with `LOCAL_TARBALL_RE = /\\.(?:tgz|tar\\.gz|tar)$/i` and
extend the heuristic-boundary test in `lockfileV6Converters.test.ts` with
`.TGZ` and `.Tar.Gz` cases.

---
Written by an agent (Claude Code, claude-opus-4-7).
Mirror the TS-side defense-in-depth fix on the pacquet stack so a
pruned `injectWorkspacePackages` lockfile (older `turbo prune --docker`
output, pre vercel/turborepo#12825) loads on Rust too. Two pieces:

1. Widen `PkgVerPeer` to accept `file:` versions. The version slot
   becomes a `VersionPart::{Semver(Version), File(String)}` sum type
   so a snapshot key like `pkg@file:packages/pkg(peer@1.0.0)` parses
   instead of failing at the semver `from_str`. Display writes the
   `file:` scheme back, so round-trips stay byte-stable. The two
   semver-required callers (`find_runtime_node_major` and
   `find_own_runtime_node_major`) migrate to a new `version_semver()`
   accessor; `runtime:` and `file:` are explicitly mutually exclusive
   at parse time.

2. Add `Lockfile::reconstruct_missing_directory_resolutions()` and
   run it at the end of `load_from_path`. Analog of the TS
   `convertToLockfileObject` synthesis: for any peer-variant snapshot
   whose `without_peer()` lookup misses, fabricate a
   `LockfileResolution::Directory` from the `file:` path so every
   downstream `packages.get(...).resolution` call keeps working. The
   tarball-vs-directory boundary mirrors the resolver's
   `/\\.(?:tgz|tar\\.gz|tar)$/i` regex via a case-insensitive suffix
   check on `.tgz` / `.tar.gz` / `.tar`.

Tests:

- `pkg_ver_peer/tests.rs`: round-trip + serde for `file:` (plain and
  with peer suffix); `parse_err` cases for empty path after `file:`
  and `runtime:file:` conflict.
- `load_lockfile/tests.rs`: pruned lockfile with one directory
  peer-variant + lowercase / uppercase / mixed-case tarball
  peer-variants asserts that only the directory case gets a
  synthesized resolution.

---
Written by an agent (Claude Code, claude-opus-4-7).
@zkochan zkochan force-pushed the fix/graph-builder-missing-resolution branch from d294098 to 59b8393 Compare May 19, 2026 21:51
@codecov-commenter

codecov-commenter commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.10%. Comparing base (0a40f64) to head (f1f487e).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #11662      +/-   ##
==========================================
+ Coverage   90.06%   90.10%   +0.03%     
==========================================
  Files         146      146              
  Lines       16795    16868      +73     
==========================================
+ Hits        15127    15199      +72     
- Misses       1668     1669       +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Trailing comma on the multi-line `assert!(...)` in the new
`reconstructs_dropped_directory_resolution_for_pruned_file_peer_variant`
test. Dylint flagged it on CI; the rest of the new code passes the lint
locally.

---
Written by an agent (Claude Code, claude-opus-4-7).
@zkochan zkochan merged commit 9cb48bb into pnpm:main May 19, 2026
19 of 20 checks passed
@welcome

welcome Bot commented May 19, 2026

Copy link
Copy Markdown

Congrats on merging your first pull request! 🎉🎉🎉

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.

3 participants