Skip to content

fix: npm install in a linked worktree corrupts the shared node_modules #1442

Description

@vivek7405

Problem

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.mjs documents 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

(^|[^[:alnum:]-])(npm[[:space:]]+(install|i|ci|add|update|dedupe)|bun[[:space:]]+(install|add|update)|pnpm[[:space:]]+(install|add|update)|yarn[[:space:]]+(install|add))([^[:alnum:]-]|$)

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

package.json scripts today (no preinstall):

"prepare": "node scripts/git-worktree-safe.mjs 2>/dev/null || true",
"fix:git": "node scripts/git-worktree-safe.mjs",
"check:git": "node scripts/git-worktree-safe.mjs --check",
"worktree:link": "node scripts/link-worktree-deps.mjs",

After, with two new entries (preinstall first in the block, check:worktree-links beside check:git):

"preinstall": "node scripts/warn-worktree-install.mjs || true",
"prepare": "node scripts/git-worktree-safe.mjs 2>/dev/null || true",
"fix:git": "node scripts/git-worktree-safe.mjs",
"check:git": "node scripts/git-worktree-safe.mjs --check",
"check:worktree-links": "node scripts/link-worktree-deps.mjs --check",
"worktree:link": "node scripts/link-worktree-deps.mjs",

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.json workspaces (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.json name. 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:

const here = process.cwd();
const primary = 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:

const here = process.cwd();
const CHECK = process.argv.includes('--check');
const args = process.argv.slice(2).filter((a) => a !== '--check');
const primary = 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 {
  const r = 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=""; continue
      fi
      # #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 }}
 */
export function inspectFrameworkLink(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.
 * ...
 */
export function checkFrameworkLinks(appDir) { const name = '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

3. Extend test/hooks/cleanup-merged-worktree.test.mjs (layer C). Extend makeRepo / addWorktree to plant main/node_modules/@webjsdev/ entries.

  • '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.

  1. 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).
  2. 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.
  3. 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.
  4. .agents/skills/webjs/references/built-ins.md L219, the same list in the skill's webjs doctor paragraph. Add FRAMEWORK_LINKS.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions