Skip to content

Commit f5cfde9

Browse files
committed
fix: close five more defects the delta review found
The bare-`yarn` branch was the worst of these. It lived inside the generic VERBS pattern, whose prefix any space satisfies, so it matched the token ANYWHERE and blocked `which yarn`, `rm -rf /tmp/yarn` and `git switch -c feat/yarn`. That fires on ordinary commands in a linked worktree, which is the mandated working state here, and a gate that cries wolf gets turned off. It is now its own anchored pattern requiring command position. The hyphenated npm verbs needed spelling out. The trailing word boundary excludes `-`, so listing `install` never reached `install-test`, `install-ci-test` or `clean-install-test`, and the short aliases do not cover them. The remove verbs are in the table too: `npm rm` in a linked worktree deletes from the checkout that owns the tree. `--check` under WEBJS_NO_WORKTREE_REPAIR=1 exited 0 while inspecting nothing, because `touched` kept its initializer when the repair block was skipped. The hatch suppresses the repair WRITE, and `--check` never writes, so it now always inspects. My previous test asserted the exit 0 and locked the defect in. The `entry_name` rename gets the source guard the last commit claimed for it.
1 parent 0874936 commit f5cfde9

7 files changed

Lines changed: 111 additions & 23 deletions

.claude/hooks/block-install-in-linked-worktree.sh

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,38 @@ if [ -z "$cmd" ]; then exit 0; fi
3737
# aliases are the consequential ones. npm's list is long because npm ships a
3838
# large alias table of its own (`npm help install`), typo aliases included.
3939
#
40+
# HYPHENATED verbs must be spelled out. The trailing word boundary excludes `-`,
41+
# so `install-test` is NOT reached by listing `install`; each hyphenated command
42+
# needs its own entry, and the short aliases (`it`, `cit`, `sit`) do not cover
43+
# the long spellings.
44+
#
45+
# The REMOVE verbs are here too. `npm rm <pkg>` in a linked worktree deletes
46+
# from the checkout that owns the tree, the same corruption in the other
47+
# direction.
48+
#
4049
# Word boundaries on BOTH sides keep this narrow: `npm init` does not match `in`
4150
# or `i`, because the next character is alphanumeric, and `npm run install-deps`
4251
# does not match because `run` is not a verb here.
43-
NPM_VERBS='install|i|in|ins|inst|insta|instal|isnt|isnta|isntal|isntall|add|ci|clean-install|ic|install-clean|sit|it|cit|update|up|upgrade|udpate|dedupe|ddp'
44-
BUN_VERBS='install|i|add|a|update|up'
45-
PNPM_VERBS='install|i|add|update|up'
46-
YARN_VERBS='install|add|up|upgrade'
47-
# The last two branches are the two shapes a plain verb regex cannot see:
48-
# * `npm --prefix <dir> install`, flags BEFORE the verb. Only the two flags
49-
# that themselves name a target directory are admitted, so this stays
50-
# targeted rather than swallowing a token run and matching `npm run install`.
51-
# * bare `yarn`, which IS an install in yarn classic. Matched only when it ends
52-
# the command or is followed by a flag, so `yarn test` stays allowed.
53-
VERBS="(npm[[:space:]]+(${NPM_VERBS})|bun[[:space:]]+(${BUN_VERBS})|pnpm[[:space:]]+(${PNPM_VERBS})|yarn[[:space:]]+(${YARN_VERBS})|(npm|pnpm|yarn)[[:space:]]+(--prefix|-C)[[:space:]=]+[^[:space:]&|;]+[[:space:]]+(${NPM_VERBS})|yarn([[:space:]]+-[^[:space:]]*)*[[:space:]]*(\$|[&|;]))"
54-
if ! printf '%s' "$cmd" | grep -Eq "(^|[^[:alnum:]_-])${VERBS}([^[:alnum:]_-]|\$)"; then
52+
NPM_VERBS='install-ci-test|clean-install-test|install-clean|clean-install|install-test|install|isntall|isntal|isnta|isnt|instal|insta|inst|ins|in|i|add|ci|cit|sit|it|ic|update|upgrade|udpate|up|dedupe|ddp|uninstall|unlink|un|remove|rm|r'
53+
BUN_VERBS='install|i|add|a|update|up|remove|rm'
54+
PNPM_VERBS='install|i|add|update|upgrade|up|dedupe|remove|rm|uninstall|un'
55+
YARN_VERBS='install|add|upgrade|up|dedupe|remove'
56+
# `npm --prefix <dir> install`, flags BEFORE the verb. Only the two flags that
57+
# themselves name a target directory are admitted, so this stays targeted rather
58+
# than swallowing a token run and matching `npm run install`.
59+
VERBS="(npm[[:space:]]+(${NPM_VERBS})|bun[[:space:]]+(${BUN_VERBS})|pnpm[[:space:]]+(${PNPM_VERBS})|yarn[[:space:]]+(${YARN_VERBS})|(npm|pnpm|yarn)[[:space:]]+(--prefix|-C)[[:space:]=]+[^[:space:]&|;]+[[:space:]]+(${NPM_VERBS}))"
60+
61+
# Bare `yarn` IS an install in yarn classic, and it needs its own anchored
62+
# pattern rather than a branch of VERBS. VERBS is wrapped in a generic
63+
# non-word-character prefix, which any space satisfies, so a bare-yarn branch
64+
# inside it matched the token ANYWHERE in the command: `which yarn`,
65+
# `rm -rf /tmp/yarn` and `git switch -c feat/yarn` all blocked. Here `yarn` must
66+
# sit in COMMAND position, at the start or straight after a `&&`, `||`, `;` or
67+
# `|`, and be followed only by flags.
68+
BARE_YARN='(^|[&|;])[[:space:]]*yarn([[:space:]]+-[^[:space:]]*)*[[:space:]]*($|[&|;])'
69+
70+
if ! printf '%s' "$cmd" | grep -Eq "(^|[^[:alnum:]_-])${VERBS}([^[:alnum:]_-]|\$)" \
71+
&& ! printf '%s' "$cmd" | grep -Eq "$BARE_YARN"; then
5572
exit 0
5673
fi
5774

@@ -61,6 +78,7 @@ fi
6178
# tokens that appear BEFORE the install verb and let them supersede the cwd, then
6279
# add any directory a package manager is pointed at explicitly.
6380
prefix=$(printf '%s' "$cmd" | sed -E "s/(^|[^[:alnum:]_-])${VERBS}([^[:alnum:]_-]|\$).*//")
81+
prefix=$(printf '%s' "$prefix" | sed -E "s/${BARE_YARN}.*//")
6482

6583
eff="$PWD"
6684
resolve_against_eff() {

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ The script discovers the `node_modules` set from the primary checkout rather tha
6666

6767
**Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `<primary>/node_modules/@webjsdev/core` is a relative symlink into `<primary>/packages/core` and resolving through the linked root lands there. Relative imports (`../../../src/x.js`) and the browser suite, which web-test-runner serves from the worktree, do use the worktree's own files. So linking 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 nothing else is lost) and then install, or repoint the individual `@webjsdev/<pkg>` entries at it. CI always builds from the branch, so it is unaffected either way.
6868

69-
**NEVER install while the `node_modules` symlink is standing (#1442).** This is the trap the two paragraphs above used to walk you into, and the damage lands on a checkout you are not working in, so the failure surfaces in someone else's session with nothing naming the cause. Measured on npm 11.19.0 and bun 1.3.14: `npm ci` DELETES the primary's whole `node_modules` through the link before any lifecycle script runs, `bun install` writes packages and `.bin` entries straight into the primary through it, and `npm install` silently replaces the link with a real tree, detaching the worktree from the shared source. No `preinstall` script can prevent any of it, because npm removes the symlink before `preinstall` runs, `npm ci` has already emptied the primary by then, and Bun runs it in time but ignores a non-zero exit. So the layers are: Claude Code BLOCKS the command through `.claude/hooks/block-install-in-linked-worktree.sh` (escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`), the root `preinstall` REPORTS it for every other tool without ever blocking, `npm run worktree:link` REPAIRS an already-damaged primary, and `npm run check:worktree-links` reports what it would repair without changing anything (escape hatch `WEBJS_NO_WORKTREE_REPAIR=1`). Tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`.
69+
**NEVER install while the `node_modules` symlink is standing (#1442).** This is the trap the two paragraphs above used to walk you into, and the damage lands on a checkout you are not working in, so the failure surfaces in someone else's session with nothing naming the cause. Measured on npm 11.19.0 and bun 1.3.14: `npm ci` DELETES the primary's whole `node_modules` through the link before any lifecycle script runs, `bun install` writes packages and `.bin` entries straight into the primary through it, and `npm install` silently replaces the link with a real tree, detaching the worktree from the shared source. No `preinstall` script can prevent any of it, because npm removes the symlink before `preinstall` runs, `npm ci` has already emptied the primary by then, and Bun runs it in time but ignores a non-zero exit. So the layers are: Claude Code BLOCKS the command through `.claude/hooks/block-install-in-linked-worktree.sh` (escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`), the root `preinstall` REPORTS it for every other tool without ever blocking, `npm run worktree:link` REPAIRS an already-damaged primary, and `npm run check:worktree-links` reports what it would repair without changing anything, exiting non-zero when there is work. `WEBJS_NO_WORKTREE_REPAIR=1` suppresses the repair WRITE, so it has no effect on `--check`, which never writes and always inspects. Tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`.
7070

7171
Note the `webjs doctor` / `webjs dev` remedy message suggests the root-only symlink. That advice is correct for a scaffolded APP worktree, which has no nested trees and no built `dist/`, and wrong only for this monorepo. It stays app-generic on purpose, because it ships in the published CLI and `webjs dev` prints it verbatim to someone whose app has none of this repo's scripts; it names `npm run worktree:link` only when it finds a package.json actually declaring that script, so in this repo you get the monorepo path and in a scaffolded app you do not.
7272

framework-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ So prevention lives one layer up, and the rest is repair:
165165
- **Block.** `.claude/hooks/block-install-in-linked-worktree.sh` is a `PreToolUse` (Bash) hook, the only layer that sees the state before the package manager starts. It refuses an install verb whose target directory has a symlinked `node_modules`, and stays narrow: `npm test`, `npm run <script>`, and `npx ...` all pass. Escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`.
166166
- **Report.** The root `preinstall` runs `scripts/warn-worktree-install.mjs`, which ALWAYS exits 0 and returns immediately unless `.git` is a FILE, so a normal clone and CI never see it. It names whichever of the three states it landed in and prints the repair.
167167
- **Repoint on teardown.** `.claude/hooks/cleanup-merged-worktree.sh` repoints any `<primary>/node_modules/@webjsdev/*` link targeting a worktree it is about to remove, before removing it.
168-
- **Repair on demand.** `npm run worktree:link` repairs the primary's `@webjsdev/*` scope: a dangling link, a link into a live foreign checkout, and an absolute in-primary link all become the relative form, and a DANGLING `.name-HASH` npm staging entry is dropped. A LIVE staging entry is left strictly alone. `npm run check:worktree-links` reports without writing and exits non-zero when there is work. Escape hatch `WEBJS_NO_WORKTREE_REPAIR=1`, which the `defaultPrimary()` test needs for the same reason it needs `WEBJS_NO_WORKTREE_SEED=1`: the repair pass sits ABOVE the primary-checkout guard by design, so it runs in both positions and would otherwise rewrite the real checkout during `npm test`.
168+
- **Repair on demand.** `npm run worktree:link` repairs the primary's `@webjsdev/*` scope: a dangling link, a link into a live foreign checkout, and an absolute in-primary link all become the relative form, and a DANGLING `.name-HASH` npm staging entry is dropped. A LIVE staging entry is left strictly alone. `npm run check:worktree-links` reports without writing and exits non-zero when there is work. Escape hatch `WEBJS_NO_WORKTREE_REPAIR=1` suppresses the repair WRITE only, so `--check` ignores it and still inspects; the `defaultPrimary()` test needs the hatch for the same reason it needs `WEBJS_NO_WORKTREE_SEED=1`: the repair pass sits ABOVE the primary-checkout guard by design, so it runs in both positions and would otherwise rewrite the real checkout during `npm test`.
169169
- **Detect.** `webjs doctor`'s `framework-links` check warns on a dangling or foreign `@webjsdev/core` link. `FRAMEWORK_RESOLVE` cannot see the foreign case, because a link into a live checkout resolves perfectly.
170170

171171
Regression tests: `test/hooks/block-install-in-linked-worktree.test.mjs`, `test/repo-health/warn-worktree-install.test.mjs`, `test/repo-health/link-worktree-deps.test.mjs`, `test/hooks/cleanup-merged-worktree.test.mjs`, `test/cli/doctor.test.mjs`.

scripts/link-worktree-deps.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,10 @@ if (!existsSync(join(primary, 'package.json'))) {
409409
// is why the `defaultPrimary()` test can run this against the real checkout
410410
// without mutating it, exactly as `WEBJS_NO_WORKTREE_SEED=1` does for seeding.
411411
let touched = 0;
412-
if (process.env.WEBJS_NO_WORKTREE_REPAIR === '1') {
412+
// The hatch suppresses the repair WRITE. `--check` never writes, so there is
413+
// nothing for it to suppress there, and skipping the inspection too would make
414+
// `check:worktree-links` exit 0 reporting a clean tree it never looked at.
415+
if (process.env.WEBJS_NO_WORKTREE_REPAIR === '1' && !CHECK) {
413416
console.log('[link-worktree-deps] framework-link repair skipped (WEBJS_NO_WORKTREE_REPAIR=1).');
414417
} else {
415418
const repair = repairPrimaryFrameworkLinks(primary, { check: CHECK });

test/hooks/block-install-in-linked-worktree.test.mjs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,53 @@ test('blocks every manager\'s install ALIAS, not just the canonical spelling', (
9696
} finally { rmSync(root, { recursive: true, force: true }); }
9797
});
9898

99+
test('blocks the HYPHENATED npm verbs, which the short aliases do not cover', () => {
100+
// The trailing word boundary excludes `-`, so listing `install` does not reach
101+
// `install-test`. Each hyphenated command needs its own entry.
102+
const { root, worktree } = makeLinkedPair();
103+
try {
104+
for (const cmd of ['npm install-test', 'npm install-ci-test', 'npm clean-install-test', 'npm clean-install', 'npm install-clean']) {
105+
assert.equal(runHook(cmd, worktree).status, 2, `expected block for \`${cmd}\``);
106+
}
107+
} finally { rmSync(root, { recursive: true, force: true }); }
108+
});
109+
110+
test('blocks the REMOVE verbs, which delete from the owning checkout', () => {
111+
const { root, worktree } = makeLinkedPair();
112+
try {
113+
for (const cmd of ['npm uninstall x', 'npm rm x', 'npm r x', 'bun rm x', 'pnpm rm x', 'yarn remove x', 'pnpm upgrade', 'pnpm dedupe', 'yarn dedupe']) {
114+
assert.equal(runHook(cmd, worktree).status, 2, `expected block for \`${cmd}\``);
115+
}
116+
} finally { rmSync(root, { recursive: true, force: true }); }
117+
});
118+
119+
test('bare `yarn` blocks only in COMMAND position, never as a trailing word', () => {
120+
// The bare-yarn branch first lived inside the generic VERBS pattern, whose
121+
// prefix any space satisfies, so it matched the token ANYWHERE and blocked
122+
// `which yarn`, `rm -rf /tmp/yarn` and `git switch -c feat/yarn`. That fires
123+
// on ordinary commands in a linked worktree, which is the mandated state here,
124+
// and a gate that cries wolf gets turned off.
125+
const { root, worktree } = makeLinkedPair();
126+
try {
127+
for (const cmd of ['yarn', 'yarn --frozen-lockfile', 'cd . && yarn']) {
128+
assert.equal(runHook(cmd, worktree).status, 2, `expected block for \`${cmd}\``);
129+
}
130+
for (const cmd of [
131+
'which yarn', 'command -v yarn', 'ls -la ~/.yarn', 'rm -rf /tmp/yarn',
132+
'git switch -c feat/yarn', 'cat README | grep yarn', 'echo yarn',
133+
'npm run build --workspace yarn',
134+
]) {
135+
assert.equal(runHook(cmd, worktree).status, 0, `expected allow for \`${cmd}\``);
136+
}
137+
} finally { rmSync(root, { recursive: true, force: true }); }
138+
});
139+
99140
test('the broadened alias table does not swallow non-install commands', () => {
100141
const { root, worktree } = makeLinkedPair();
101142
try {
102143
// `npm init` must not match `in` or `i`, and a bare `yarn test` must not
103144
// match the bare-yarn install branch.
104-
for (const cmd of ['npm init', 'npm init -y', 'yarn test', 'yarn run build', 'bun run dev', 'bun test', 'pnpm run build']) {
145+
for (const cmd of ['npm init', 'npm init -y', 'yarn test', 'yarn run build', 'bun run dev', 'bun test', 'pnpm run build', 'git rm x', 'rm -rf node_modules', 'npm run rm']) {
105146
assert.equal(runHook(cmd, worktree).status, 0, `expected allow for \`${cmd}\``);
106147
}
107148
} finally { rmSync(root, { recursive: true, force: true }); }

test/hooks/cleanup-merged-worktree.test.mjs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { test } from 'node:test';
1414
import assert from 'node:assert/strict';
1515
import { execFileSync, spawnSync } from 'node:child_process';
16-
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, chmodSync, symlinkSync, readlinkSync, lstatSync } from 'node:fs';
16+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, symlinkSync, readlinkSync, lstatSync } from 'node:fs';
1717
import { tmpdir } from 'node:os';
1818
import { join, dirname, resolve, delimiter } from 'node:path';
1919
import { fileURLToPath } from 'node:url';
@@ -282,3 +282,19 @@ test('leaves the primary links untouched when the worktree is KEPT', () => {
282282
assert.ok(existsSync(wt), 'a dirty worktree is kept');
283283
assert.equal(readlinkSync(entry), target, 'so its links are left pointing at it');
284284
});
285+
286+
test('repoint_primary_links never shadows the script-global `base` merge ref', () => {
287+
// `base` holds the merge base ref resolved once at the top and read by
288+
// `is_merged()`. bash `local` is dynamically scoped, so declaring `base` local
289+
// inside this function blanks the ref for anything the function calls. Nothing
290+
// calls a helper from there TODAY, which is why this is a source assertion
291+
// rather than a behavioural one: the defect is unreachable until someone adds
292+
// that call, and then it silently leaks the worktree this hook exists to remove.
293+
const src = readFileSync(HOOK, 'utf8');
294+
const fn = src.slice(src.indexOf('repoint_primary_links() {'));
295+
const locals = fn.slice(0, fn.indexOf('\n}')).match(/^\s*local .*$/gm) || [];
296+
assert.ok(locals.length > 0, 'the function still declares locals');
297+
for (const line of locals) {
298+
assert.doesNotMatch(line, /\blocal\b[^\n]*\bbase\b/, `shadows the global merge ref: ${line.trim()}`);
299+
}
300+
});

test/repo-health/link-worktree-deps.test.mjs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -562,22 +562,32 @@ describe('framework-link repair (#1442)', () => {
562562
} finally { rmSync(primary, { recursive: true, force: true }); }
563563
});
564564

565-
test('--check stays read-only even with WEBJS_NO_WORKTREE_REPAIR=1 set', () => {
566-
// The exit for `--check` used to live INSIDE the repair `else`, so this one
567-
// combination fell through to the linking loop and the seed step. The flag
568-
// pair AGENTS.md documents as changing nothing was the pair that wrote.
565+
test('--check with WEBJS_NO_WORKTREE_REPAIR=1 still INSPECTS, and still writes nothing', () => {
566+
// Two separate bugs met here. The exit for `--check` used to live INSIDE the
567+
// repair `else`, so this pair fell through to the linking loop and the seed
568+
// step, and the flag pair documented as changing nothing was the pair that
569+
// wrote. Moving the exit out fixed the write but left `touched` at its
570+
// initializer, so the run then exited 0 reporting a clean tree it had never
571+
// looked at. The hatch suppresses the repair WRITE; `--check` never writes,
572+
// so it must still inspect.
569573
const primary = makeRepairPrimary();
570574
const wt = makeWorktree();
571575
try {
576+
plant(primary, 'core', '/nonexistent/gone-worktree/packages/core');
572577
const r = spawnSync(process.execPath, [SCRIPT, primary, '--check'], {
573578
cwd: wt,
574579
encoding: 'utf8',
575580
env: cleanEnv({ WEBJS_NO_WORKTREE_REPAIR: '1' }),
576581
});
577-
assert.equal(r.status, 0);
578-
assert.match(r.stdout, /repair skipped/);
582+
assert.equal(r.status, 1, 'a broken link is still reported as work to do');
583+
assert.match(r.stdout, /would repoint/, 'it actually inspected');
579584
assert.doesNotMatch(r.stdout, /linked node_modules/, '--check must never link');
580585
assert.ok(!existsSync(join(wt, 'node_modules')), '--check must not create the symlink');
586+
assert.equal(
587+
readlinkSync(join(scopeOf(primary), 'core')),
588+
'/nonexistent/gone-worktree/packages/core',
589+
'--check must not repair either',
590+
);
581591
} finally { rmSync(primary, { recursive: true, force: true }); rmSync(wt, { recursive: true, force: true }); }
582592
});
583593

0 commit comments

Comments
 (0)