Skip to content

Detect transitive external patches via the workspace lockfile#181

Open
0x80 wants to merge 4 commits intomainfrom
thijs/0504-detect-patches-based-on-full-tree
Open

Detect transitive external patches via the workspace lockfile#181
0x80 wants to merge 4 commits intomainfrom
thijs/0504-detect-patches-based-on-full-tree

Conversation

@0x80
Copy link
Copy Markdown
Owner

@0x80 0x80 commented May 4, 2026

Problem

pnpm.patchedDependencies and bun's patchedDependencies are dropped during isolation when the patched package is reachable only through an external transitive dep. The follow-up case from issue #167 shows the shape: target depends on @react-pdf/renderer, which depends on the patched @react-pdf/render. Since @react-pdf/render is not listed on any internal manifest, collectReachablePackageNames (which only walks workspace manifests) doesn't see it, filterPatchedDependencies excludes it, the patch file is not copied, and the isolated install runs with an unpatched copy.

Solution

Walk the workspace lockfile from the target plus its internal importers and union the resulting set of installed package names into the existing reachableDependencyNames argument that filterPatchedDependencies consumes. The three current tiers (direct prod, direct dev, internal-reachable) are unchanged; the lockfile-derived set is added on top so the filter already handles it via its existing reachable check.

For pnpm, the new helper reads pnpm-lock.yaml (v8 or v9, Rush-aware), seeds a BFS frontier from each importer's dependencies/optionalDependencies (plus the target's devDependencies when includeDevDependencies is true), and walks lockfile.packages, mirroring @pnpm/dependency-path's refToRelative and indexOfPeersSuffix inline so the package set stays a strict subset of what pnpm will install. For bun, the BFS that already exists in generateBunLockfile is extracted into a shared module so both lockfile generation and patch detection use the same walker.

Walk failures (missing lockfile, parse error, unknown pnpm version) return an empty set so the existing manifest-based tiers still apply as a fallback.

NPM is unaffected: copyPatches is still gated to pnpm/bun via isolate.ts and skipped entirely under forceNpm.

Scope: packages (isolate-package)
Visibility: user-facing

Manifest-based reachability cannot see external-to-external transitives,
so a patch for a deep dep like @react-pdf/render (reachable only through
@react-pdf/renderer) was dropped during isolation. Walk the workspace
pnpm-lock.yaml or bun.lock from the target plus its internal importers
and union the resulting set of installed package names into the existing
reachableDependencyNames passed to filterPatchedDependencies. The bun
graph walker is shared with generateBunLockfile via a new
bun-lockfile module.
Copilot AI review requested due to automatic review settings May 4, 2026 17:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds lockfile-based dependency reachability to ensure pnpm.patchedDependencies and Bun patchedDependencies are preserved during isolation even when the patched package is only reachable via external transitive dependencies (regression in issue #167 follow-up).

Changes:

  • Extend copyPatches reachability by unioning manifest-derived reachable names with names collected by walking the workspace lockfile (pnpm + bun).
  • Add pnpm lockfile walker (pnpm-lock.yaml v8/v9, Rush-aware) and bun lockfile walker (bun.lock) to collect the set of installed package names.
  • Refactor Bun lockfile graph-walker logic into a shared helper module reused by both lockfile generation and patch detection; add regression tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/lib/patches/copy-patches.ts Adds lockfile-derived reachable package names to preserve patches for deep external transitives.
src/lib/patches/copy-patches.test.ts Updates mocks/inputs and adds regression test for deep pnpm transitive patch reachability.
src/lib/patches/collect-installed-names-pnpm.ts New pnpm lockfile walker to collect installed package names starting from target + internal importers.
src/lib/patches/collect-installed-names-pnpm.test.ts New unit tests covering pnpm v9 walking, devDep inclusion, peer suffix trimming, and error handling.
src/lib/patches/collect-installed-names-bun.ts New bun.lock walker that reuses shared bun lockfile traversal helpers.
src/lib/patches/collect-installed-names-bun.test.ts New unit tests for bun.lock walking and error handling.
src/lib/lockfile/helpers/generate-bun-lockfile.ts Refactors previously inlined bun.lock traversal helpers into a shared module.
src/lib/lockfile/helpers/bun-lockfile.ts New shared Bun lockfile types + dependency graph traversal helpers.
src/isolate.ts Passes targetPackageDir and internalDepPackageNames into copyPatches.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/patches/collect-installed-names-pnpm.ts
Comment thread src/lib/patches/collect-installed-names-pnpm.ts
@0x80
Copy link
Copy Markdown
Owner Author

0x80 commented May 4, 2026

Code review

Found 2 issues:

  1. The inline refToRelative produces depPaths in v9 format, but pnpm_lockfile_file_v8's readWantedLockfile rewrites lockfile.packages keys to v5 format with a leading slash and /-separator (e.g. /foo/1.0.0). For pnpm 8 users the BFS lookup packages[depPath] always misses for transitives, so the walker silently degrades to direct importer dep names only — defeating the PR's stated goal for v8 users. The v8 unit test passes only because enqueueResolvedDeps adds the alias name on line 179 regardless of the lookup; the fixture key /foo@1.0.0 also isn't the real v8 shape.

names.add(extractPackageName(depPath));
const pkg = packages[depPath];
if (!pkg) continue;
enqueueResolvedDeps(pkg.dependencies, names, queue, seen);
enqueueResolvedDeps(pkg.optionalDependencies, names, queue, seen);
}
return names;
} catch (err) {
log.debug(
`Failed to walk pnpm lockfile for installed names: ${err instanceof Error ? err.message : String(err)}`,
);
return new Set();
}

/**
* Mirrors `@pnpm/dependency-path`'s `refToRelative`, which we don't expose as
* a direct dep. Returns the depPath used as a key in `lockfile.packages`, or
* null if the ref points to a workspace link / non-resolved entry.
*/
function refToRelative(reference: string, pkgName: string): string | null {
if (!reference) return null;
if (reference.startsWith("link:")) return null;
if (reference.startsWith("@")) return reference;
const atIndex = reference.indexOf("@");
if (atIndex === -1) return `${pkgName}@${reference}`;
const colonIndex = reference.indexOf(":");
const bracketIndex = reference.indexOf("(");
if (
(colonIndex === -1 || atIndex < colonIndex) &&
(bracketIndex === -1 || atIndex < bracketIndex)
) {
return reference;
}
return `${pkgName}@${reference}`;
}

  1. The new pnpm walker omits peerDependencies from both importer enqueueing and transitive package enqueueing, contradicting the documented invariant in the sister collectReachablePackageNames ("dependencies, optionalDependencies, and peerDependencies are all walked — any of them can lead to a package being installed in the isolate (pnpm installs peers by default via autoInstallPeers)"). The bun walker added in this same PR honors that invariant; the pnpm walker doesn't. With autoInstallPeers=false or peer-only externals, a patch on a peer transitive can be silently dropped — the same regression class Bug: Upstream/transitive patched dependencies are omitted during isolation #167/Preserve patches for transitive dependencies via internal packages #177 set out to fix.

const pkg = packages[depPath];
if (!pkg) continue;
enqueueResolvedDeps(pkg.dependencies, names, queue, seen);
enqueueResolvedDeps(pkg.optionalDependencies, names, queue, seen);
}

function enqueueImporterDeps({
importer,
names,
queue,
includeDevDependencies,
}: {
importer: PnpmImporter;
names: Set<string>;
queue: string[];
includeDevDependencies: boolean;
}): void {
enqueueResolvedDeps(importer.dependencies, names, queue);
enqueueResolvedDeps(importer.optionalDependencies, names, queue);
if (includeDevDependencies) {
enqueueResolvedDeps(importer.devDependencies, names, queue);
}
}

* `dependencies`, `optionalDependencies`, and `peerDependencies` are all
* walked — any of them can lead to a package being installed in the isolate
* (pnpm installs peers by default via `autoInstallPeers`). devDependencies of
* internal packages are never followed, and devDependencies of the *target*
* are followed only when `includeDevDependencies` is true.
*
* Note: only recurses through internal packages — manifests of external deps
* aren't available here. Deep external→external transitives therefore won't
* appear in the set.
*/

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

The inline refToRelative produced v9-style keys regardless of pnpm major
version, so for pnpm 8 (lockfileVersion 6.x normalized to v5 keys like
/foo/1.0.0) the BFS lookup always missed and transitive walking silently
degraded to importer-direct names only. Fork refToRelative into v8 and
v9 variants and teach extractPackageName to parse the leading-slash v5
shape.

Walk peerDependencies on package snapshots (and importers as a fallback)
to keep the walker aligned with collectReachablePackageNames and the bun
walker added in this PR. Peer values are name to semver-range, so they
contribute names but don't seed the BFS.

The previous v8 test fixture used a bogus key shape (/foo@1.0.0) that
masked the bug. Replaced with realistic v5-style keys (/@react-pdf/...)
proving transitive walking works for v8.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 73e2408. Configure here.

Comment thread src/lib/patches/collect-installed-names-pnpm.ts Outdated
Comment thread src/lib/patches/collect-installed-names-pnpm.ts
getLockfileImporterId returns OS-native separators; on Windows that's
backslashes. The importerIds array was POSIX-normalized so the lockfile
lookup works, but the isTarget equality check still compared against the
raw value. The result is that on Windows the target's devDependencies
would never apply even with includeDevDependencies=true. Extract a
toLockfileImporterKey helper and use it for both the importerIds list
and the targetImporterId used in the comparison. Normalize backslashes
unconditionally rather than relying on path.sep, since lockfile keys
are always POSIX regardless of host OS.

Add a regression test that simulates the Windows shape.
Copilot AI review requested due to automatic review settings May 4, 2026 18:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants