diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 3aaabb757..262843896 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -216,7 +216,7 @@ An over-limit body responds `413` without buffering the whole payload. ### Doctor severity gate -`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` depends on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries. +`webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries. ```jsonc { "webjs": { diff --git a/.claude/hooks/block-install-in-linked-worktree.sh b/.claude/hooks/block-install-in-linked-worktree.sh new file mode 100755 index 000000000..4dbfd680c --- /dev/null +++ b/.claude/hooks/block-install-in-linked-worktree.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# PreToolUse hook (matcher: Bash): BLOCK an install command aimed at a directory +# whose `node_modules` is a SYMLINK. In this repo that link points at the +# PRIMARY checkout's tree, so the install acts on a checkout you are not working +# in and the failure surfaces in someone else's session (#1442). +# +# Why a hook rather than a `preinstall` script. Measured on npm 11.19.0 and bun +# 1.3.14, no package-manager lifecycle hook can prevent the damage: +# npm install REPLACES the symlink with a real directory before `preinstall` +# runs, so a guard there never sees a symlink at all +# npm ci DELETES the symlink's target, the whole of the primary's +# node_modules, before `preinstall` runs +# bun install runs `preinstall` in time but IGNORES a non-zero exit +# A PreToolUse hook is the only layer that sees the state before the package +# manager starts. `scripts/warn-worktree-install.mjs` covers every other tool by +# reporting rather than blocking. +# +# ## How the matching works, and why it is shaped this way +# +# Recognising a package-manager invocation inside an arbitrary shell command is +# the hard part of this hook, and getting it wrong in either direction is +# expensive: a false NEGATIVE lets the corruption through, and a false POSITIVE +# fires on ordinary commands in a linked worktree, which is the mandated working +# state here, until someone turns the gate off. Four passes of review found a +# defect in each direction, so the matcher is built in explicit stages: +# +# 1. QUOTED SPANS ARE REMOVED FIRST. A manager invocation never has its own +# name inside quotes, while ordinary commands carry shell metacharacters +# there constantly. Splitting the raw string instead makes +# `git commit -m "fix: the link; npm install now blocks"` look like two +# commands, the second an install. +# 2. The remainder is split on `&&`, `||`, `;`, `|`, `(`, `)` and newlines. +# 3. Each segment is judged by its FIRST token, after leading env assignments +# and wrappers are stripped. A segment that does not START with a package +# manager is not an install, whatever else it contains. +# 4. Only inside a manager-led segment are the remaining tokens scanned, and +# the FIRST one recognised as either an install verb or a known safe verb +# decides. Anything unrecognised is skipped rather than assumed, so a flag +# VALUE (`npm -w packages/core install`) does not hide the verb behind it. +# +# Contract: exit 0 = allow, exit 2 = block (message on stderr). +# Escape hatch: WEBJS_NO_WORKTREE_INSTALL_GATE=1. + +if [ "${WEBJS_NO_WORKTREE_INSTALL_GATE:-0}" = "1" ]; then exit 0; fi +if ! command -v jq >/dev/null 2>&1; then exit 0; fi + +input=$(cat) +cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +if [ -z "$cmd" ]; then exit 0; fi + +# Verbs that WRITE to node_modules. Every manager's documented aliases, because +# a gate `bun i` walks past is worthless and Bun is the manager that writes +# THROUGH the link rather than replacing it. `link`, `rebuild` and `prune` are +# here for the same reason as the remove verbs: they all mutate the tree that +# the symlink points at. +# PER MANAGER, not one merged list. Merging them blocked `npm --workspace a run +# build`, because `a` is a BUN alias for `add`, and blocked `bun upgrade`, which +# upgrades the Bun BINARY and never touches node_modules. +# +# The one-letter aliases `a` and `r` are deliberately omitted from npm's list. +# They are rare as commands and common as flag VALUES, and the scan cannot tell +# the two apart, so admitting them blocks `npm -w a run build`. KNOWN GAP: `npm +# r ` and `npm a ` are therefore not blocked. That is the deliberate +# trade, because the false positive lands on an ordinary command while the false +# negative lands on a spelling almost nobody types, and the repair, report and +# doctor layers still catch the damage after the fact. +NPM_INSTALL='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|link|ln|rebuild|rb|prune' +BUN_INSTALL='install|i|add|a|remove|rm|link|unlink|update|pm' +PNPM_INSTALL='install|i|add|update|upgrade|up|dedupe|remove|rm|uninstall|un|link|unlink|prune|rebuild' +YARN_INSTALL='install|add|upgrade|up|dedupe|remove|link|unlink' +# Verbs that do NOT touch node_modules. Listed explicitly so the scan can STOP: +# without them, `npm run test -- --grep add` would keep scanning and hit `add`. +SAFE_VERBS='run|run-script|rum|urn|test|tst|t|start|stop|restart|exec|x|ls|list|la|ll|init|innit|create|publish|pack|version|view|v|info|show|why|ping|config|c|get|set|docs|home|repo|bugs|audit|fund|outdated|prefix|root|bin|whoami|token|team|org|access|star|unstar|search|s|se|find|help|doctor|explain|edit|deprecate|dist-tag|hook|login|logout|adduser|owner|profile|shrinkwrap|unpublish|completion|diff|query|sbom' +# `npm audit` reports and is safe; `npm audit fix` INSTALLS revised versions +# straight through the link, so it is matched ahead of the safe-verb scan. +AUDIT_FIX='(^|[[:space:]])audit([[:space:]]+-[^[:space:]]+)*[[:space:]]+fix([[:space:]]|$)' +# A GLOBAL install writes to the npm prefix, never through the local link, and +# `npm update -g webjsdev` is this repo's documented post-release step. +GLOBAL='(^|[[:space:]])(-g|--global)([[:space:]]|$)' + +# STAGE 1: neutralise quoted spans and drop heredoc bodies. +# +# A quoted span must keep its CONTENT (a quoted path is the ordinary defensive +# spelling of `cd "" && npm ci`, which is the arrival shape this hook +# exists for) while losing its power to look like a command boundary. So the +# quote characters are removed and only the SEPARATORS inside them are +# neutralised. Deleting the whole span instead loses the path and fails open. +# +# It is a character-by-character state machine rather than a pair of seds +# because quote nesting has to be tracked: an apostrophe inside a double-quoted +# string is literal, and a sed pass over `'...'` first would pair it with the +# next single quote in the line and swallow whatever sat between. +# +# A heredoc BODY is not commands. This repo's docs are full of `npm install` +# lines, and `cat > doc.md <<'EOF'` ... `EOF` must not read as an install. +scrubbed=$(printf '%s' "$cmd" | awk ' + function flushline(l) { print l } + BEGIN { heredoc = "" } + { + if (heredoc != "") { + line = $0 + sub(/[[:space:]]+$/, "", line) + if (line == heredoc) heredoc = "" + next + } + out = ""; inS = 0; inD = 0 + n = length($0) + for (i = 1; i <= n; i++) { + c = substr($0, i, 1) + if (!inD && c == "\047") { inS = !inS; continue } + if (!inS && c == "\042") { inD = !inD; continue } + if ((inS || inD) && (c == "&" || c == "|" || c == ";" || c == "(" || c == ")")) { out = out "\001"; continue } + out = out c + } + # A HERE-STRING is not a heredoc: `<<` and `cd -P ` both work. + while [ $# -gt 0 ]; do + case "$1" in --) shift; break ;; -*) shift ;; *) break ;; esac + done + d="${1:-}" + case "$d" in + '') ;; + '~') eff="$HOME" ;; + '~/'*) eff="$HOME/${d#'~/'}" ;; + /*) eff="$d" ;; + *) eff="$eff/$d" ;; + esac + continue ;; + esac + + # STAGE 4: only a manager-led segment can be an install. + case "$head_tok" in + npm) verbs="$NPM_INSTALL" ;; + bun) verbs="$BUN_INSTALL" ;; + pnpm) verbs="$PNPM_INSTALL" ;; + yarn|yarnpkg) verbs="$YARN_INSTALL" ;; + *) continue ;; + esac + shift + + # A global install never touches this tree. + printf '%s' "$seg" | grep -Eq "$GLOBAL" && continue + + verdict="" + if printf '%s' "$seg" | grep -Eq "$AUDIT_FIX"; then verdict="install"; fi + prefix_dir="" + pending_prefix=0 + while [ $# -gt 0 ]; do + tok="$1"; shift + case "$tok" in + --prefix=*|-C=*|--cwd=*|--dir=*) prefix_dir="${tok#*=}"; continue ;; + --prefix|-C|--cwd|--dir) pending_prefix=1; continue ;; + -*) continue ;; + esac + if [ "$pending_prefix" = "1" ]; then prefix_dir="$tok"; pending_prefix=0; continue; fi + # The first token recognised either way decides; anything else is a flag + # value or a package name and is skipped rather than assumed. + # Do NOT stop at the verb: `--prefix` may still be ahead of us, and + # `npm install --prefix ` run from the primary would otherwise be + # judged against the primary's own real node_modules and allowed. The FIRST + # verdict wins; later tokens are only mined for the prefix. + [ -n "$verdict" ] && continue + if printf '%s' "$tok" | grep -Eq "^(${verbs})$"; then verdict="install"; continue; fi + if printf '%s' "$tok" | grep -Eq "^(${SAFE_VERBS})$"; then verdict="safe"; continue; fi + done + + # A bare `yarn` (only flags, no verb) IS an install in yarn classic. + # A flags-only `yarn` IS an install in yarn classic, but `yarn --version` and + # `yarn --help` only print, so they must not be read as one. + if [ -z "$verdict" ]; then + case "$head_tok" in + yarn|yarnpkg) + if printf '%s' "$seg" | grep -Eq '(^|[[:space:]])(--version|-v|-V|--help|-h)([[:space:]]|$)'; then : + else verdict="install"; fi ;; + esac + fi + [ "$verdict" = "install" ] || continue + + target="$eff" + if [ -n "$prefix_dir" ]; then + case "$prefix_dir" in + '~') target="$HOME" ;; + '~/'*) target="$HOME/${prefix_dir#'~/'}" ;; + /*) target="$prefix_dir" ;; + *) target="$eff/$prefix_dir" ;; + esac + fi + break +done </dev/null || true) +for dir in "$target" "$top"; do + [ -n "$dir" ] || continue + hit="" + if [ -L "$dir/node_modules" ]; then + hit="$dir/node_modules" + else + # Depth 5, NOT 4: the link script walks directories to depth 4 and plants + # /node_modules, one level deeper. packages/ui/packages/registry is the + # live shape. These two depths must agree or the gate is blind to links the + # script itself creates. + hit=$(find "$dir" -maxdepth 5 \( -type d \( -name .git -o -name node_modules \) \) -prune -o -type l -name node_modules -print 2>/dev/null | head -1) + fi + [ -n "$hit" ] || continue + owner=$(cd "$(dirname "$hit")" 2>/dev/null && cd "$(readlink "$(basename "$hit")")" 2>/dev/null && pwd -P) || owner="the checkout it points at" + { + echo "BLOCKED: this command installs into $dir, where $hit is a SYMLINK at $owner." + echo "An install through that link damages the checkout that OWNS the tree, not this one:" + echo " npm ci DELETES the linked tree outright, before any lifecycle script can run" + echo " bun install writes packages and .bin entries straight through the link" + echo " npm install silently REPLACES a root link with a real tree, detaching this worktree" + echo "A remove verb (npm rm, bun remove) deletes from that same owning checkout." + echo "Safe alternatives:" + echo " npm run worktree:link links a fresh worktree; it never installs" + echo " a real install with NO symlink in the way. The link script plants NESTED" + echo " node_modules links too (packages/server, website, ...), so remove them ALL first:" + echo " find . -maxdepth 5 -type l -name node_modules -delete" + echo " (they are only links, nothing else is lost), or install in the PRIMARY checkout." + echo "A GLOBAL install (-g) is not affected by this and is never blocked." + echo "Escape hatch for a deliberate exception: WEBJS_NO_WORKTREE_INSTALL_GATE=1." + } >&2 + exit 2 +done + +exit 0 diff --git a/.claude/hooks/cleanup-merged-worktree.sh b/.claude/hooks/cleanup-merged-worktree.sh index a57c0d167..6ca3a5a95 100755 --- a/.claude/hooks/cleanup-merged-worktree.sh +++ b/.claude/hooks/cleanup-merged-worktree.sh @@ -19,9 +19,14 @@ # Anything with uncommitted or unpushed-looking work is KEPT and reported, so # the hook can never destroy in-flight work. # +# Before removing one, it repoints any `/node_modules/@webjsdev/*` link +# that targets that worktree back at the primary's own packages (#1442), so a +# removal cannot leave the primary resolving into a directory that is gone. +# # It never blocks the tool (always exits 0) and reports what it did back to the # model via hookSpecificOutput.additionalContext. Disable with -# WEBJS_NO_WORKTREE_CLEANUP=1. +# WEBJS_NO_WORKTREE_CLEANUP=1, which disables the repoint along with everything +# else, since it is the same teardown. # # Rule: AGENTS.md "One task per git worktree" + the webjs-start-work skill. @@ -93,8 +98,51 @@ is_clean() { [ -z "$dirty" ] } +# #1442: the primary may hold `@webjsdev/*` links pointing INTO a worktree we +# are about to delete. Repoint them back at the primary's own packages while the +# target still exists, so the removal cannot leave a dangling link behind. It is +# scoped to links targeting THIS worktree; the general sweep belongs to +# `npm run worktree:link`, which you run deliberately. +repoint_primary_links() { + # NOT `base`: that name holds the script-global merge-base ref that + # `is_merged()` reads, and bash `local` is dynamically scoped, so shadowing + # it here would blank the ref for anything this function ever calls. + local wt="$1" scope wtreal entry_name abs rel + scope="$primary/node_modules/@webjsdev" + [ -d "$scope" ] || return 0 + wtreal=$(cd "$wt" 2>/dev/null && pwd -P) || return 0 + + # Dot entries too: npm's `.name-HASH` staging links land in the same scope. + for e in "$scope"/* "$scope"/.[!.]*; do + # An unmatched glob arrives literally, and is not a symlink, so this also + # absorbs an empty scope. + [ -L "$e" ] || continue + entry_name=$(basename "$e") + # Resolve without `readlink -f`, which is GNU-only. + abs=$(cd "$(dirname "$e")" 2>/dev/null && cd "$(readlink "$e")" 2>/dev/null && pwd -P) || continue + case "$abs" in + "$wtreal"/*) ;; + *) continue ;; + esac + rel="${abs#"$wtreal"/}" + case "$entry_name" in + .*-????????) + rm -f "$e" && relinked+=("dropped staging entry @webjsdev/$entry_name") + ;; + *) + if [ -e "$primary/$rel" ]; then + ln -sfn "../../$rel" "$e" && relinked+=("repointed @webjsdev/$entry_name -> ../../$rel") + else + relinked+=("KEPT @webjsdev/$entry_name (points into $wt, but $rel is missing in the primary)") + fi + ;; + esac + done +} + removed=() kept=() +relinked=() # Parse worktree path + branch pairs. wt="" @@ -116,6 +164,7 @@ while IFS= read -r line; do if ! is_merged "$br"; then kept+=("$wt (branch $br not merged yet)"); wt=""; continue fi + repoint_primary_links "$wt" if git worktree remove --force "$wt" >/dev/null 2>&1; then removed+=("$wt ($br)") else @@ -129,11 +178,12 @@ done < <(git worktree list --porcelain 2>/dev/null) git worktree prune >/dev/null 2>&1 || true # Report nothing if there was nothing to do. -if [ "${#removed[@]}" -eq 0 ] && [ "${#kept[@]}" -eq 0 ]; then exit 0; fi +if [ "${#removed[@]}" -eq 0 ] && [ "${#kept[@]}" -eq 0 ] && [ "${#relinked[@]}" -eq 0 ]; then exit 0; fi msg="Worktree cleanup after \`gh pr merge\`:" for r in "${removed[@]:-}"; do [ -n "$r" ] && msg="$msg"$'\n'" removed $r (merged, clean)"; done for k in "${kept[@]:-}"; do [ -n "$k" ] && msg="$msg"$'\n'" kept $k"; done +for l in "${relinked[@]:-}"; do [ -n "$l" ] && msg="$msg"$'\n'" $l"; done jq -n --arg ctx "$msg" '{ hookSpecificOutput: { diff --git a/.claude/settings.json b/.claude/settings.json index 7a0487212..fb23dbafa 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -61,6 +61,10 @@ { "type": "command", "command": ".claude/hooks/require-bun-parity-with-runtime-src.sh" + }, + { + "type": "command", + "command": ".claude/hooks/block-install-in-linked-worktree.sh" } ] } diff --git a/AGENTS.md b/AGENTS.md index 222d886c8..12655a2f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ cd ../- # do ALL work for the task here **A fresh worktree has NO `node_modules`** (git worktrees do not copy it), so running an app from one (`webjs dev` / `webjs start`, the test runner, a scaffolded app) fails to resolve `@webjsdev/*` until you install or link it. `webjs doctor` warns for this exact case (#954) and `webjs dev` / `webjs start` print the cause + remedy instead of a raw `ERR_MODULE_NOT_FOUND`. -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). **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Three things beyond the root tree are needed, and the script handles all three: +Fix it with **`npm run worktree:link`** from inside the worktree. A full `npm install` there is NOT an alternative once the link is standing, and the reason is below. **Do NOT hand-symlink only the root `node_modules`.** That is the obvious move and it produces a worktree that looks set up and then fails dozens of tests for reasons that point nowhere near the real cause. Three things beyond the root tree are needed, and the script handles all three: - **Every NESTED `node_modules`, not just the root.** npm hoists what it can, but a workspace whose range conflicts with the hoisted copy keeps its own nested tree. `packages/server` is the live example: the root carries `ws@7` (hoisted for another dependent) while `packages/server` declares `^8.20.0` and keeps `ws@8` nested. Link only the root and `WebSocketServer`, a ws@8-only named export, resolves up to ws@7 and throws at module load. That single miss failed hundreds of assertions across the server, integration, and smoke suites, none of them naming ws. - **`packages/core/dist`**, which is built rather than committed, so a fresh worktree has none and every test importing the built bundle fails to resolve it. @@ -64,13 +64,15 @@ Fix it with **`npm run worktree:link`** from inside the worktree (or a full `npm The script discovers the `node_modules` set from the primary checkout rather than hardcoding a list (it changes whenever a package gains a nested tree), never overwrites an existing path, and never creates a dangling link, so it is safe to re-run and safe in a worktree where you already ran a real `npm install`. The seed step keeps the same contract: it only ever applies pending migrations and inserts demo rows that are not there, so a database with rows in it is left untouched. -**Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `/node_modules/@webjsdev/core` is a relative symlink into `/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, run a real `npm install` in the worktree, or repoint the individual `@webjsdev/` entries at it. CI always builds from the branch, so it is unaffected either way. +**Know what this does NOT give you.** The worktree then runs the PRIMARY checkout's framework source through every bare `@webjsdev/*` specifier, because `/node_modules/@webjsdev/core` is a relative symlink into `/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 EVERY `node_modules` SYMLINK first, not only the root one, because the link script plants one per workspace that carries its own tree (`find . -maxdepth 5 -type l -name node_modules -delete`, they are only links and nothing else is lost) and then install, or repoint the individual `@webjsdev/` entries at it. CI always builds from the branch, so it is unaffected either way. -Note the `webjs doctor` / `webjs dev` remedy message still 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. +**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`, which covers every manager's install aliases plus the REMOVE verbs (`npm rm` in a linked worktree deletes from the owning checkout), judges a COMMAND rather than a token so `git commit -m "fix: npm install ..."` and `grep -rn "npm ci"` are unaffected, and never blocks a GLOBAL `-g` install such as the post-release `npm update -g webjsdev` (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`. + +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. Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. There is NO lone-agent exception: every task cuts a worktree, and the primary checkout stays an untouched mirror of main (tracked-file edits there are hook-blocked). The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom. -**Cleanup is automatic after a merge.** The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook fires after any `gh pr merge` and removes each linked worktree whose branch is merged AND whose tree is clean, so a merged branch's worktree never leaks (accumulated stale worktrees are exactly what it prevents). It is conservative: it KEEPS anything with uncommitted changes, an unmerged branch, or the worktree you ran the merge from (you cannot remove your current directory, so `cd` out and `git worktree remove` it yourself), and never touches the primary checkout. Disable with `WEBJS_NO_WORKTREE_CLEANUP=1`. Test: `test/hooks/cleanup-merged-worktree.test.mjs`. +**Cleanup is automatic after a merge.** The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook fires after any `gh pr merge` and removes each linked worktree whose branch is merged AND whose tree is clean, so a merged branch's worktree never leaks (accumulated stale worktrees are exactly what it prevents). It is conservative: it KEEPS anything with uncommitted changes, an unmerged branch, or the worktree you ran the merge from (you cannot remove your current directory, so `cd` out and `git worktree remove` it yourself), and never touches the primary checkout. Before removing one it repoints any `/node_modules/@webjsdev/*` link that targets that worktree back at the primary's own packages and drops an npm staging entry that would be left dangling (#1442), so a removal can never leave the primary resolving into a directory that is gone; a link it cannot correct is kept and reported rather than repointed at a missing path. Disable with `WEBJS_NO_WORKTREE_CLEANUP=1`, which disables the repoint along with everything else. Test: `test/hooks/cleanup-merged-worktree.test.mjs`. ### Skills are routed deterministically, never skipped @@ -571,7 +573,7 @@ webjs check [--rules] [--json] # correctness validator (report-only, no auto webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping webjs elision [--json] [--verify] [--routes ] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit -webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) +webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a `framework-links` check that warns when the `@webjsdev/core` entry is a symlink that DANGLES or resolves outside the checkout owning its `node_modules`, which is what an install inside a linked worktree leaves behind and what a plain resolve probe cannot see, #1442; a warning when a route module writes a `` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257) webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258) webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975) webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1 diff --git a/framework-dev.md b/framework-dev.md index af939146b..251e6cb51 100644 --- a/framework-dev.md +++ b/framework-dev.md @@ -146,6 +146,32 @@ It costs about two and a half seconds on a cold worktree and nothing once there It runs below the primary-checkout guard, so `worktree:link` in the primary stays a no-op. That guard is not what keeps seeding out of the test suite, though. The `defaultPrimary()` repo-health test runs the script bare against its own cwd, and from a linked worktree (the mandated workflow) the guard does not fire, so the script would seed that worktree's blog database as a side effect of `npm test`, racing `test/integration/blog-http.test.mjs` reading the same file in parallel. That test therefore sets `WEBJS_NO_WORKTREE_SEED=1` explicitly, and the helpers in that file strip the variable from the ambient env so an exported opt-out cannot invert the seed assertions. +### Never install through a linked worktree's `node_modules` (#1442) + +`npm run worktree:link` symlinks a worktree's `node_modules` at the primary checkout's, which makes the worktree runnable and 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 a session that did nothing wrong. + +Measured on npm 11.19.0 and bun 1.3.14: + +| Command, in a linked worktree | What actually happens | +|---|---| +| `npm install` | REPLACES the `node_modules` symlink with a real directory and builds a full tree there. The primary is untouched, but the worktree silently detaches and no longer runs the primary's framework source. | +| `npm ci` | DELETES the symlink's TARGET, meaning the whole of `/node_modules`, then builds a real tree locally. The primary and every other linked worktree break at once. | +| `bun add` / `bun install` | KEEPS the symlink and writes THROUGH it. New packages and `.bin` entries land in `/node_modules`. | + +**A `preinstall` script cannot prevent any of this.** Under `npm install` it runs with `node_modules` already a real directory, because npm replaced the symlink before the script started. Under `npm ci` it runs with the primary already emptied, because npm deletes the link's target first. Under Bun it does run in time and does see the symlink, but a non-zero exit does not stop Bun (measured: `bun install` exited 0 with a `preinstall` that exited 2). A non-zero `preinstall` DOES block `npm install` and `npm ci`, exit code propagated verbatim, so a buggy guard there would red every CI job for no protection at all. + +So prevention lives one layer up, and the rest is repair: + +- **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`, at the root OR nested (the link script plants one per workspace carrying its own tree, so a worktree whose root link was removed still holds nested links the install writes through; remove them all with `find . -maxdepth 5 -type l -name node_modules -delete`), covering every manager's documented aliases (`bun i` matters most, since Bun writes THROUGH the link), the REMOVE verbs, and `link` / `rebuild` / `prune` / `audit fix`, all of which mutate the tree the symlink points at. The tables are per manager, so `bun upgrade`, which upgrades the Bun binary, stays allowed while `pnpm upgrade` does not. Escape hatch `WEBJS_NO_WORKTREE_INSTALL_GATE=1`. + + It judges a COMMAND, never a token, in four stages. Quoted spans are neutralised FIRST, keeping their content but stripping the separators inside them, and heredoc bodies are dropped. The remainder is split on `&&`, `||`, `;`, `|`, `(`, `)` and newlines. Each segment is then judged by its FIRST token, after leading env assignments and wrappers like `sudo` are stripped. Only inside a manager-led segment are the remaining tokens scanned, for the first one recognised as either an install verb or a known safe verb, so a flag sitting before the verb does not hide it. Matching the manager token anywhere in the line is the obvious shortcut and it is badly wrong: it blocks `git commit -m "fix: npm install ..."`, `grep -rn "npm ci" AGENTS.md` and `git log --grep "npm install"`. A linked worktree is the mandated working state here, so that fires on ordinary commands constantly, and a gate that cries wolf is a gate someone turns off. `npm test`, `npm run