fix: injectWorkspacePackages crashes — lean resolution defense-in-depth + lifecycle re-import#11662
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR fixes two crashes when ChangesLockfile resolution reconstruction for peer variants
Lifecycle re-import with node_modules preservation
🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (7)
.changeset/guard-missing-resolution-graph-builder.mddeps/graph-builder/src/lockfileToDepGraph.tsinstalling/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.jsoninstalling/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.jsoninstalling/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.jsoninstalling/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yamlinstalling/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.tsdeps/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.tsdeps/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.jsoninstalling/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.jsoninstalling/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!
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.
27df41a to
c9f9535
Compare
Integrated-Benchmark Report (Linux)Scenario: Frozen Lockfile
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)
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
]
}
]
} |
2e55cb9 to
2a39d62
Compare
resolution on peer-variant snapshotsThere was a problem hiding this comment.
🧹 Nitpick comments (1)
installing/deps-installer/test/install/injectLocalPackages.ts (1)
2017-2024: 💤 Low valueTest comment is inconsistent with the actual implementation.
The comment states the fix uses
keepModulesDir: truewithimportIndexedDirmove-or-merge, but the actual implementation inrunLifecycleHooksConcurrently.tsuses direct file mirroring viamirrorFilesIntoTarget+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
📒 Files selected for processing (6)
.changeset/fix-lifecycle-reimport-mirror.md.changeset/guard-missing-resolution-graph-builder.mddeps/graph-builder/src/lockfileToDepGraph.tsexec/lifecycle/src/runLifecycleHooksConcurrently.tsinstalling/deps-installer/test/install/injectLocalPackages.tsreleasing/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.tsdeps/graph-builder/src/lockfileToDepGraph.tsinstalling/deps-installer/test/install/injectLocalPackages.tsexec/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.tsdeps/graph-builder/src/lockfileToDepGraph.tsinstalling/deps-installer/test/install/injectLocalPackages.tsexec/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!
|
Note for triage: CodeRabbit auto-applied
(I can't relabel myself — flagging for whoever picks this up.) |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
lockfile/utils/test/inheritOrSynthesizeResolution.ts (1)
34-38: ⚡ Quick winAdd a regression test for
file:tarball refs.Given the helper’s intended behavior, add a case that ensures
file:...tgzdepPaths 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (11)
.changeset/guard-missing-resolution-graph-builder.mddeps/compliance/sbom/src/collectComponents.tsdeps/graph-builder/src/lockfileToDepGraph.tsdeps/inspection/tree-builder/src/getPkgInfo.tsinstalling/deps-restorer/src/lockfileToHoistedDepGraph.tslockfile/utils/src/index.tslockfile/utils/src/inheritOrSynthesizeResolution.tslockfile/utils/test/inheritOrSynthesizeResolution.tsreleasing/commands/package.jsonreleasing/commands/src/deploy/createDeployFiles.tsreleasing/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.tslockfile/utils/src/index.tsdeps/compliance/sbom/src/collectComponents.tsinstalling/deps-restorer/src/lockfileToHoistedDepGraph.tsdeps/inspection/tree-builder/src/getPkgInfo.tslockfile/utils/src/inheritOrSynthesizeResolution.tsreleasing/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.tslockfile/utils/src/index.tsdeps/compliance/sbom/src/collectComponents.tsinstalling/deps-restorer/src/lockfileToHoistedDepGraph.tsdeps/inspection/tree-builder/src/getPkgInfo.tslockfile/utils/src/inheritOrSynthesizeResolution.tsreleasing/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
|
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). |
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.
d3af249 to
7e18676
Compare
|
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 existThe format converter at 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:
So the fixture as written doesn't reproduce the bug it claims to fix. It passes on The bug is only actually reachable when the base entry is missing from the file's That means in
I'd suggest one of two approaches, picking whichever matches pnpm's stance on pruned lockfiles:
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 abstractionThe wipe-then-copy crash is real (#11088 made the fast path default, and the old lifecycle code uniquely mixed target-internal paths from But replacing
A smaller fix is at the caller: keep If Other notes
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). |
|
I'm going to do some deeper tests and come back with a conclusive solution |
|
Thanks @zkochan for the detailed review — pushed Bug 1 (resolution)
On your alternative (throw Bug 2 (lifecycle re-import)
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. |
…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).
…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).
d294098 to
59b8393
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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).
|
Congrats on merging your first pull request! 🎉🎉🎉 |
Fixes two independent crashes hitting
pnpm install --frozen-lockfileon workspaces withinjectWorkspacePackages: true(ordependenciesMeta.*.injected), surfaced viaturbo prune --dockerpipelines.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 itsresolutionfrom the basepackages:entry (@scope/pkg@file:packages/pkg). When a tool prunes the lockfile and drops that base entry, readers that derefpkgSnapshot.resolutioncrash with the cryptic: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 viaObject.assign. When the base entry is absent, reconstruct the directory resolution from thefile:depPath. This is reconstruction, not guessing: for a workspacefile: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 siblingCannot use 'in' operator … 'integrity' in undefinedon thepnpm deploypath (sameresolution === undefinedroot, different deref site).Per review feedback: the earlier per-reader
inheritOrSynthesizeResolutionhelper across 5 call sites is removed; normalization lives in exactly one place (convertToLockfileObject), and the readers are back tomain.Bug 2 — lifecycle re-import wipes
.bin/<tool>(pure pnpm; the substantive fix)runLifecycleHooksConcurrentlyre-imports an injected workspace package into its targets afterprepare/postinstall. The 2022scanDir-into-filesMapworkaround (#4299) fed target-internal paths toimportPackage; once #11088 madeimportIndexedDir'smakeEmptyDirfast path the default, that path wipes the target'snode_modulesbefore copying, so the re-import dies withERR_PNPM_ENOENTonnode_modules/.bin/<tool>.Fix: drop the
scanDirworkaround and passkeepModulesDir: truesoimportIndexedDirskips the destructive fast path and preserves the target's existingnode_modules(bin symlinks + transitive deps) via its staging/move path. Stays onstoreController.importPackage, so source files keep their hardlinks (no copy-loop regression). Net reduction vsmain: thescanDirhelper and thenode:fs/FilesMapimports are removed.Tests
deps-restorerregression fixturepeer-variant-missing-resolutionomits the basepackages:entry, so it encodes the actual pruned shape and reproduces the crash onmain: reverting theconvertToLockfileObjectchange yieldsresolution: undefinedfor the peer-variant (→ thelockfileToDepGraphcrash); with this PR it is reconstructed as{ type: 'directory', directory: … }.lockfile.fsunit test pins the heuristic boundary: a directory resolution is synthesized for a prunedfile:peer-variant but never for afile:tarball.deps-installerregression test covers the Bug 2 re-import (injected dep with apreparescript + a bin-having dependency).Validation
End-to-end on a real
injectWorkspacePackagesmonorepo (turbo prune --docker→pnpm 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
injectWorkspacePackages: trueis enabled with pruned lockfiles containing peer-dependency variants.node_modules/.bintool references being lost during lifecycle hook re-import, preventingENOENTerrors.