fix(cli): resolve pnpm symlinks so code bundles extract and run [RED-713] [show] - #1429
Merged
Conversation
sorccu
force-pushed
the
simo/red-713-pnpm-symlink-code-bundle
branch
from
August 7, 2026 05:59
aa29a90 to
14c044a
Compare
Under pnpm every package in node_modules is a symlink into a content-addressed store, and the include globs report such a link as if it were a regular file while separately walking through it and reporting the files beneath. Archiving both yields a path that is at once a symlink and a directory, which tar refuses to extract. Add a resolver that turns matched paths into archive entries upholding one invariant: an entry is either a symlink, with nothing beneath it, or a regular file at a symlink-free archive path. Symlinks are kept as symlinks and their targets brought along, rather than dereferenced — under pnpm a package's dependencies are siblings of it inside the store, not children, so a flattened copy of a package cannot resolve anything it depends on. Not yet wired into the bundler. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…irectory [RED-713] Bundling a project whose include patterns reach a symlinked directory produced an archive GNU tar refuses to extract: tar: node_modules/pkg/package.json: Cannot open: Not a directory The include globs report a symlinked directory as if it were a regular file, while separately walking through it and reporting the files beneath. Archiver then lstats each path, turns the link into a tar symlink entry, and writes the files under that same path. Under pnpm every package in node_modules is such a link, so any project bundling its dependencies hit this. Route the matched paths through the symlink resolver, and emit symlink entries deliberately: archiver is now handed a followed stat for every regular file, so it can no longer infer a symlink entry from a path that happens to be one. Enforce the invariant where the archive is assembled rather than only where the resolver produces entries, since the archive also contains files registered by the check parser, which does not resolve symlinks — a spec importing through a symlinked directory is registered at its path through the link. Any symlink that ends up with files beneath it is dropped in favour of them: the files are the content, and the link is what makes the archive unextractable. Key the file registry by archive path rather than source path: a package reached through two links is archived at both, and keying by source silently dropped one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test discovery ran in the lexical namespace: testDir was resolved against the config directory but never through symlinks. A testDir that is a symlink discovered no files at all — globbing with a symlinked working directory finds nothing — and a testDir running through a symlink discovered files at their through-link spellings, archiving them at paths that collide with the symlink entries the bundle carries. Under pnpm, where a workspace dependency is a symlink into the workspace, a config whose testDir reaches into such a package hit both. Canonicalize every path the config names — testDir, snapshotDir, tsconfig, globalSetup, globalTeardown, and the config file itself — at construction, so directories, discovered files and snapshot patterns all live in one namespace. Reconcile discovered paths into the bundle root's own spelling before the parser sees them, since the parser bounds its directory walks by exact string comparison against that root. Canonicalization stops at the bundle's edge: when a reference's canonical location is outside the bundle root but its spelling is inside, files are bundled at the spelling, which extracts as ordinary directories. A file outside the root under every spelling fails with an actionable error where it was previously dropped silently or archived at names that never extracted. The config still spells its references as written, so every symlink a spelling traverses is carried into the archive as a symlink entry — link only, content comes from discovery at real paths — and marked, so the bundler can warn rather than stay silent if archive-path conflicts ever force one out. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…roject links [RED-713] A package link whose target is a workspace member was expanded wholesale: matching it with an include pattern bundled the member's entire directory — sources, tests, its own node_modules links and everything those reach — where the CLI bundles every non-linked workspace dependency selectively (manifest, entry points, transitively imported files). Bundle the two the same way: the link travels, the member's manifest travels (it carries load-bearing metadata and keeps the link resolvable in the archive), files matched through the link travel at their real paths, and everything else is the import parser's business. The branch applies only where the parser genuinely compensates: the target must be the member directory itself, the link's node_modules name must equal the member's package name (the parser resolves by specifier, so an aliased dependency would otherwise ship as an empty package), and the link must have been matched by an include pattern — links reached through a store's dependency closure have no parser coverage and keep expansion. When selective treatment narrows a directly-named link, a warning says so and names the member's path. An include pattern naming a node_modules symlink that points outside the bundle root — a cache volume, a relocated virtual store, a globally linked package — previously had the target's contents silently flattened into the archive, producing bundles that only half-worked: a pnpm package's dependencies are its store siblings, which never came along. Fail with an actionable error instead, naming the bundle root and the ways out. The error is scoped to what it can honestly claim: only links the include patterns named directly (links the resolver reaches on its own keep the warn-and-copy fallback), and only node_modules shapes — an out-of-project file link or asset-directory link copies cleanly and continues to. Excluding a link via ignoreDirectoriesMatch counts as excluding its subtree even in the documented pattern spelling: a trailing globstar does not match the bare directory entry, so exclusion is probed with a synthetic child. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ED-713] A pnpm workspace whose Playwright testDir runs through a workspace-dependency link into a member package, bundled with include patterns that match both the member link and a pnpm-store dependency: asserts the member travels selectively (manifest, the testDir-discovered spec, its relative import, its by-name member dependency, and an include-matched asset — all at real paths, and nothing more), the store package expands with its sibling closure alongside, the matched links survive as symlink entries, no entry lands at a through-link spelling, and no symlink in the archive has children. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bundler.registerFiles dedups files by archive path, but the fallback for parser-registered files used path.relative verbatim, which produces backslash separators on Windows. Symlink-resolver entries carry posix archive paths, so the same file could key twice — both entries survived and archiver normalized them to the same tar name, producing a duplicate tar entry and defeating the prefer-physical rule. Return the posix form so both producers key identically, and drop the now-redundant normalization inside dropSymlinksWithChildren. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF
Windows picks a symlink's type by looking at its target when the link is created; the pnpm-store fixture created its store-sibling link before writing the target files, yielding a file-typed link that cannot be opened as a directory — so the resolver (correctly) dropped it and the assertion missed one symlink entry, on Windows only. Pass an explicit 'dir' type to every fixture link so ordering is not load-bearing, and write files before links anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF
sorccu
force-pushed
the
simo/red-713-pnpm-symlink-code-bundle
branch
from
August 7, 2026 06:40
14c044a to
53a2e8d
Compare
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linear: RED-713
Problem
Bundling a project whose
includepatterns or Playwright config paths reach through symlinks produced broken code bundles. Under pnpm every package innode_modulesis a symlink, so this affected any project bundling its dependencies:tar: ...: Cannot open: Not a directory).testDir(orglobalSetup/tsconfig) spelled through a symlink discovered files at their through-link paths and archived them there — colliding with the symlink entries — while atestDirthat is a symlink silently discovered zero files.node_modules/@scope/pkg → ../../pkg) matched byincludeswept the member package's entire directory into the bundle.Fix
Symlink-aware bundling (
symlink-resolver.ts): symlinks are preserved as symlink entries and their targets bundled at real paths, upholding one invariant — an archive entry is either a symlink (with nothing beneath it) or a regular file at a symlink-free path. pnpm store packages bring their sibling dependency links along, so a bundled package resolves what it depends on. Archive entries are keyed by their posix path, so on Windows a parser-registered file and a resolver-carried one dedupe to a single tar entry instead of two.Canonical config paths (
playwright-config.ts,playwright-config-expander.ts): every path the Playwright config names is resolved through symlinks at construction, so discovery, snapshot templates and archive layout share one namespace. The links a config's spellings traverse are carried into the archive so those spellings still resolve on the runner. Canonicalization stops at the bundle's edge: a reference whose target lies outside the bundle root is bundled at its spelled path, and a path outside the root under every spelling is an actionable error instead of a silently broken archive.Selective workspace members: a package link whose target is a workspace member travels like every other workspace dependency — the link, the member's manifest, files matched through the link, and whatever the import parser reaches, all at real paths — instead of a wholesale directory copy. Scoped to exactly where the parser can compensate (member directory targets whose link name matches the package name, matched by an include pattern); aliases, sub-member targets and indirectly-reached links keep expansion. A warning explains the narrowing when it applies.
Behaviour changes
includepattern naming anode_modulessymlink that points outside the project now failsdeploy/testwith an actionable error instead of silently flattening half-working content (a pnpm package's dependencies are its store siblings and never came along). Excluding the link viaignoreDirectoriesMatch(including the**/dir/**spelling) skips it instead; out-of-project file and asset-directory links still copy cleanly.includethrough a workspace-member link now bundles selectively — bundles get smaller; a member's registry dependencies rely on the runner's install step, as they already do for non-linked members.testDirgoes from silently bundling zero test files (and zero snapshots) to working.Testing
testDirresolves on the extracted tree, andpnpm installin the extracted tree recreates the workspace links frompnpm-workspace.yamland the bundled member directories.🤖 Generated with Claude Code
https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF