Skip to content

fix(cli): resolve pnpm symlinks so code bundles extract and run [RED-713] [show] - #1429

Merged
sorccu merged 7 commits into
mainfrom
simo/red-713-pnpm-symlink-code-bundle
Aug 7, 2026
Merged

fix(cli): resolve pnpm symlinks so code bundles extract and run [RED-713] [show]#1429
sorccu merged 7 commits into
mainfrom
simo/red-713-pnpm-symlink-code-bundle

Conversation

@sorccu

@sorccu sorccu commented Aug 7, 2026

Copy link
Copy Markdown
Member

Linear: RED-713

Problem

Bundling a project whose include patterns or Playwright config paths reach through symlinks produced broken code bundles. Under pnpm every package in node_modules is a symlink, so this affected any project bundling its dependencies:

  • The archive could contain a path that was simultaneously a symlink and a directory, which GNU tar refuses to extract (tar: ...: Cannot open: Not a directory).
  • A testDir (or globalSetup/tsconfig) spelled through a symlink discovered files at their through-link paths and archived them there — colliding with the symlink entries — while a testDir that is a symlink silently discovered zero files.
  • A workspace-dependency link (node_modules/@scope/pkg → ../../pkg) matched by include swept 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

  • An include pattern naming a node_modules symlink that points outside the project now fails deploy/test with 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 via ignoreDirectoriesMatch (including the **/dir/** spelling) skips it instead; out-of-project file and asset-directory links still copy cleanly.
  • include through 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.
  • A symlinked testDir goes from silently bundling zero test files (and zero snapshots) to working.

Testing

  • ~60 unit tests across the resolver, config canonicalization and expander reconciliation, plus end-to-end fixtures: pnpm store shapes, a full pnpm workspace with member links and a testDir running through one, out-of-root links, cycles, chained links, scoped packages, Windows-safe fixture construction.
  • Verified outside the suite against a real pnpm workspace: the bundle extracts under GNU tar 1.30 (exit 0), the through-link testDir resolves on the extracted tree, and pnpm install in the extracted tree recreates the workspace links from pnpm-workspace.yaml and the bundled member directories.

🤖 Generated with Claude Code

https://claude.ai/code/session_014nP7rLGVD6QjmG73f9uBcF

@sorccu
sorccu force-pushed the simo/red-713-pnpm-symlink-code-bundle branch from aa29a90 to 14c044a Compare August 7, 2026 05:59
sorccu and others added 7 commits August 7, 2026 15:40
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
sorccu force-pushed the simo/red-713-pnpm-symlink-code-bundle branch from 14c044a to 53a2e8d Compare August 7, 2026 06:40
@sorccu sorccu changed the title fix(cli): resolve pnpm symlinks so code bundles extract and run [RED-713] fix(cli): resolve pnpm symlinks so code bundles extract and run [RED-713] [show] Aug 7, 2026

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

Auto-approved: ship/show PR from a same-repo branch.

@sorccu
sorccu merged commit c25a904 into main Aug 7, 2026
17 checks passed
@sorccu
sorccu deleted the simo/red-713-pnpm-symlink-code-bundle branch August 7, 2026 14:17
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.

1 participant