You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
npm run worktree:link (scripts/link-worktree-deps.mjs) symlinks a worktree's node_modules at the primary checkout's. That makes the worktree runnable without a full install, and it also puts a single writable path from every worktree into the primary's dependency tree. An install run inside a linked worktree therefore acts on the PRIMARY, and the damage lands on sessions that did nothing wrong, in a place and at a time remote from the cause.
The trap is that scripts/link-worktree-deps.mjsdocuments this exact action as the remedy (L37, under "What this does NOT give you"): "run a real npm install in the worktree". AGENTS.md L59 repeats it ("or a full npm install there, which is correct but slow"). Following the tooling's own advice damages shared state.
Measured behaviour (npm 11.19.0, Node v26.7.0, bun 1.3.14, all reproduced in a throwaway tree)
The original filing asserted one mechanism (npm's workspace linker rewriting <primary>/node_modules/@webjsdev/* to absolute worktree paths). That specific rewrite does NOT reproduce on today's npm. Three other behaviours do, and one is worse than the filing described. Each was measured with a synthetic primary plus a worktree whose root node_modules is a symlink at it.
Command, run in a linked worktree
What actually happens
npm install
npm REPLACES the node_modules symlink with a real directory and builds a full tree there. The primary is untouched. The worktree silently detaches from the primary, so it no longer runs the primary's framework source.
npm ci
npm DELETES the symlink's TARGET contents, that is the whole of <primary>/node_modules, then builds a real tree in the worktree. The primary and every other linked worktree break at once.
bun add <pkg> / bun install
Bun KEEPS the symlink and writes THROUGH it. New packages and .bin entries land in <primary>/node_modules.
A preinstall script cannot prevent any of this, which is the filing's main design error. Measured, with a preinstall that prints the state it sees:
Under npm install, preinstall runs with node_modules ALREADY a real directory. The symlink is gone before the script starts.
Under npm ci, preinstall runs with the primary's node_modules ALREADY emptied. The destruction precedes the script.
Under bun install / bun add, preinstall runs BEFORE the write and DOES see the symlink, but a non-zero exit from it does not stop Bun (measured: bun install exited 0 with a preinstall that exited 2).
npm's preinstall cwd is always the workspace ROOT that owns the script, even when the install was invoked from a workspace subdirectory (INIT_CWD carries the invocation directory). npm creates node_modules before running preinstall, so the ENOENT branch is unreachable on a fresh clone.
A non-zero preinstall DOES block both npm install and npm ci (exit code propagated verbatim), so a buggy guard would red every CI job.
So the key the filing proposed, lstatSync('node_modules').isSymbolicLink() evaluated inside preinstall, is always false under npm. Prevention has to happen before the package manager starts.
Current state of <primary>/node_modules/@webjsdev/ (audited at HEAD)
The filing said the .name-HASH entries are staging debris. That is only partly right, and the repair rule has to be narrower than "clean them up".
cli LIVE relative ../../packages/cli
core LIVE relative ../../packages/core (repaired by hand before filing)
docs-redirect LIVE relative ../../docs
example-blog LIVE relative ../../examples/blog
intellisense LIVE relative ../../packages/editors/intellisense
mcp LIVE relative ../../packages/mcp
server LIVE ABSOLUTE /home/vivek/.../webjs/packages/server <- in-primary, correct by luck
ui, ui-registry, ui-website, website LIVE relative
.docs-kRj0IV70 LIVE relative ../../docs
.example-blog-R6PPj5yJ LIVE relative ../../examples/blog
.intellisense-u59WYPN9 LIVE relative ../../packages/editors/intellisense
.mcp-JE1jfXjF LIVE relative ../../packages/mcp
.ui-tVBcnl39 LIVE relative ../../packages/ui
.ui-registry-9lLf2XiC LIVE relative ../../packages/ui/packages/registry
.ui-website-pzSCwt5a LIVE relative ../../website
.website-QfDt3xAN LIVE relative ../../website
.cli-rDk6NcDJ DANGLING ABSOLUTE .../webjs-formaction-submitter/packages/cli
.core-mTPzu12Q DANGLING ABSOLUTE .../webjs-formaction-submitter/packages/core
.server-K5TIoquc DANGLING ABSOLUTE .../webjs-formaction-submitter/packages/server
Eight staging entries are LIVE and relative and point at real in-repo directories. They are harmless (nothing imports @webjsdev/.ui-tVBcnl39) and one of them, .docs-kRj0IV70, predates a package rename, so its name cannot even be mapped back to a workspace. Only three dangle, all into the removed webjs-formaction-submitter worktree, dated 1 Aug. server is absolute where every sibling is relative, pointing at the primary itself.
So the corruption is real and has happened at least twice. The exact writer that produced worktree-anchored ABSOLUTE targets in the primary is not reproducible on npm 11.19.0 or bun 1.3.14, and the fix does not depend on identifying it: all three reproducible behaviours above, plus the historical debris, are covered by the same set of changes.
The rule that makes this severe is AGENTS.md "One task per git worktree, ALWAYS": multiple agents work this repo concurrently, so shared-state damage hits a session that cannot see the cause. Same class as #166 (recurring core.bare=true corruption of the main repo from worktree operations) and #1287 (a fresh-worktree remedy that itself produced a broken worktree).
Design / approach
Five layers. Prevention moves UP a level from the filing's design, because the measurements above prove the package manager gives no usable hook, and repair stays because the debris proves corruption already happened twice unnoticed.
A. Prevent, at the only layer that runs before the package manager: a Claude Code PreToolUse hook..claude/hooks/block-install-in-linked-worktree.sh (matcher Bash) refuses an install command whose target directory has a symlinked node_modules, exit 2 with the cause and both safe alternatives on stderr. This is the repo's established pattern for exactly this shape of rule (.claude/hooks/require-worktree-for-edits.sh blocks tracked-file edits in the primary the same way), and it is the ONLY layer that sees the state before npm mutates it. It covers Claude Code sessions, which is where the incident happened and where the concurrency that makes this severe comes from. Other agents and humans are covered by layer B plus the doc fix.
B. Report, for every tool: a root preinstall.scripts/warn-worktree-install.mjs, wired as "preinstall", ALWAYS exits 0. Under Bun it fires before the write and warns in time. Under npm it fires after and turns a silent detach, or a silently emptied primary, into a named diagnosis with the exact repair command. Blocking here is rejected on measurement: it cannot stop npm ci (the primary is already gone), it cannot stop Bun (exit code ignored), and stopping npm install would leave the worktree with no symlink and a half-built tree, which is worse than what it prevented. A guard that never blocks is not called a guard, so the filing's scripts/guard-worktree-install.mjs name is replaced by scripts/warn-worktree-install.mjs.
C. Repair on teardown..claude/hooks/cleanup-merged-worktree.sh removes merged worktrees while the primary may hold links INTO the tree it is deleting. It repoints them BEFORE the removal, while the target is still identifiable.
D. Repair on demand.scripts/link-worktree-deps.mjs gains a repair pass over <primary>/node_modules/@webjsdev/ and a read-only --check mode.
E. Detect. A new webjs doctor check, framework-links / FRAMEWORK_LINKS, distinguishes a dangling or foreign framework link from the #954 missing-node_modules case, and checkFrameworkResolves stops recommending a bare npm install in a linked worktree, which is the command that causes this.
Settled decisions
The repair pass in link-worktree-deps.mjs runs UNCONDITIONALLY, not behind a flag. The script's contract is already "safe to re-run, a no-op when there is nothing to do" (docblock L41 to L52), and the repair is idempotent by the same standard: it rewrites only a link that is already wrong. A flag would never be passed, because the one command anyone runs is npm run worktree:link. Read-only inspection is available as --check, following scripts/git-worktree-safe.mjs L34 and npm run check:git.
Repair is separated from report. Only the two commands that already mutate on purpose repair: npm run worktree:link (layer D) and the teardown hook (layer C, which is repairing damage it is itself about to cause). The preinstall reporter and webjs doctor never repair. webjs doctor is documented as a read-only project-health checklist, and a repair triggered by npm install would mutate shared state from a command nobody asked to mutate anything with.
The teardown hook repairs only links into the worktree it is about to remove, not the whole tree. It stays conservative and fast; the general sweep belongs to the tool you run deliberately.
Live .name-HASH staging entries are left strictly alone. They resolve, nothing imports them (@webjsdev/.ui-tVBcnl39 is not a specifier any code writes), one of them predates a package rename so its name maps to no current workspace, and deleting a live entry risks racing an install that is mid-reify. A staging entry is removed ONLY when it is unusable, defined as dangling OR pointing into the worktree being removed.
The guard is bypassable via WEBJS_NO_WORKTREE_INSTALL_GATE=1, matching the WEBJS_NO_WORKTREE_GATE / WEBJS_NO_WORKTREE_CLEANUP / WEBJS_NO_WORKTREE_SEED / WEBJS_NO_DOC_GATE family. A bypass is warranted because a real install in a worktree is a legitimate workflow (the self-testing-worktree case AGENTS.md L67 describes), and the hook's own message names the safer form of it (remove the symlink first). The repair pass gets WEBJS_NO_WORKTREE_REPAIR=1 for the same reason WEBJS_NO_WORKTREE_SEED=1 exists: one repo-health test runs the real script against the real checkout and must not mutate it.
One new doctor code, FRAMEWORK_LINKS, default severity warn. Dangling and foreign are the same defect class with the same remedy, so they share a code and differ only in message; the missing-node_modules case keeps FRAMEWORK_RESOLVE, which already has a code, a documented meaning, and a CLI preflight consuming it. warn matches the tier of the sibling environment-shaped checks. No in-repo app's webjs.doctor.gate changes: a corrupted local link cannot occur on a CI runner (fresh clone, real directories), so gating it error would only red the conventions job on a developer machine.
Alternatives considered and rejected
A preinstall that blocks (the filing's layer 1). Rejected on measurement, see above. Kept as a non-blocking reporter.
Per-entry symlinks instead of one whole-node_modules link, so a destructive rm -rf unlinks copies rather than following one link into shared state. Structurally the best fix, and rejected: per-entry symlinks are already known to break cjs-lexer in this repo, and the root tree carries over a thousand entries whose nested trees (packages/server's ws@8, see scripts/link-worktree-deps.mjs L9 to L16) would each need the same treatment.
No sharing at all, a real install per worktree. Correct and rejected on cost, which is the entire reason scripts/link-worktree-deps.mjs exists.
Pointing @webjsdev/* at the worktree. Explicitly refused. AGENTS.md L67 documents that a linked worktree runs the PRIMARY's framework source through bare specifiers, and changing it silently changes what every linked worktree tests.
An .npmrc or engines guard.only-allow-style preinstall refusal is the standard prior art (vite/package.json, astro/package.json, solid/package.json, and qwik/package.json all ship "preinstall": "npx only-allow pnpm"), but its precondition does not hold here: only-allow reads npm_config_user_agent, which npm sets before touching anything, so refusing costs nothing. Here the destructive act precedes the script.
Prior art read: scripts/git-worktree-safe.mjs (the repo's existing shared-git-state repair, its --check convention, and its "ensure mode must never fail npm install" rule at L145 to L155), .claude/hooks/require-worktree-for-edits.sh (PreToolUse block contract, exit 2 plus stderr, escape hatch), next.js/package.json ("postinstall": "node scripts/git-configure.mjs", install-time repair of shared git state), issues #1287 and #166.
No other issue is being planned in this batch, so this surface is uncontested as of HEAD e0abcf96. Every line anchor below is against that commit.
Implementation plan
Ordered. Steps 1 and 2 are one commit (prevention), 3 and 4 one commit (repair), 5 one commit (detection), 6 one commit (docs). packages/ is plain .js with JSDoc and scripts/ is .mjs; no .ts anywhere in this change.
Step 1. New .claude/hooks/block-install-in-linked-worktree.sh
New file, chmod +x. Model it on .claude/hooks/require-worktree-for-edits.sh (same contract: exit 0 allows, exit 2 blocks with the message on stderr, jq absent means exit 0, escape hatch checked first).
It cannot key on its own cwd alone. The harness resets cwd to the primary checkout between Bash calls, which require-worktree-for-edits.sh L7 to L9 already records, so a real install in a worktree arrives as cd /path/to/worktree && npm ci. Candidate directories are therefore: the hook's own cwd, plus every cd <token> in the command, plus every -C <token> and --prefix[= ]<token>. Relative candidates resolve against the hook cwd. If no candidate has a node_modules at all, exit 0 (fail open, the same posture every other predicate in that file takes).
Install verbs to match, as a word-boundary regex so npm run test, npm test, npm exec, npm ls, and npx are NOT matched:
Block when [ -L "$cand/node_modules" ] for any candidate, or for the git toplevel of any candidate. Message, verbatim:
BLOCKED: this command installs into <dir>, whose node_modules is a SYMLINK at <target>.
An install through that link damages the checkout that OWNS the tree, not this one:
npm ci DELETES <target> outright, before any lifecycle script can run
bun install writes packages and .bin entries straight into <target>
npm install silently REPLACES the link with a real tree, detaching this worktree
Safe alternatives:
npm run worktree:link links a fresh worktree; it never installs
a real install with NO symlink in the way: `rm node_modules` first (it is only a
link, nothing else is lost), or install in the PRIMARY checkout
Escape hatch for a deliberate exception: WEBJS_NO_WORKTREE_INSTALL_GATE=1.
Register it in .claude/settings.json, in the existing PreToolUse entry whose "matcher": "Bash" already lists require-tests-with-src.sh, require-docs-with-src.sh, require-scaffold-with-src.sh, and require-bun-parity-with-runtime-src.sh. Append a fifth {"type": "command", "command": ".claude/hooks/block-install-in-linked-worktree.sh"}.
Step 2. New scripts/warn-worktree-install.mjs plus the root preinstall
The || true is belt and braces on top of the script's own always-exit-0 contract, because a non-zero preinstall blocks npm ci and would red every CI job (measured: the exit code propagates verbatim). The same reasoning is already written into git-worktree-safe.mjs L145 to L155.
The script, all output on stderr, always process.exit(0), whole body in a try/catch that swallows:
Exit silently unless statSync('.git').isFile(), that is unless this is a LINKED worktree. A normal clone, which is what CI has, and the primary checkout both return immediately, so CI is untouched by construction.
Resolve the primary as resolve(dirname(execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir']))), the same derivation defaultPrimary() uses at scripts/link-worktree-deps.mjs L113 to L122.
If lstatSync('node_modules').isSymbolicLink() is true, the link is still standing, which is the Bun shape. Warn BEFORE the fact: this install writes through the link into <primary>/node_modules; run npm run worktree:link instead, or rm node_modules first if a real install is what you want.
Otherwise, if node_modules is a real directory and <primary>/node_modules is missing or empty, this is the npm ci aftermath. Report that the primary's tree was deleted through the link and that the repair is cd <primary> && npm install.
Otherwise, if node_modules is a real directory in a linked worktree, report that this worktree has detached from the primary and that rm -rf node_modules && npm run worktree:link restores the shared tree.
Otherwise silent.
The header comment must state the measurements that shape it: npm removes the symlink before preinstall runs, npm ci empties the primary before it runs, Bun runs it in time but ignores a non-zero exit, so this file reports and never blocks. Without that, the next reader "fixes" it into a blocking guard.
Step 3. Repair pass in scripts/link-worktree-deps.mjs
3a. Docblock. L35 to L39 read today:
* So this makes the suite RUNNABLE, not self-testing. If you are editing
* `packages/core/src` or `packages/server/src` and need a bare-specifier
* consumer to exercise YOUR copy, run a real `npm install` in the worktree, or
* point the individual `@webjsdev/<pkg>` entries at this worktree instead. CI
* always builds from the branch, so it is unaffected either way.
After:
* So this makes the suite RUNNABLE, not self-testing. If you are editing
* `packages/core/src` or `packages/server/src` and need a bare-specifier
* consumer to exercise YOUR copy, delete the `node_modules` SYMLINK first
* (`rm node_modules`, it is only a link) and then install, or point the
* individual `@webjsdev/<pkg>` entries at this worktree instead. CI always
* builds from the branch, so it is unaffected either way.
*
* NEVER install while the link is standing (#1442). Measured: `npm ci` deletes
* the PRIMARY's whole `node_modules` through it before any lifecycle script
* runs, `bun install` writes packages into the primary through it, and
* `npm install` silently replaces the link with a real tree. All three land on
* a checkout you are not working in, so the failure surfaces in someone else's
* session with nothing naming the cause.
L41 to L49, the first two safety rules, gain the narrowed exception:
* - Never delete or overwrite anything, with ONE exception: the repair pass
* over `<primary>/node_modules/@webjsdev/` replaces a link that is ALREADY
* wrong (dangling, or resolving outside the primary) and removes a DANGLING
* `.name-HASH` npm staging entry. It never touches a real directory, never
* touches a link that is already correct, and never removes a staging entry
* that still resolves. Outside that pass, a path that already exists is left
* alone, so a worktree with a real install is untouched and re-running is a
* no-op.
* - Never create a dangling link. A source that does not exist is skipped, and
* the repair pass declines to repoint when the corrected target is missing.
Usage block at L54 to L58 gains node scripts/link-worktree-deps.mjs --check and npm run check:worktree-links.
3b. New functions, placed after link() (L94 to L111) and before defaultPrimary() (L113):
workspacePackageDirs(primary) returns Map<packageName, relDir>. Read <primary>/package.jsonworkspaces (today packages/*, packages/editors/*, packages/wrappers/*, packages/ui/packages/*, examples/*, website, gallery), expand a single trailing * by readdirSync, treat a pattern without * as a literal directory, and read each candidate's package.jsonname. Do NOT derive the target from the entry name: the mapping is not packages/<name> (example-blog lives at examples/blog, ui-registry at packages/ui/packages/registry, docs-redirect at docs, intellisense at packages/editors/intellisense).
repairPrimaryFrameworkLinks(primary, { check }) iterates readdirSync(<primary>/node_modules/@webjsdev, { withFileTypes: true }), including dot entries, and classifies each entry:
not a symlink, exit early, leave it;
staging-shaped, matching /^\.(.+)-[A-Za-z0-9_-]{8}$/, AND dangling, remove it;
staging-shaped and live, leave it (decision 4);
otherwise resolve the target against the entry's directory. Correct target is join(primary, workspacePackageDirs(primary).get('@webjsdev/' + name)), written RELATIVE as relative(dirname(entry), correct), which is ../../<relDir>. Repoint when the current target dangles or resolves outside realpathSync(primary); normalise to relative when it resolves inside the primary but is absolute; leave a link that already reads exactly the relative form.
Never repoint to a path that does not exist, and never repoint an entry whose name is in no workspace (a stale entry for a renamed or removed package): report and leave.
Writes are ATOMIC: symlinkSync(target, entry + '.tmp-' + process.pid) then renameSync(tmp, entry), so a parallel resolve never sees the entry absent. This matters because npm test resolves @webjsdev/* from many processes at once.
Returns { repaired: string[], removed: string[], kept: string[] } and prints one line per action prefixed [link-worktree-deps].
3c. Control flow, L225 to L237 today:
consthere=process.cwd();constprimary=resolve(process.argv[2]||defaultPrimary());if(primary===here){console.log('[link-worktree-deps] this IS the primary checkout, nothing to link.');process.exit(0);}if(!existsSync(join(primary,'package.json'))){console.error(`[link-worktree-deps] not a checkout: ${primary}`);process.exit(1);}
After. The checkout check moves ABOVE the primary-is-here guard, and the repair runs between them, because the repair targets the PRIMARY and must run whether or not this checkout is the primary. --check is filtered out of the positional argument, or resolve('--check') would be read as the primary path:
consthere=process.cwd();constCHECK=process.argv.includes('--check');constargs=process.argv.slice(2).filter((a)=>a!=='--check');constprimary=resolve(args[0]||defaultPrimary());if(!existsSync(join(primary,'package.json'))){console.error(`[link-worktree-deps] not a checkout: ${primary}`);process.exit(1);}// FIRST, and above the primary-is-here guard: the repair targets the PRIMARY's// `@webjsdev/*` links, so `npm run worktree:link` heals them from a worktree AND// `--check` / a bare run heals them from the primary itself. `WEBJS_NO_WORKTREE_REPAIR=1`// is why the `defaultPrimary()` repo-health test can run this against the real// checkout without mutating it, exactly as `WEBJS_NO_WORKTREE_SEED=1` does for seeding.if(process.env.WEBJS_NO_WORKTREE_REPAIR==='1'){console.log('[link-worktree-deps] framework-link repair skipped (WEBJS_NO_WORKTREE_REPAIR=1).');}else{constr=repairPrimaryFrameworkLinks(primary,{check: CHECK});if(CHECK)process.exit(r.repaired.length+r.removed.length>0 ? 1 : 0);}if(primary===here){console.log('[link-worktree-deps] this IS the primary checkout, nothing to link.');process.exit(0);}
--check is read-only and terminal: it reports what it WOULD repair, exits 1 when there is anything, and never links or seeds. That mirrors npm run check:git (scripts/git-worktree-safe.mjs L96 to L143).
Step 4. Repoint on teardown in .claude/hooks/cleanup-merged-worktree.sh
The hook already computes primary at L55 and already tolerates untracked node_modules when judging cleanliness at L88 to L94. Add a repoint_primary_links() function beside is_clean(), and call it at L119, immediately BEFORE the removal, so the link target is still identifiable:
if! is_merged "$br";then
kept+=("$wt (branch $br not merged yet)"); wt="";continuefi# #1442: the primary may hold @webjsdev/* links INTO the tree we are about# to delete. Repoint them while the target still exists.
repoint_primary_links "$wt"if git worktree remove --force "$wt">/dev/null 2>&1;then
repoint_primary_links walks "$primary"/node_modules/@webjsdev/* and .[!.]*, skips anything that is not a symlink (which also absorbs an unmatched glob), normalises the target to an absolute real path with cd "$(dirname ...)" && pwd -P rather than readlink -f (portable, no GNU coreutils assumption), and acts only on targets under $(cd "$wt" && pwd -P)/:
a staging-shaped name, case "$base" in .*-????????), is removed with rm -f;
anything else is repointed with ln -sfn "../../${abs#$wtreal/}" "$e", which is correct because the entry sits exactly two levels below the primary root, and only when "$primary/${abs#$wtreal/}" exists;
when it does not exist, the link is KEPT and reported, never repointed to a missing path.
Append each action to the existing msg so it surfaces through hookSpecificOutput.additionalContext alongside the removed and kept lists. The hook keeps exiting 0 always, and WEBJS_NO_WORKTREE_CLEANUP=1 (L33) disables the repoint along with everything else, which is correct: it is the same teardown.
Step 5. Doctor: a new framework-links check, and a corrected framework-resolve remedy
5a.packages/cli/lib/doctor/probes/framework-resolves.js today has exactly two exports. frameworkResolves(appDir) (L18 to L28) is a bare require.resolve in a try/catch, so a link pointing at a live but FOREIGN checkout PASSES while being precisely the corrupted state. checkFrameworkResolves(appDir) (L41 to L84) attributes every failure to the #954 cause and prints, at L63 to L65:
fix:
'Install dependencies in this worktree (`npm install`), or symlink node_modules from the '+'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`).',
and at L82:
fix: 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).',
Both send the reader to run the command that causes this issue, and the second one fires on exactly the state a dangling link produces.
Add a shared inspector to the same file, since both checks ask about the same artefact:
/** * Classify the `@webjsdev/core` entry that `appDir` would resolve through. * Walks up for the first `node_modules` carrying the package, then judges the * link against the tree that PHYSICALLY owns that `node_modules`, which is the * only rule that is correct in a linked worktree: there `node_modules` is itself * a symlink at the primary's, so the owning tree is the PRIMARY and a target * inside it is right, not foreign. * @param {string} appDir * @param {string} [pkg] * @returns {{ state: 'absent'|'real'|'ok'|'dangling'|'foreign', entry?: string, target?: string, owner?: string }} */exportfunctioninspectFrameworkLink(appDir,pkg='@webjsdev/core'){/* ... */}
Implementation: for each dir from appDir up to the filesystem root, entry = join(dir, 'node_modules', ...pkg.split('/')); lstatSync throwing means keep walking; not a symlink means 'real'; otherwise resolve readlinkSync(entry) against dirname(entry), compute owner = dirname(realpathSync(join(dir, 'node_modules'))), and return 'dangling' when the target does not exist, 'foreign' when realpathSync(target) is not under owner + sep, else 'ok'. Reaching the root returns 'absent'.
New check in the same file:
/** * CHECK: framework link integrity (#1442). WARN when the `@webjsdev/core` entry * in node_modules is a symlink that DANGLES or resolves outside the tree that * owns it. That is what an install run inside a linked worktree leaves behind, * and it is invisible to CHECK 8, which only asks whether the package resolves. * ... */exportfunctioncheckFrameworkLinks(appDir){constname='framework-links';/* ... */}
'absent' and 'real' and 'ok' all PASS silently, so a normally installed app pays one lstat. 'dangling' and 'foreign' WARN with a message naming the entry and its target, and a fix of "run npm run worktree:link from this checkout, which repoints it; do NOT run npm install here if node_modules is a symlink". The 'foreign' message must note that a deliberate npm link produces the same shape.
Rewrite the two fix strings above so neither recommends a bare npm install when lstatSync(join(appDir, 'node_modules')).isSymbolicLink() is true; in that case both point at npm run worktree:link, or at removing the symlink first.
5b. Wire it in three places:
packages/cli/lib/doctor/codes.js L37 to L51, add 'framework-links': 'FRAMEWORK_LINKS', directly after the 'framework-resolve': 'FRAMEWORK_RESOLVE', entry at L44. This is mandatory: test/cli/doctor.test.mjs L692 asserts every result's code is a declared DOCTOR_CODE, and codeForName would otherwise derive an undeclared FRAMEWORK_LINKS, failing that test. It also makes the code gateable, since readDoctorPolicy validates gate keys against Object.values(DOCTOR_CODES) (packages/cli/lib/doctor/policy.js L78).
packages/cli/lib/doctor/runner.js L52 to L66, add Promise.resolve(checkFrameworkLinks(appDir)), immediately after the existing Promise.resolve(checkFrameworkResolves(appDir)), at L59, and import it at L13.
packages/cli/lib/doctor.js L56, extend the re-export to export { frameworkResolves, checkFrameworkResolves, inspectFrameworkLink, checkFrameworkLinks } from './doctor/probes/framework-resolves.js';.
Leave packages/cli/bin/webjs.js L430 to L438 calling checkFrameworkResolves only. A dangling link makes require.resolve fail, so that preflight already fires for dev and start; what changes is that its fix no longer tells the reader to run the destructive command.
The --json shape is unaffected: one more DoctorResult in results, summary counts recomputed by the existing code, no new key. No in-repo app's webjs.doctor.gate changes (decision 6); examples/blog and website keep {"gate":{"UNMARKED_ASSET_LINKS":"error"}} and gallery keeps none.
Step 6. Documentation
Listed in full under Docs below. It is part of the same change, not a follow-up: the advice in AGENTS.md L59 and L67 is the proximate cause, and leaving it in place means the next agent repeats the incident.
Tests
Every layer that applies, each with the counterfactual that fails when its guard is reverted.
1. New test/hooks/block-install-in-linked-worktree.test.mjs (layer A). Follow the harness in test/hooks/require-worktree-for-edits.test.mjs for the payload shape and in test/hooks/cleanup-merged-worktree.test.mjs L26 to L95 for building a throwaway repo with real worktrees. Feed the hook {"tool_input":{"command":"..."}} on stdin via spawnSync('bash', [HOOK], { cwd, input }) and assert on status plus stderr.
blocks cd <wt> && npm ci when <wt>/node_modules is a symlink, exit 2, stderr names the symlink target and npm run worktree:link
blocks a bare npm install when the hook's own cwd is that worktree
blocks bun add nanoid, since Bun writes through the link
counterfactual: the identical command exits 0 when <wt>/node_modules is a real directory
allows npm run test, npm test, and npx webjs check in the same symlinked worktree, so the verb regex does not swallow the commands agents actually run
allows an install whose only candidate directory has no node_modules at all (fail open)
honours WEBJS_NO_WORKTREE_INSTALL_GATE=1
2. New test/repo-health/warn-worktree-install.test.mjs (layer B). It belongs in test/repo-health/, not test/hooks/: it covers a repo-development script under scripts/, which is exactly what test/repo-health/git-worktree-safe.test.mjs and test/repo-health/link-worktree-deps.test.mjs already cover, while test/hooks/ covers .claude/hooks/* and .hooks/*. Drive it as a subprocess against a synthetic primary plus worktree, with .git written as a gitdir: FILE the way test/cli/doctor.test.mjs L372 already does.
silent and exit 0 when .git is a directory (the CI and primary shape)
with the symlink intact, exit 0 and stderr warns BEFORE the fact, naming the primary (the Bun shape)
with a real node_modules and an EMPTY primary node_modules, exit 0 and stderr names the npm ci aftermath and cd <primary> && npm install
with a real node_modules and a populated primary, exit 0 and stderr reports the detach and npm run worktree:link
counterfactual: exit is 0 even when the gitdir: pointer names a path that does not exist, proving the script can never block an install
'repoints a primary @webjsdev link that targets the worktree being removed': plant main/node_modules/@webjsdev/core at the absolute <wt>/packages/core with main/packages/core present, run the hook, assert the worktree is gone AND readlinkSync now returns ../../packages/core AND existsSync of the resolved entry is true. This is the counterfactual: revert the repoint and the link dangles, so existsSync fails.
'drops an npm staging entry that points into the removed worktree': .core-AbCdEf12 at <wt>/packages/core is gone afterwards.
'leaves a link that already points inside the primary alone': @webjsdev/server at ../../packages/server is byte-identical afterwards.
'never repoints to a path the primary does not have': @webjsdev/ghost at <wt>/packages/ghost with no main/packages/ghost is left reading its original target and is reported, never rewritten to a missing path.
'leaves links untouched when the worktree is KEPT': a dirty worktree keeps both the worktree and the links.
4. Extend test/repo-health/link-worktree-deps.test.mjs (layer D), inside the existing describe('link-worktree-deps (#1287)') or a sibling describe('framework-link repair (#1442)'). Extend makePrimary() to create node_modules/@webjsdev/ entries and real workspace directories with package.json names, so workspacePackageDirs has something to map.
'repoints a dangling @webjsdev link to the relative in-primary path' (counterfactual: reverted, the link still dangles)
'repoints a link that resolves outside the primary'
'normalises an absolute in-primary link to relative'
'removes a dangling .name-HASH staging entry'
'leaves a LIVE .name-HASH staging entry alone' (decision 4)
'leaves a correct relative link untouched', asserting readlinkSync is unchanged and the run reports nothing repaired
'never touches a real directory in @webjsdev'
'reports and leaves a dangling link whose package is in no workspace'
'repairs from the PRIMARY too', running with cwd === primary and asserting the repair happened before the this IS the primary checkout line
'--check reports without changing anything and exits 1', plus exit 0 on a clean tree
Update the existing 'defaultPrimary() resolves the real primary from this checkout' test (L195 to L208) to pass WEBJS_NO_WORKTREE_REPAIR: '1' alongside the existing WEBJS_NO_WORKTREE_SEED: '1', and extend its comment with the same reasoning: it is the one test that runs against the real checkout, so it must not mutate it. Add WEBJS_NO_WORKTREE_REPAIR to the cleanEnv() strip list at L79 to L82 for the same reason the other two are stripped.
5. Extend test/cli/doctor.test.mjs (layer E), in the framework-resolvability section at L345 to L392, importing checkFrameworkLinks and inspectFrameworkLink from the same doctor.js barrel the file already imports at L348.
'framework-links PASSES when @webjsdev/core is a real directory'
'framework-links PASSES when the link resolves inside the tree that owns node_modules'
'framework-links PASSES through a SYMLINKED node_modules (the linked-worktree shape)', the critical no-false-positive case: build a primary with a relative @webjsdev/core link, a worktree whose node_modules is a symlink at it, and assert pass from the worktree
'framework-links WARNS when the link dangles', asserting the fix matches worktree:link and assert.doesNotMatch(r.fix, /npm install/)
'framework-links WARNS when the link resolves outside the owning tree'
'framework-resolve does not recommend a bare npm install when node_modules is a symlink'
The existing drift test at L692 covers the DOCTOR_CODES entry: drop the map entry and it fails.
Layers that do NOT apply. Browser (npm run test:browser): nothing here renders, hydrates, or touches the DOM. E2E (WEBJS_E2E=1): no HTTP, routing, streaming, or navigation surface. Smoke (test/examples/*/smoke/*): no scaffolded-app behaviour changes, and packages/cli/lib/create.js L380 builds the generated scripts block literally with no preinstall, while packages/cli/templates/ ships no package.json at all, so a webjs create app cannot inherit the root preinstall. Bun parity under test/bun/**: this is repo tooling, not a runtime-sensitive surface. Nothing here touches the serializer, the listener or request path, SSR / action / CSRF dispatch, streams, node:crypto, the TS stripper, or auth / session / cors, and .claude/hooks/require-bun-parity-with-runtime-src.sh L61 to L62 does not match any path in this change (framework-resolves.js and framework-links hit none of its keywords). The Bun-specific behaviour that DOES exist, Bun running preinstall before the write and ignoring its exit code, is asserted in the Node test by constructing both filesystem shapes directly, because test/bun/** is for framework cross-runtime parity rather than for repo tooling. No scripts/run-bun-tests.js denylist entry is needed either: the new tests spawn node and bash and import no node:sqlite, unlike the existing link-worktree-deps entry at L59.
Docs
Six surfaces. Grepped the whole repo for the offending advice: it appears ONLY in AGENTS.md and scripts/link-worktree-deps.mjs. The skill at .agents/skills/webjs/, packages/cli/templates/.agents/**, and website/app/docs/** carry no worktree-install guidance at all, so nothing there needs correcting on that count.
AGENTS.md, the "One task per git worktree, ALWAYS" section.
L59, drop the parenthetical that recommends the destructive action. Today: "Fix it with npm run worktree:link from inside the worktree (or a full npm install there, which is correct but slow and duplicates a large tree per worktree)." After: "Fix it with npm run worktree:link from inside the worktree. A full npm install there is NOT an alternative while the link is standing, see below."
L67, the "Know what this does NOT give you" paragraph, replace "run a real npm install in the worktree" with "delete the node_modules symlink first (rm node_modules, it is only a link) and then install".
New paragraph after L67: NEVER install through a linked worktree's node_modules (fix: npm install in a linked worktree corrupts the shared node_modules #1442). State the three measured behaviours (npm ci deletes the primary's whole tree before any lifecycle script runs, bun install writes into the primary through the link, npm install silently replaces the link and detaches the worktree), that preinstall cannot prevent any of them, that Claude Code blocks it via .claude/hooks/block-install-in-linked-worktree.sh with escape hatch WEBJS_NO_WORKTREE_INSTALL_GATE=1, that the root preinstall reports it for every other tool, and that npm run worktree:link repairs an already-damaged primary while npm run check:worktree-links reports without changing anything.
L69, the note that the webjs doctor / webjs dev remedy still suggests the root-only symlink, gains the corrected reality: the remedy no longer recommends a bare npm install when node_modules is a symlink.
L73, the cleanup-hook paragraph, add that the hook now repoints any primary @webjsdev/* link into a worktree it is about to remove, before removing it.
L574, the webjs doctor CLI reference line, add the new check beside the existing framework-resolve mention: a framework-links check that warns when the @webjsdev/core entry is a symlink that dangles or resolves outside the tree that owns its node_modules (fix: npm install in a linked worktree corrupts the shared node_modules #1442).
framework-dev.md. New subsection after L147, "Never install through a linked worktree's node_modules (fix: npm install in a linked worktree corrupts the shared node_modules #1442)", carrying the measurement table, the layer map (hook, preinstall, teardown repoint, worktree:link repair, doctor check), both escape hatches (WEBJS_NO_WORKTREE_INSTALL_GATE=1, WEBJS_NO_WORKTREE_REPAIR=1), and the regression tests by path. Also extend L170 (the cleanup-merged-worktree.sh paragraph) with the repoint step.
packages/cli/AGENTS.md L193, the doctor severity paragraph that lists the environment-shaped codes (GIT_HOOK, ENV_DRIFT, VENDOR_PIN, FRAMEWORK_RESOLVE). Add FRAMEWORK_LINKS, which is environment-shaped for the same reason.
.agents/skills/webjs/references/built-ins.md L219, the same list in the skill's webjs doctor paragraph. Add FRAMEWORK_LINKS.
website/app/docs/configuration/page.ts, the docs site. L50 lists the environment-shaped checks ("the git-hook, env-drift, vendor-pin, and framework-resolve checks"), add framework-links. L62 enumerates what doctor verifies, add "framework link integrity" to that list.
scripts/link-worktree-deps.mjs docblock, covered by step 3a; it is itself a doc surface and is the file that carried the advice.
Doc gate..claude/hooks/require-docs-with-src.sh L59 matches ^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, so staging packages/cli/lib/doctor/** DOES fire it, and L73 to L74 accept a doc surface only from AGENTS|CLAUDE|CONVENTIONS|README.md, ^\.agents/skills/webjs/, ^website/, or ^packages/cli/templates/. Surfaces 1, 3, 4, and 5 all qualify, so the doctor commit must stage at least one of them alongside the probe and WEBJS_NO_DOC_GATE=1 is NOT needed. Note that framework-dev.md alone would NOT satisfy the gate. .claude/hooks/require-scaffold-with-src.sh L78 matches only ^packages/(core|server|cli)/src/, so it does not fire.
Acceptance criteria
A Bash command that installs into a directory whose node_modules is a symlink is BLOCKED before it runs, with a message naming the cause, the owning checkout, and both safe alternatives
npm run test, npm test, and npx webjs check in the same worktree are not blocked
WEBJS_NO_WORKTREE_INSTALL_GATE=1 disables the block
The root preinstall never blocks an install, exits 0 in every state including a broken gitdir pointer, and is silent in a normal clone and in CI
The root preinstall names the npm ci aftermath (an emptied primary node_modules) and prints the exact repair command
npm ci in CI and npm install in the primary are unaffected, verified against every install step in .github/workflows/ci.yml (L33, L89, L113, L360, L365, L380, L394, L526, L555, L582), .github/workflows/release.yml (L69), and .github/workflows/vendor-cdn.yml (L70)
Removing a merged worktree repoints every primary @webjsdev/* link that targeted it, and drops the staging entries that pointed into it, so no dangling link survives the removal
A primary link the hook cannot correct is kept and reported, never repointed at a path that does not exist
npm run worktree:link repairs an already-corrupted primary, including the three dangling .cli-rDk6NcDJ / .core-mTPzu12Q / .server-K5TIoquc entries present today and the absolute-but-in-primary server entry
npm run worktree:link leaves the eight live relative .name-HASH entries alone
npm run check:worktree-links reports without changing anything and exits non-zero only when there is something to repair
webjs doctor reports framework-links PASS through a correctly linked worktree, and WARNs distinctly on a dangling or foreign link
No webjs doctor remedy recommends a bare npm install while node_modules is a symlink
webjs doctor --json still emits { results, summary } with every result carrying a declared DOCTOR_CODE
Neither link-worktree-deps.mjs nor AGENTS.md recommends installing through the link
A freshly generated webjs create app has no preinstall in its package.json
Every new test fails when its guard is reverted
Out of scope
Changing what a linked worktree resolves through bare @webjsdev/* specifiers. It runs the PRIMARY's framework source by design (AGENTS.md L67), and repointing those entries at the worktree would silently change what every linked worktree tests.
Replacing the whole-directory node_modules link with per-entry links. It is the structurally better shape and it is rejected here (per-entry links break cjs-lexer in this repo, and the nested-tree set at scripts/link-worktree-deps.mjs L9 to L16 multiplies the work).
Identifying which historical npm or Bun version wrote the absolute worktree-anchored targets now sitting in the primary. Not reproducible on npm 11.19.0 or bun 1.3.14, and the fix does not depend on it.
Extending the block hook to non-Claude agents through a shim or a git hook. Installs are not git operations, and the cross-agent coverage here is AGENTS.md plus the preinstall reporter.
Adding FRAMEWORK_LINKS to any app's webjs.doctor.gate, or otherwise gating it in CI. It cannot occur on a runner.
Anything about the seeding step, the nested-tree discovery, or packages/core/dist linking. Untouched by this change.
Problem
npm run worktree:link(scripts/link-worktree-deps.mjs) symlinks a worktree'snode_modulesat the primary checkout's. That makes the worktree runnable without a full install, and it also puts a single writable path from every worktree into the primary's dependency tree. An install run inside a linked worktree therefore acts on the PRIMARY, and the damage lands on sessions that did nothing wrong, in a place and at a time remote from the cause.The trap is that
scripts/link-worktree-deps.mjsdocuments this exact action as the remedy (L37, under "What this does NOT give you"): "run a realnpm installin the worktree".AGENTS.mdL59 repeats it ("or a fullnpm installthere, which is correct but slow"). Following the tooling's own advice damages shared state.Measured behaviour (npm 11.19.0, Node v26.7.0, bun 1.3.14, all reproduced in a throwaway tree)
The original filing asserted one mechanism (npm's workspace linker rewriting
<primary>/node_modules/@webjsdev/*to absolute worktree paths). That specific rewrite does NOT reproduce on today's npm. Three other behaviours do, and one is worse than the filing described. Each was measured with a synthetic primary plus a worktree whose rootnode_modulesis a symlink at it.npm installnode_modulessymlink with a real directory and builds a full tree there. The primary is untouched. The worktree silently detaches from the primary, so it no longer runs the primary's framework source.npm ci<primary>/node_modules, then builds a real tree in the worktree. The primary and every other linked worktree break at once.bun add <pkg>/bun install.binentries land in<primary>/node_modules.A
preinstallscript cannot prevent any of this, which is the filing's main design error. Measured, with apreinstallthat prints the state it sees:npm install,preinstallruns withnode_modulesALREADY a real directory. The symlink is gone before the script starts.npm ci,preinstallruns with the primary'snode_modulesALREADY emptied. The destruction precedes the script.bun install/bun add,preinstallruns BEFORE the write and DOES see the symlink, but a non-zero exit from it does not stop Bun (measured:bun installexited 0 with apreinstallthat exited 2).preinstallcwd is always the workspace ROOT that owns the script, even when the install was invoked from a workspace subdirectory (INIT_CWDcarries the invocation directory). npm createsnode_modulesbefore runningpreinstall, so theENOENTbranch is unreachable on a fresh clone.preinstallDOES block bothnpm installandnpm ci(exit code propagated verbatim), so a buggy guard would red every CI job.So the key the filing proposed,
lstatSync('node_modules').isSymbolicLink()evaluated insidepreinstall, is alwaysfalseunder npm. Prevention has to happen before the package manager starts.Current state of
<primary>/node_modules/@webjsdev/(audited at HEAD)The filing said the
.name-HASHentries are staging debris. That is only partly right, and the repair rule has to be narrower than "clean them up".Eight staging entries are LIVE and relative and point at real in-repo directories. They are harmless (nothing imports
@webjsdev/.ui-tVBcnl39) and one of them,.docs-kRj0IV70, predates a package rename, so its name cannot even be mapped back to a workspace. Only three dangle, all into the removedwebjs-formaction-submitterworktree, dated 1 Aug.serveris absolute where every sibling is relative, pointing at the primary itself.So the corruption is real and has happened at least twice. The exact writer that produced worktree-anchored ABSOLUTE targets in the primary is not reproducible on npm 11.19.0 or bun 1.3.14, and the fix does not depend on identifying it: all three reproducible behaviours above, plus the historical debris, are covered by the same set of changes.
The rule that makes this severe is
AGENTS.md"One task per git worktree, ALWAYS": multiple agents work this repo concurrently, so shared-state damage hits a session that cannot see the cause. Same class as #166 (recurringcore.bare=truecorruption of the main repo from worktree operations) and #1287 (a fresh-worktree remedy that itself produced a broken worktree).Design / approach
Five layers. Prevention moves UP a level from the filing's design, because the measurements above prove the package manager gives no usable hook, and repair stays because the debris proves corruption already happened twice unnoticed.
A. Prevent, at the only layer that runs before the package manager: a Claude Code
PreToolUsehook..claude/hooks/block-install-in-linked-worktree.sh(matcherBash) refuses an install command whose target directory has a symlinkednode_modules, exit 2 with the cause and both safe alternatives on stderr. This is the repo's established pattern for exactly this shape of rule (.claude/hooks/require-worktree-for-edits.shblocks tracked-file edits in the primary the same way), and it is the ONLY layer that sees the state before npm mutates it. It covers Claude Code sessions, which is where the incident happened and where the concurrency that makes this severe comes from. Other agents and humans are covered by layer B plus the doc fix.B. Report, for every tool: a root
preinstall.scripts/warn-worktree-install.mjs, wired as"preinstall", ALWAYS exits 0. Under Bun it fires before the write and warns in time. Under npm it fires after and turns a silent detach, or a silently emptied primary, into a named diagnosis with the exact repair command. Blocking here is rejected on measurement: it cannot stopnpm ci(the primary is already gone), it cannot stop Bun (exit code ignored), and stoppingnpm installwould leave the worktree with no symlink and a half-built tree, which is worse than what it prevented. A guard that never blocks is not called a guard, so the filing'sscripts/guard-worktree-install.mjsname is replaced byscripts/warn-worktree-install.mjs.C. Repair on teardown.
.claude/hooks/cleanup-merged-worktree.shremoves merged worktrees while the primary may hold links INTO the tree it is deleting. It repoints them BEFORE the removal, while the target is still identifiable.D. Repair on demand.
scripts/link-worktree-deps.mjsgains a repair pass over<primary>/node_modules/@webjsdev/and a read-only--checkmode.E. Detect. A new
webjs doctorcheck,framework-links/FRAMEWORK_LINKS, distinguishes a dangling or foreign framework link from the #954 missing-node_modulescase, andcheckFrameworkResolvesstops recommending a barenpm installin a linked worktree, which is the command that causes this.Settled decisions
link-worktree-deps.mjsruns UNCONDITIONALLY, not behind a flag. The script's contract is already "safe to re-run, a no-op when there is nothing to do" (docblock L41 to L52), and the repair is idempotent by the same standard: it rewrites only a link that is already wrong. A flag would never be passed, because the one command anyone runs isnpm run worktree:link. Read-only inspection is available as--check, followingscripts/git-worktree-safe.mjsL34 andnpm run check:git.npm run worktree:link(layer D) and the teardown hook (layer C, which is repairing damage it is itself about to cause). Thepreinstallreporter andwebjs doctornever repair.webjs doctoris documented as a read-only project-health checklist, and a repair triggered bynpm installwould mutate shared state from a command nobody asked to mutate anything with..name-HASHstaging entries are left strictly alone. They resolve, nothing imports them (@webjsdev/.ui-tVBcnl39is not a specifier any code writes), one of them predates a package rename so its name maps to no current workspace, and deleting a live entry risks racing an install that is mid-reify. A staging entry is removed ONLY when it is unusable, defined as dangling OR pointing into the worktree being removed.WEBJS_NO_WORKTREE_INSTALL_GATE=1, matching theWEBJS_NO_WORKTREE_GATE/WEBJS_NO_WORKTREE_CLEANUP/WEBJS_NO_WORKTREE_SEED/WEBJS_NO_DOC_GATEfamily. A bypass is warranted because a real install in a worktree is a legitimate workflow (the self-testing-worktree case AGENTS.md L67 describes), and the hook's own message names the safer form of it (remove the symlink first). The repair pass getsWEBJS_NO_WORKTREE_REPAIR=1for the same reasonWEBJS_NO_WORKTREE_SEED=1exists: one repo-health test runs the real script against the real checkout and must not mutate it.FRAMEWORK_LINKS, default severitywarn. Dangling and foreign are the same defect class with the same remedy, so they share a code and differ only in message; the missing-node_modulescase keepsFRAMEWORK_RESOLVE, which already has a code, a documented meaning, and a CLI preflight consuming it.warnmatches the tier of the sibling environment-shaped checks. No in-repo app'swebjs.doctor.gatechanges: a corrupted local link cannot occur on a CI runner (fresh clone, real directories), so gating iterrorwould only red theconventionsjob on a developer machine.Alternatives considered and rejected
preinstallthat blocks (the filing's layer 1). Rejected on measurement, see above. Kept as a non-blocking reporter.node_moduleslink, so a destructiverm -rfunlinks copies rather than following one link into shared state. Structurally the best fix, and rejected: per-entry symlinks are already known to breakcjs-lexerin this repo, and the root tree carries over a thousand entries whose nested trees (packages/server's ws@8, seescripts/link-worktree-deps.mjsL9 to L16) would each need the same treatment.scripts/link-worktree-deps.mjsexists.@webjsdev/*at the worktree. Explicitly refused.AGENTS.mdL67 documents that a linked worktree runs the PRIMARY's framework source through bare specifiers, and changing it silently changes what every linked worktree tests..npmrcorenginesguard.only-allow-stylepreinstallrefusal is the standard prior art (vite/package.json,astro/package.json,solid/package.json, andqwik/package.jsonall ship"preinstall": "npx only-allow pnpm"), but its precondition does not hold here:only-allowreadsnpm_config_user_agent, which npm sets before touching anything, so refusing costs nothing. Here the destructive act precedes the script.Prior art read:
scripts/git-worktree-safe.mjs(the repo's existing shared-git-state repair, its--checkconvention, and its "ensure mode must never failnpm install" rule at L145 to L155),.claude/hooks/require-worktree-for-edits.sh(PreToolUse block contract, exit 2 plus stderr, escape hatch),next.js/package.json("postinstall": "node scripts/git-configure.mjs", install-time repair of shared git state), issues #1287 and #166.No other issue is being planned in this batch, so this surface is uncontested as of HEAD
e0abcf96. Every line anchor below is against that commit.Implementation plan
Ordered. Steps 1 and 2 are one commit (prevention), 3 and 4 one commit (repair), 5 one commit (detection), 6 one commit (docs).
packages/is plain.jswith JSDoc andscripts/is.mjs; no.tsanywhere in this change.Step 1. New
.claude/hooks/block-install-in-linked-worktree.shNew file,
chmod +x. Model it on.claude/hooks/require-worktree-for-edits.sh(same contract: exit 0 allows, exit 2 blocks with the message on stderr,jqabsent means exit 0, escape hatch checked first).It cannot key on its own cwd alone. The harness resets cwd to the primary checkout between Bash calls, which
require-worktree-for-edits.shL7 to L9 already records, so a real install in a worktree arrives ascd /path/to/worktree && npm ci. Candidate directories are therefore: the hook's own cwd, plus everycd <token>in the command, plus every-C <token>and--prefix[= ]<token>. Relative candidates resolve against the hook cwd. If no candidate has anode_modulesat all, exit 0 (fail open, the same posture every other predicate in that file takes).Install verbs to match, as a word-boundary regex so
npm run test,npm test,npm exec,npm ls, andnpxare NOT matched:Block when
[ -L "$cand/node_modules" ]for any candidate, or for the git toplevel of any candidate. Message, verbatim:Register it in
.claude/settings.json, in the existingPreToolUseentry whose"matcher": "Bash"already listsrequire-tests-with-src.sh,require-docs-with-src.sh,require-scaffold-with-src.sh, andrequire-bun-parity-with-runtime-src.sh. Append a fifth{"type": "command", "command": ".claude/hooks/block-install-in-linked-worktree.sh"}.Step 2. New
scripts/warn-worktree-install.mjsplus the rootpreinstallpackage.jsonscriptstoday (nopreinstall):After, with two new entries (
preinstallfirst in the block,check:worktree-linksbesidecheck:git):The
|| trueis belt and braces on top of the script's own always-exit-0 contract, because a non-zeropreinstallblocksnpm ciand would red every CI job (measured: the exit code propagates verbatim). The same reasoning is already written intogit-worktree-safe.mjsL145 to L155.The script, all output on stderr, always
process.exit(0), whole body in a try/catch that swallows:statSync('.git').isFile(), that is unless this is a LINKED worktree. A normal clone, which is what CI has, and the primary checkout both return immediately, so CI is untouched by construction.resolve(dirname(execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir']))), the same derivationdefaultPrimary()uses atscripts/link-worktree-deps.mjsL113 to L122.lstatSync('node_modules').isSymbolicLink()is true, the link is still standing, which is the Bun shape. Warn BEFORE the fact: this install writes through the link into<primary>/node_modules; runnpm run worktree:linkinstead, orrm node_modulesfirst if a real install is what you want.node_modulesis a real directory and<primary>/node_modulesis missing or empty, this is thenpm ciaftermath. Report that the primary's tree was deleted through the link and that the repair iscd <primary> && npm install.node_modulesis a real directory in a linked worktree, report that this worktree has detached from the primary and thatrm -rf node_modules && npm run worktree:linkrestores the shared tree.The header comment must state the measurements that shape it: npm removes the symlink before
preinstallruns,npm ciempties the primary before it runs, Bun runs it in time but ignores a non-zero exit, so this file reports and never blocks. Without that, the next reader "fixes" it into a blocking guard.Step 3. Repair pass in
scripts/link-worktree-deps.mjs3a. Docblock. L35 to L39 read today:
After:
L41 to L49, the first two safety rules, gain the narrowed exception:
Usage block at L54 to L58 gains
node scripts/link-worktree-deps.mjs --checkandnpm run check:worktree-links.3b. New functions, placed after
link()(L94 to L111) and beforedefaultPrimary()(L113):workspacePackageDirs(primary)returnsMap<packageName, relDir>. Read<primary>/package.jsonworkspaces(todaypackages/*,packages/editors/*,packages/wrappers/*,packages/ui/packages/*,examples/*,website,gallery), expand a single trailing*byreaddirSync, treat a pattern without*as a literal directory, and read each candidate'spackage.jsonname. Do NOT derive the target from the entry name: the mapping is notpackages/<name>(example-bloglives atexamples/blog,ui-registryatpackages/ui/packages/registry,docs-redirectatdocs,intellisenseatpackages/editors/intellisense).repairPrimaryFrameworkLinks(primary, { check })iteratesreaddirSync(<primary>/node_modules/@webjsdev, { withFileTypes: true }), including dot entries, and classifies each entry:/^\.(.+)-[A-Za-z0-9_-]{8}$/, AND dangling, remove it;join(primary, workspacePackageDirs(primary).get('@webjsdev/' + name)), written RELATIVE asrelative(dirname(entry), correct), which is../../<relDir>. Repoint when the current target dangles or resolves outsiderealpathSync(primary); normalise to relative when it resolves inside the primary but is absolute; leave a link that already reads exactly the relative form.symlinkSync(target, entry + '.tmp-' + process.pid)thenrenameSync(tmp, entry), so a parallel resolve never sees the entry absent. This matters becausenpm testresolves@webjsdev/*from many processes at once.{ repaired: string[], removed: string[], kept: string[] }and prints one line per action prefixed[link-worktree-deps].3c. Control flow, L225 to L237 today:
After. The checkout check moves ABOVE the primary-is-here guard, and the repair runs between them, because the repair targets the PRIMARY and must run whether or not this checkout is the primary.
--checkis filtered out of the positional argument, orresolve('--check')would be read as the primary path:--checkis read-only and terminal: it reports what it WOULD repair, exits 1 when there is anything, and never links or seeds. That mirrorsnpm run check:git(scripts/git-worktree-safe.mjsL96 to L143).Step 4. Repoint on teardown in
.claude/hooks/cleanup-merged-worktree.shThe hook already computes
primaryat L55 and already tolerates untrackednode_moduleswhen judging cleanliness at L88 to L94. Add arepoint_primary_links()function besideis_clean(), and call it at L119, immediately BEFORE the removal, so the link target is still identifiable:repoint_primary_linkswalks"$primary"/node_modules/@webjsdev/*and.[!.]*, skips anything that is not a symlink (which also absorbs an unmatched glob), normalises the target to an absolute real path withcd "$(dirname ...)" && pwd -Prather thanreadlink -f(portable, no GNU coreutils assumption), and acts only on targets under$(cd "$wt" && pwd -P)/:case "$base" in .*-????????), is removed withrm -f;ln -sfn "../../${abs#$wtreal/}" "$e", which is correct because the entry sits exactly two levels below the primary root, and only when"$primary/${abs#$wtreal/}"exists;Append each action to the existing
msgso it surfaces throughhookSpecificOutput.additionalContextalongside the removed and kept lists. The hook keeps exiting 0 always, andWEBJS_NO_WORKTREE_CLEANUP=1(L33) disables the repoint along with everything else, which is correct: it is the same teardown.Step 5. Doctor: a new
framework-linkscheck, and a correctedframework-resolveremedy5a.
packages/cli/lib/doctor/probes/framework-resolves.jstoday has exactly two exports.frameworkResolves(appDir)(L18 to L28) is a barerequire.resolvein a try/catch, so a link pointing at a live but FOREIGN checkout PASSES while being precisely the corrupted state.checkFrameworkResolves(appDir)(L41 to L84) attributes every failure to the #954 cause and prints, at L63 to L65:and at L82:
Both send the reader to run the command that causes this issue, and the second one fires on exactly the state a dangling link produces.
Add a shared inspector to the same file, since both checks ask about the same artefact:
Implementation: for each
dirfromappDirup to the filesystem root,entry = join(dir, 'node_modules', ...pkg.split('/'));lstatSyncthrowing means keep walking; not a symlink means'real'; otherwise resolvereadlinkSync(entry)againstdirname(entry), computeowner = dirname(realpathSync(join(dir, 'node_modules'))), and return'dangling'when the target does not exist,'foreign'whenrealpathSync(target)is not underowner + sep, else'ok'. Reaching the root returns'absent'.New check in the same file:
'absent'and'real'and'ok'all PASS silently, so a normally installed app pays onelstat.'dangling'and'foreign'WARN with a message naming the entry and its target, and afixof "runnpm run worktree:linkfrom this checkout, which repoints it; do NOT runnpm installhere ifnode_modulesis a symlink". The'foreign'message must note that a deliberatenpm linkproduces the same shape.Rewrite the two
fixstrings above so neither recommends a barenpm installwhenlstatSync(join(appDir, 'node_modules')).isSymbolicLink()is true; in that case both point atnpm run worktree:link, or at removing the symlink first.5b. Wire it in three places:
packages/cli/lib/doctor/codes.jsL37 to L51, add'framework-links': 'FRAMEWORK_LINKS',directly after the'framework-resolve': 'FRAMEWORK_RESOLVE',entry at L44. This is mandatory:test/cli/doctor.test.mjsL692 asserts every result's code is a declaredDOCTOR_CODE, andcodeForNamewould otherwise derive an undeclaredFRAMEWORK_LINKS, failing that test. It also makes the code gateable, sincereadDoctorPolicyvalidates gate keys againstObject.values(DOCTOR_CODES)(packages/cli/lib/doctor/policy.jsL78).packages/cli/lib/doctor/runner.jsL52 to L66, addPromise.resolve(checkFrameworkLinks(appDir)),immediately after the existingPromise.resolve(checkFrameworkResolves(appDir)),at L59, and import it at L13.packages/cli/lib/doctor.jsL56, extend the re-export toexport { frameworkResolves, checkFrameworkResolves, inspectFrameworkLink, checkFrameworkLinks } from './doctor/probes/framework-resolves.js';.Leave
packages/cli/bin/webjs.jsL430 to L438 callingcheckFrameworkResolvesonly. A dangling link makesrequire.resolvefail, so that preflight already fires fordevandstart; what changes is that itsfixno longer tells the reader to run the destructive command.The
--jsonshape is unaffected: one moreDoctorResultinresults,summarycounts recomputed by the existing code, no new key. No in-repo app'swebjs.doctor.gatechanges (decision 6);examples/blogandwebsitekeep{"gate":{"UNMARKED_ASSET_LINKS":"error"}}andgallerykeeps none.Step 6. Documentation
Listed in full under Docs below. It is part of the same change, not a follow-up: the advice in
AGENTS.mdL59 and L67 is the proximate cause, and leaving it in place means the next agent repeats the incident.Tests
Every layer that applies, each with the counterfactual that fails when its guard is reverted.
1. New
test/hooks/block-install-in-linked-worktree.test.mjs(layer A). Follow the harness intest/hooks/require-worktree-for-edits.test.mjsfor the payload shape and intest/hooks/cleanup-merged-worktree.test.mjsL26 to L95 for building a throwaway repo with real worktrees. Feed the hook{"tool_input":{"command":"..."}}on stdin viaspawnSync('bash', [HOOK], { cwd, input })and assert onstatusplus stderr.cd <wt> && npm ciwhen<wt>/node_modulesis a symlink, exit 2, stderr names the symlink target andnpm run worktree:linknpm installwhen the hook's own cwd is that worktreebun add nanoid, since Bun writes through the link<wt>/node_modulesis a real directorynpm run test,npm test, andnpx webjs checkin the same symlinked worktree, so the verb regex does not swallow the commands agents actually runnode_modulesat all (fail open)WEBJS_NO_WORKTREE_INSTALL_GATE=12. New
test/repo-health/warn-worktree-install.test.mjs(layer B). It belongs intest/repo-health/, nottest/hooks/: it covers a repo-development script underscripts/, which is exactly whattest/repo-health/git-worktree-safe.test.mjsandtest/repo-health/link-worktree-deps.test.mjsalready cover, whiletest/hooks/covers.claude/hooks/*and.hooks/*. Drive it as a subprocess against a synthetic primary plus worktree, with.gitwritten as agitdir:FILE the waytest/cli/doctor.test.mjsL372 already does..gitis a directory (the CI and primary shape)node_modulesand an EMPTY primarynode_modules, exit 0 and stderr names thenpm ciaftermath andcd <primary> && npm installnode_modulesand a populated primary, exit 0 and stderr reports the detach andnpm run worktree:linkgitdir:pointer names a path that does not exist, proving the script can never block an install3. Extend
test/hooks/cleanup-merged-worktree.test.mjs(layer C). ExtendmakeRepo/addWorktreeto plantmain/node_modules/@webjsdev/entries.'repoints a primary @webjsdev link that targets the worktree being removed': plantmain/node_modules/@webjsdev/coreat the absolute<wt>/packages/corewithmain/packages/corepresent, run the hook, assert the worktree is gone ANDreadlinkSyncnow returns../../packages/coreANDexistsSyncof the resolved entry is true. This is the counterfactual: revert the repoint and the link dangles, soexistsSyncfails.'drops an npm staging entry that points into the removed worktree':.core-AbCdEf12at<wt>/packages/coreis gone afterwards.'leaves a link that already points inside the primary alone':@webjsdev/serverat../../packages/serveris byte-identical afterwards.'never repoints to a path the primary does not have':@webjsdev/ghostat<wt>/packages/ghostwith nomain/packages/ghostis left reading its original target and is reported, never rewritten to a missing path.'leaves links untouched when the worktree is KEPT': a dirty worktree keeps both the worktree and the links.4. Extend
test/repo-health/link-worktree-deps.test.mjs(layer D), inside the existingdescribe('link-worktree-deps (#1287)')or a siblingdescribe('framework-link repair (#1442)'). ExtendmakePrimary()to createnode_modules/@webjsdev/entries and real workspace directories withpackage.jsonnames, soworkspacePackageDirshas something to map.'repoints a dangling @webjsdev link to the relative in-primary path'(counterfactual: reverted, the link still dangles)'repoints a link that resolves outside the primary''normalises an absolute in-primary link to relative''removes a dangling .name-HASH staging entry''leaves a LIVE .name-HASH staging entry alone'(decision 4)'leaves a correct relative link untouched', assertingreadlinkSyncis unchanged and the run reports nothing repaired'never touches a real directory in @webjsdev''reports and leaves a dangling link whose package is in no workspace''repairs from the PRIMARY too', running withcwd === primaryand asserting the repair happened before thethis IS the primary checkoutline'--check reports without changing anything and exits 1', plus exit 0 on a clean tree'defaultPrimary() resolves the real primary from this checkout'test (L195 to L208) to passWEBJS_NO_WORKTREE_REPAIR: '1'alongside the existingWEBJS_NO_WORKTREE_SEED: '1', and extend its comment with the same reasoning: it is the one test that runs against the real checkout, so it must not mutate it. AddWEBJS_NO_WORKTREE_REPAIRto thecleanEnv()strip list at L79 to L82 for the same reason the other two are stripped.5. Extend
test/cli/doctor.test.mjs(layer E), in the framework-resolvability section at L345 to L392, importingcheckFrameworkLinksandinspectFrameworkLinkfrom the samedoctor.jsbarrel the file already imports at L348.'framework-links PASSES when @webjsdev/core is a real directory''framework-links PASSES when the link resolves inside the tree that owns node_modules''framework-links PASSES through a SYMLINKED node_modules (the linked-worktree shape)', the critical no-false-positive case: build a primary with a relative@webjsdev/corelink, a worktree whosenode_modulesis a symlink at it, and assertpassfrom the worktree'framework-links WARNS when the link dangles', asserting thefixmatchesworktree:linkandassert.doesNotMatch(r.fix, /npm install/)'framework-links WARNS when the link resolves outside the owning tree''framework-resolve does not recommend a bare npm install when node_modules is a symlink'DOCTOR_CODESentry: drop the map entry and it fails.Layers that do NOT apply. Browser (
npm run test:browser): nothing here renders, hydrates, or touches the DOM. E2E (WEBJS_E2E=1): no HTTP, routing, streaming, or navigation surface. Smoke (test/examples/*/smoke/*): no scaffolded-app behaviour changes, andpackages/cli/lib/create.jsL380 builds the generatedscriptsblock literally with nopreinstall, whilepackages/cli/templates/ships nopackage.jsonat all, so awebjs createapp cannot inherit the rootpreinstall. Bun parity undertest/bun/**: this is repo tooling, not a runtime-sensitive surface. Nothing here touches the serializer, the listener or request path, SSR / action / CSRF dispatch, streams,node:crypto, the TS stripper, or auth / session / cors, and.claude/hooks/require-bun-parity-with-runtime-src.shL61 to L62 does not match any path in this change (framework-resolves.jsandframework-linkshit none of its keywords). The Bun-specific behaviour that DOES exist, Bun runningpreinstallbefore the write and ignoring its exit code, is asserted in the Node test by constructing both filesystem shapes directly, becausetest/bun/**is for framework cross-runtime parity rather than for repo tooling. Noscripts/run-bun-tests.jsdenylist entry is needed either: the new tests spawnnodeandbashand import nonode:sqlite, unlike the existinglink-worktree-depsentry at L59.Docs
Six surfaces. Grepped the whole repo for the offending advice: it appears ONLY in
AGENTS.mdandscripts/link-worktree-deps.mjs. The skill at.agents/skills/webjs/,packages/cli/templates/.agents/**, andwebsite/app/docs/**carry no worktree-install guidance at all, so nothing there needs correcting on that count.AGENTS.md, the "One task per git worktree, ALWAYS" section.npm run worktree:linkfrom inside the worktree (or a fullnpm installthere, which is correct but slow and duplicates a large tree per worktree)." After: "Fix it withnpm run worktree:linkfrom inside the worktree. A fullnpm installthere is NOT an alternative while the link is standing, see below."npm installin the worktree" with "delete thenode_modulessymlink first (rm node_modules, it is only a link) and then install".node_modules(fix: npm install in a linked worktree corrupts the shared node_modules #1442). State the three measured behaviours (npm cideletes the primary's whole tree before any lifecycle script runs,bun installwrites into the primary through the link,npm installsilently replaces the link and detaches the worktree), thatpreinstallcannot prevent any of them, that Claude Code blocks it via.claude/hooks/block-install-in-linked-worktree.shwith escape hatchWEBJS_NO_WORKTREE_INSTALL_GATE=1, that the rootpreinstallreports it for every other tool, and thatnpm run worktree:linkrepairs an already-damaged primary whilenpm run check:worktree-linksreports without changing anything.webjs doctor/webjs devremedy still suggests the root-only symlink, gains the corrected reality: the remedy no longer recommends a barenpm installwhennode_modulesis a symlink.@webjsdev/*link into a worktree it is about to remove, before removing it.webjs doctorCLI reference line, add the new check beside the existingframework-resolvemention: aframework-linkscheck that warns when the@webjsdev/coreentry is a symlink that dangles or resolves outside the tree that owns itsnode_modules(fix: npm install in a linked worktree corrupts the shared node_modules #1442).framework-dev.md. New subsection after L147, "Never install through a linked worktree'snode_modules(fix: npm install in a linked worktree corrupts the shared node_modules #1442)", carrying the measurement table, the layer map (hook,preinstall, teardown repoint,worktree:linkrepair, doctor check), both escape hatches (WEBJS_NO_WORKTREE_INSTALL_GATE=1,WEBJS_NO_WORKTREE_REPAIR=1), and the regression tests by path. Also extend L170 (thecleanup-merged-worktree.shparagraph) with the repoint step.packages/cli/AGENTS.mdL193, the doctor severity paragraph that lists the environment-shaped codes (GIT_HOOK,ENV_DRIFT,VENDOR_PIN,FRAMEWORK_RESOLVE). AddFRAMEWORK_LINKS, which is environment-shaped for the same reason..agents/skills/webjs/references/built-ins.mdL219, the same list in the skill'swebjs doctorparagraph. AddFRAMEWORK_LINKS.website/app/docs/configuration/page.ts, the docs site. L50 lists the environment-shaped checks ("the git-hook, env-drift, vendor-pin, and framework-resolve checks"), add framework-links. L62 enumerates what doctor verifies, add "framework link integrity" to that list.scripts/link-worktree-deps.mjsdocblock, covered by step 3a; it is itself a doc surface and is the file that carried the advice.Doc gate.
.claude/hooks/require-docs-with-src.shL59 matches^packages/([^/]+/src|editors/[^/]+/src|cli/lib)/, so stagingpackages/cli/lib/doctor/**DOES fire it, and L73 to L74 accept a doc surface only fromAGENTS|CLAUDE|CONVENTIONS|README.md,^\.agents/skills/webjs/,^website/, or^packages/cli/templates/. Surfaces 1, 3, 4, and 5 all qualify, so the doctor commit must stage at least one of them alongside the probe andWEBJS_NO_DOC_GATE=1is NOT needed. Note thatframework-dev.mdalone would NOT satisfy the gate..claude/hooks/require-scaffold-with-src.shL78 matches only^packages/(core|server|cli)/src/, so it does not fire.Acceptance criteria
node_modulesis a symlink is BLOCKED before it runs, with a message naming the cause, the owning checkout, and both safe alternativesnpm run test,npm test, andnpx webjs checkin the same worktree are not blockedWEBJS_NO_WORKTREE_INSTALL_GATE=1disables the blockpreinstallnever blocks an install, exits 0 in every state including a broken gitdir pointer, and is silent in a normal clone and in CIpreinstallnames thenpm ciaftermath (an emptied primarynode_modules) and prints the exact repair commandnpm ciin CI andnpm installin the primary are unaffected, verified against every install step in.github/workflows/ci.yml(L33, L89, L113, L360, L365, L380, L394, L526, L555, L582),.github/workflows/release.yml(L69), and.github/workflows/vendor-cdn.yml(L70)@webjsdev/*link that targeted it, and drops the staging entries that pointed into it, so no dangling link survives the removalnpm run worktree:linkrepairs an already-corrupted primary, including the three dangling.cli-rDk6NcDJ/.core-mTPzu12Q/.server-K5TIoqucentries present today and the absolute-but-in-primaryserverentrynpm run worktree:linkleaves the eight live relative.name-HASHentries alonenpm run check:worktree-linksreports without changing anything and exits non-zero only when there is something to repairwebjs doctorreportsframework-linksPASS through a correctly linked worktree, and WARNs distinctly on a dangling or foreign linkwebjs doctorremedy recommends a barenpm installwhilenode_modulesis a symlinkwebjs doctor --jsonstill emits{ results, summary }with every result carrying a declaredDOCTOR_CODElink-worktree-deps.mjsnorAGENTS.mdrecommends installing through the linkwebjs createapp has nopreinstallin itspackage.jsonOut of scope
@webjsdev/*specifiers. It runs the PRIMARY's framework source by design (AGENTS.mdL67), and repointing those entries at the worktree would silently change what every linked worktree tests.node_moduleslink with per-entry links. It is the structurally better shape and it is rejected here (per-entry links breakcjs-lexerin this repo, and the nested-tree set atscripts/link-worktree-deps.mjsL9 to L16 multiplies the work).AGENTS.mdplus thepreinstallreporter.FRAMEWORK_LINKSto any app'swebjs.doctor.gate, or otherwise gating it in CI. It cannot occur on a runner.packages/core/distlinking. Untouched by this change.