@webpieces/ai-hook-rules 0.4.667 → 0.4.669

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/bin/l0-allowlist.d.ts +7 -1
  3. package/src/bin/l0-allowlist.js +26 -6
  4. package/src/bin/l0-allowlist.js.map +1 -1
  5. package/src/bin/shim-drift-fix.js +15 -1
  6. package/src/bin/shim-drift-fix.js.map +1 -1
  7. package/src/core/l0-matrix.js +6 -0
  8. package/src/core/l0-matrix.js.map +1 -1
  9. package/src/core/l1-rows.js +1 -1
  10. package/src/core/l1-rows.js.map +1 -1
  11. package/src/core/l2-doc.js +1 -1
  12. package/src/core/l2-doc.js.map +1 -1
  13. package/src/core/l2-rows.js +2 -2
  14. package/src/core/l2-rows.js.map +1 -1
  15. package/src/core/rules/branch-switch-scan.d.ts +5 -0
  16. package/src/core/rules/branch-switch-scan.js +5 -0
  17. package/src/core/rules/branch-switch-scan.js.map +1 -1
  18. package/src/core/rules/catch-error-pattern.d.ts +25 -0
  19. package/src/core/rules/catch-error-pattern.js +54 -16
  20. package/src/core/rules/catch-error-pattern.js.map +1 -1
  21. package/src/core/rules/merged-branch-bash-guard.js +1 -1
  22. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  23. package/src/core/rules/merged-branch-message.js +4 -3
  24. package/src/core/rules/merged-branch-message.js.map +1 -1
  25. package/src/core/rules/redirect-how-to-merge-main.js +9 -3
  26. package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
  27. package/src/core/rules/stale-main-bash-guard.d.ts +5 -2
  28. package/src/core/rules/stale-main-bash-guard.js +15 -10
  29. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  30. package/src/core/rules/tree-recovery.d.ts +26 -5
  31. package/src/core/rules/tree-recovery.js +33 -12
  32. package/src/core/rules/tree-recovery.js.map +1 -1
  33. package/src/core/runner.js +2 -0
  34. package/src/core/runner.js.map +1 -1
  35. package/src/core/version-sync.d.ts +28 -7
  36. package/src/core/version-sync.js +56 -22
  37. package/src/core/version-sync.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"l2-rows.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-rows.ts"],"names":[],"mappings":"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","sourcesContent":["// ---------------------------------------------------------------------------\n// L2 — the BRANCH-STATE layer, as data.\n//\n// L2 answers one question: *may I work here, and is what I read current?* Drawn as a decision matrix\n// that is TEN ordered rows plus one terminal fail-open row, first match wins.\n//\n// This module holds those rows, l2-doc.ts renders them into guards/L2-branch-state.md, and a unit test\n// (l2-matrix.spec.ts) locks that file byte-identical to the renderer — the same mechanism that already\n// makes L0's fault table and L1's location table undriftable. Before this existed the L2 doc was 100%\n// hand-written and said so: *\"Until that lands this text is hand-written and can drift.\"*\n//\n// ## HOW L2 JOINS TO THE ROWS TODAY — read this before assuming it works like L1\n//\n// L1 DISPATCHES from its array: `runner.l1LocationBlock` takes the first matching row and switches on\n// its `blockId`, so deleting a row deletes a block. L2 does NOT, and pretending otherwise would be the\n// drift this table exists to remove. The four L2 guard classes each own their own ladder, and those\n// ladders diverge on purpose (guards/L2-branch-state.md, \"Deliberate divergence\": the two Bash guards\n// differ in polarity, quantifier and empty-command handling all at once, so no single parameterised\n// function serves both). Unifying them is where a wrong edit turns an allow into a session-wedging\n// block, so it is deliberately not attempted here.\n//\n// What L2 does instead is a REASON→ROW join. Every exit of every L2 guard already carries a stable\n// reason string into the decision log; `L2_ROW_FOR_REASON` maps each of those to the row it is an\n// instance of, the guards stamp that number as `row=`, and l2-matrix.spec.ts asserts the map is\n// EXHAUSTIVE against the guard sources — a new reason with no row fails the build. So `row=8` in\n// `.webpieces/logs/L2-decisions` opens guards/L2-branch-state.md at row 8 and reads the state, the cure\n// and the tools that row covers, exactly as `row=5` already does for L1.\n//\n// The rows the guards cannot yet honour are named in the doc's \"Not done\" section rather than quietly\n// rendered as if they were live. That section is generated from NOT_DONE below, so it cannot rot.\n//\n// This module is deliberately import-free at runtime, so `pnpm guards:generate` can load it without the\n// package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n/** Which tools a row covers. `B` Bash · `R` Read · `E` Write/Edit. */\nexport type L2Tool = 'B' | 'R' | 'E';\n\n/** What L2 does with a row — the same action codebook every layer reports in (GUARD_MATRIX.md). */\nexport type L2ActionKind = 'allow' | 'exempt' | 'block' | 'fail-open';\n\n/**\n * The TERMINAL fail-open row, and the one number in this table that is not from the 1-10 design.\n *\n * Everything in rows 6-10 needs the main-sync cache, and the cache is written by a fire-and-forget\n * refresher that populates it for the NEXT call — so the first tool call of every session has none.\n * \"Stop here and ALLOW\" was written as a DIVIDER in the design table, i.e. as prose between two blocks\n * of rows. Prose cannot be stamped into a log line, and this is the single most frequently taken exit\n * in the whole layer (every session's first call, every unreadable branch, every unreachable forge), so\n * it is a row with a number like any other.\n *\n * It is 11 rather than 6-with-a-renumber because row numbers are IDENTITY here: they are printed in the\n * doc and logged as `row=`, so shifting 6-10 down would silently re-point every reference. The doc\n * prints it in its true position, between rows 5 and 6, with its number shown — same treatment L1 gives\n * row 8, which is printed third and numbered 8.\n */\nexport const L2_FAIL_OPEN_ROW = 11;\n\n/** The `act` cell: the doc's literal label, plus the machine-readable kind behind it. */\nexport class L2Action {\n constructor(readonly label: string, readonly kind: L2ActionKind) {}\n}\n\nexport const L2_ALLOW = new L2Action('1 allow', 'allow');\nexport const L2_EXEMPT = new L2Action('2 exempt', 'exempt');\nexport const L2_BLOCK = new L2Action('4 block', 'block');\nexport const L2_FAIL_OPEN = new L2Action('1 allow (fail-open)', 'fail-open');\n\n/**\n * One row of the \"L2 use cases\" table: what you SEE, the state it puts you in, the verdict, the fix.\n *\n * THE POINT OF THIS CLASS is that a use case is added HERE, in code, beside the row it exercises — not\n * into a hand-written doc section that drifts. When a new situation comes up in a session, it becomes\n * one more `new L2UseCase(...)` on the row that judged it, `pnpm guards:generate` re-renders the doc,\n * and the byte-lock spec fails if anyone edits the rendered table instead.\n *\n * The four text fields are rendered VERBATIM. `reason` is the ENFORCEMENT half and is never rendered:\n * it is the exact `reason` string the guard logs for this case, so a spec can push it back through\n * `l2RowForReason` and assert it lands on the row this use case is filed under. That closes the loop\n * the L2 decision log opens — `row=` in the trail, this table on the page, one join between them.\n *\n * `reason` is REQUIRED, and a case that exercises something which is not an L2 row exit says so with\n * `NO_ROW_EXIT` rather than by omitting the argument. An optional field would make opting OUT of the\n * only real enforcement here the shortest thing to type and impossible to grep — the widening-by-absence\n * shape CLAUDE.md rejects. `grep NO_ROW_EXIT` now lists every unenforced case.\n */\nexport class L2UseCase {\n // eslint-disable-next-line @typescript-eslint/max-params -- four verbatim doc cells plus the reason behind them\n constructor(\n readonly num: number,\n readonly symptom: string,\n readonly state: string,\n readonly verdict: string,\n readonly fix: string,\n readonly reason: string,\n ) {}\n}\n\n/**\n * The `reason` for a use case that is NOT an L2 row exit, and so has nothing to join back to.\n *\n * The only legitimate case today is row 3: merge-in-progress is L4's state, and L2 exempts it without\n * logging a reason of its own. Named rather than absent, so \"this case is not enforced\" is a value in\n * the table you can grep for instead of a missing argument nobody notices.\n */\nexport const NO_ROW_EXIT = 'NO_ROW_EXIT (not an L2 row exit — another layer owns this state)';\n\n/**\n * One row of L2's decision table.\n *\n * `cure` is rendered verbatim into the doc and is LITERAL by policy: L0's cure-reachability discipline\n * says a message pointing at documentation for its own remedy cannot be tested, and it caught a fault\n * prescribing a bin that had been renamed away. `—` is the only legal non-command cure, and only on a\n * row that allows.\n */\nexport class L2Row {\n // eslint-disable-next-line @typescript-eslint/max-params -- the five cells of one doc row plus its use cases\n constructor(\n readonly num: number,\n readonly tools: readonly L2Tool[],\n /** The `state` cell, verbatim. */\n readonly state: string,\n readonly action: L2Action,\n /** The `cure` cell, verbatim. `—` when the row allows. */\n readonly cure: string,\n /**\n * The observed situations this row judges. Rendered as the \"L2 use cases\" table.\n *\n * A NON-EMPTY tuple, and required: a row nobody has ever seen fire is either dead or\n * undocumented, and both are worth knowing. Expressing that in the TYPE rather than as a\n * runtime assertion is the JwtRoles pattern — the invariant is enforced at the moment the row\n * is written, which is the only moment that changes what somebody types.\n */\n readonly useCases: readonly [L2UseCase, ...L2UseCase[]],\n ) {}\n\n /** `B R E`, the doc's own spelling of the tool cell. */\n toolCell(): string {\n return this.tools.join(' ');\n }\n}\n\n/**\n * THE ELEVEN L2 ROWS, in first-match-wins order.\n *\n * Rows 1-5 need NO cache and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a marker-file\n * scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.\n *\n * THE ORDER OF ROW 5 IS THE MOST LOAD-BEARING THING IN THIS TABLE. Put \"on main\" BELOW the divider and\n * writes on `main` are permitted for the whole first call of every session — and permanently in a\n * multi-worktree repo, where another tree may hold the cache lock indefinitely.\n *\n * `B` tracks `E` everywhere; `R` is judged separately in exactly ONE place, rows 6/7 on `main`. A Read\n * names exactly one file so the guard can evaluate it precisely; a Bash command is opaque and gets the\n * conservative answer. Reading a CURRENT `main` is fine — the problem is that `main` is almost always\n * behind.\n */\nexport const L2_ROWS: readonly L2Row[] = [\n new L2Row(1, ['B', 'R', 'E'], 'on the **global allowlist** (inert command, or a universal cure such as reading/editing `webpieces.config.json`)', L2_ALLOW, '—', [\n new L2UseCase(1,\n 'You are blocked by some other L2 row, and need to turn the policy off to get anything done',\n 'any state — this row is ahead of every block',\n 'ALLOW: reading and editing `webpieces.config.json` is never blocked, so the mode-OFF cure is always reachable',\n 'Edit `webpieces.config.json` → `hookGuards` → `branch-state-guard` → `\"mode\": \"OFF\"`',\n 'webpieces-config-read (escape hatch)'),\n new L2UseCase(2,\n 'A Write to `webpieces.config.json` while on `main`, which row 5 would otherwise block',\n 'on `main`, editing the one file that can disable the guard',\n 'ALLOW: the hook adapter bypasses feature-branch-guard for this path before any guard runs',\n 'None needed — the edit proceeds',\n 'config-bypass (feature-branch-guard skipped)'),\n ]),\n new L2Row(2, ['B'], 'bare `git checkout main`, with no `git pull` chained into the same command', L2_BLOCK, '`git checkout main && git pull origin main`', [\n new L2UseCase(3,\n '`git checkout main` after a merge, to start the next piece of work',\n 'about to land on whatever local `main` you last had — 157 commits behind, in the incident',\n 'BLOCK: decided from command TEXT alone, before the checkout, because the only `main` this could measure is the one it is about to leave',\n '`git checkout main && git pull origin main` — the pull must be in the SAME command',\n 'bare checkout of main ('),\n new L2UseCase(4,\n 'The same command inside a linked worktree, where `git checkout main` fatals anyway',\n 'linked worktree — `main` is already checked out in the primary clone',\n 'BLOCK, and the message prints the worktree form rather than a cure git would refuse',\n '`git fetch origin main`, then work off `origin/main`',\n 'bare checkout of main ('),\n ]),\n new L2Row(3, ['B', 'R', 'E'], '**merge in progress** — L4 owns this state', L2_EXEMPT, 'finish the merge: `pnpm wp-finish-upsert-pr`', [\n new L2UseCase(5,\n 'Reading and editing conflicted files during a 3-point merge, on a branch row 9 would block',\n 'merge markers on disk — `pnpm wp-start-update` has run and not finished',\n 'EXEMPT: everything is permitted, which is exactly what lets row 9 be strict',\n 'Resolve the conflicts, then `pnpm wp-finish-upsert-pr`',\n NO_ROW_EXIT),\n ]),\n new L2Row(4, ['B'], 'on the **skip list** — it gets you OUT, or tells you where you are', L2_ALLOW, '—', [\n new L2UseCase(6,\n '`git status` / `gh pr view` while blocked, to work out where you are',\n 'any state — orientation is never \"working here\"',\n 'ALLOW: metadata tells you where you are without putting stale file CONTENT in context',\n 'None needed',\n 'not-a-content-read (cure/build/metadata)'),\n new L2UseCase(7,\n '`git stash` when `git checkout -b <new> origin/main` refuses because `origin/main` touched the same files you edited',\n 'on a stale `main` or a merged branch, dirty tree, with an overlapping upstream change',\n 'ALLOW: the cure for the row that blocked you must itself never be blocked — and this is the residual step that makes rows 6 and 8 safe to block on a dirty tree',\n 'None needed — then re-run the checkout and `git stash pop`',\n 'not-a-content-read (cure/build/metadata)'),\n new L2UseCase(8,\n '`pnpm wp-start-upsert-pr` on a branch whose fork point is broken',\n 'row 9 state, running the tool row 9 prescribes',\n 'ALLOW: every `wp-*` bin is on the skip list, so no row can block its own remedy',\n 'None needed',\n 'merged-branch recovery/inspection (allowlisted)'),\n ]),\n new L2Row(5, ['B', 'E'], 'on `main`', L2_BLOCK, '`git checkout -b <new> origin/main`', [\n new L2UseCase(9,\n 'An Edit or Write to any tracked file while `git rev-parse --abbrev-ref HEAD` says `main`',\n 'on `main`, any freshness',\n 'BLOCK: decided by one `git rev-parse`, with NO cache read, so it fires on the first tool call of the session',\n '`git checkout -b <new> origin/main` — uncommitted work comes with you',\n 'on-main'),\n new L2UseCase(10,\n 'A Bash command that WRITES tracked files as a side effect — `npx expo install`, a formatter, codegen, `sed -i`, a `>` redirect',\n 'on `main`, and the write is incidental to a command whose stated purpose is something else',\n 'BLOCK: default-DENY on `main` plus row 4\\'s skip list, so a command nobody thought to enumerate is caught by not being on the list — which is the only shape that could have caught this one',\n '`git checkout -b <new> origin/main` BEFORE running anything that may write',\n 'on-main'),\n new L2UseCase(24,\n 'A build or a test run on a `main` that is perfectly up to date',\n 'on `main`, current — no staleness anywhere',\n 'BLOCK: freshness is not the question. `main` is not a place to work even when current, and the cure is a new branch, not a pull',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n new L2UseCase(16,\n 'Read is blocked, so the session reaches for `cat`, `grep` and `ls` instead — and describes a CI workflow set missing a whole workflow that existed upstream',\n 'the SIDE DOOR: same tree, different tool',\n 'BLOCK. This case used to be judged by row 6 (a stale-content blocklist on the Bash side); row 5 now subsumes it, because being on `main` is already the finding and no enumeration of readers is needed. The log used to read \"read-stale-guard handled\", which is worse than no guard — it looks covered',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n new L2UseCase(25,\n 'The FIRST command of a session, on `main`, before any cache exists',\n 'on `main`, cache absent — row 11 would fail open',\n 'BLOCK anyway: row 5 is ABOVE the cache divider and reads only `git rev-parse`, so it is armed on call #1. This is the case the cache-gated version could never catch',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n ]),\n new L2Row(L2_FAIL_OPEN_ROW, ['B', 'R', 'E'], '**the state could not be established** — branch undeterminable, no cache yet, the cache holds another branch, `origin/main` unknown, or the forge unreachable', L2_FAIL_OPEN, '— (nothing to fix; the refresher populates the cache for the next call)', [\n new L2UseCase(11,\n 'The very first tool call of a session is allowed even on a badly stale `main`',\n 'no cache — the refresher is fire-and-forget and populates it for the NEXT call',\n 'ALLOW (fail-open), logged as `ALLOW_FAIL_OPEN` so abstentions stay countable',\n 'None — the second call is judged normally',\n 'no-sync-cache'),\n new L2UseCase(12,\n 'Guards quietly stand down on a plane, or when `gh` is unauthenticated or rate-limited',\n 'the forge could not be asked whether the PR is merged',\n 'ALLOW (fail-open) logged as `no-forge` — distinct from \"asked, and it is not merged\", which used to look identical in the trail',\n 'None — restore network/`gh auth` to re-arm the merged-branch policy',\n 'no-forge'),\n new L2UseCase(14,\n 'Mid-rebase, every guard abstains',\n 'detached HEAD — there is no branch name to judge',\n 'ALLOW (fail-open), logged LOUDLY when the branch is unresolvable rather than merely detached',\n 'None — finish or abort the rebase',\n 'branch-undeterminable'),\n ]),\n new L2Row(6, ['R'], 'on `main`, behind `origin/main`', L2_BLOCK, '`git pull origin main`, or `git checkout -b <new> origin/main`', [\n new L2UseCase(13,\n 'The Read tool refuses a file on a stale `main` while you have UNCOMMITTED edits',\n 'on `main`, behind `origin/main`, dirty tree',\n 'BLOCK. This used to fail open, on the argument that the prescribed `git pull` is not a clean fast-forward when the tree is dirty. That was true of the MESSAGE, not the row: the cure cell always offered a second form, and it works dirty',\n '`git checkout -b <new> origin/main` — uncommitted changes come with you onto the new branch. If git refuses because `origin/main` touched the same files, `git stash` first (never blocked), then retry, then `git stash pop`',\n 'on-stale-main'),\n new L2UseCase(15,\n 'The Read tool refuses a file that exists, on a `main` 18 commits behind',\n 'on `main`, behind `origin/main`, clean tree',\n 'BLOCK: judged by live ancestry (`git merge-base --is-ancestor`), not hash equality, so a pull takes effect instantly',\n '`git pull origin main`, or `git checkout -b <new> origin/main`',\n 'on-stale-main'),\n ]),\n new L2Row(7, ['R'], 'on `main`, current', L2_ALLOW, '—', [\n new L2UseCase(17,\n 'Reading files on a `main` you just pulled',\n 'on `main`, and `origin/main` is an ancestor of HEAD',\n 'ALLOW: this is the ONE place a Read is judged differently from a Bash command, because a Read names exactly one file and can be evaluated precisely',\n 'None needed',\n 'local-main-contains-origin (up to date)'),\n ]),\n new L2Row(8, ['B', 'R', 'E'], 'on a branch whose PR is **already merged**', L2_BLOCK, '`git fetch origin main && git checkout -b <new> origin/main`', [\n new L2UseCase(18,\n 'You keep working on the branch after its PR merged, and the next PR reopens code review already landed',\n 'branch whose PR is merged — `merged` is monotonic, so the cached flag is trusted with no TTL',\n 'BLOCK across all three tools',\n '`git fetch origin main && git checkout -b <new> origin/main`',\n 'already-merged PR#'),\n new L2UseCase(26,\n 'You have uncommitted edits on a branch whose PR just merged',\n 'merged branch, dirty tree',\n 'BLOCK. This used to fail open too, and that valve never had an argument behind it — row 8\\'s cure carries uncommitted work onto the fresh branch, so nothing was ever trapped. It was drift from the documented design, which `read-stale-guard`\\'s own class comment still described correctly',\n '`git fetch origin main && git checkout -b <new> origin/main` — your edits come with you',\n 'already-merged PR#'),\n new L2UseCase(19,\n 'A shell-only session sails through on a merged branch',\n 'merged branch, Bash only — both FILE guards are file-scoped, so Bash reached neither',\n 'BLOCK: `merged-branch-bash-guard` exists because `branchAlreadyMerged` was being computed and logged on that very path, then thrown away',\n '`git fetch origin main && git checkout -b <new> origin/main`',\n 'already-merged PR#'),\n ]),\n new L2Row(9, ['B', 'R', 'E'], 'no fork point with `origin/main`, or `origin/main` moved and collided with your files', L2_BLOCK, '`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is open', [\n new L2UseCase(20,\n 'Your branch and `origin/main` share no merge base — usually a branch cut from a squashed-away tip',\n 'no fork point',\n 'BLOCK: nothing built on this branch can be reasoned about relative to main',\n '`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is open',\n 'no-fork-point'),\n new L2UseCase(21,\n '`origin/main` moved and changed the same files you edited',\n 'main-moved collision',\n 'BLOCK — and row 3 then exempts everything once the merge starts, which is what makes this safe',\n '`pnpm wp-start-update`, resolve, `pnpm wp-finish-upsert-pr`',\n 'main-moved-conflict'),\n ]),\n new L2Row(10, ['B', 'R', 'E'], 'healthy feature branch', L2_ALLOW, '—', [\n new L2UseCase(22,\n 'Ordinary work on a branch cut from a current `origin/main`',\n 'healthy feature branch',\n 'ALLOW — the state every other row exists to push you back into',\n 'None needed',\n 'clean-feature-branch'),\n new L2UseCase(23,\n '`stale-main-bash-guard` sees a feature branch and hands off',\n 'not on `main` — state B belongs to `merged-branch-bash-guard`',\n 'ALLOW: the same verdict about the same tree, logged by the guard that is not responsible for it',\n 'None needed',\n 'not-on-main (state B is another guard)'),\n ]),\n];\n\n/**\n * Every use case on every row, in row order, for the doc and for the exhaustiveness specs.\n *\n * Numbering is GLOBAL and is identity, exactly as the row numbers are: a use case is cited by number in\n * review and in the doc, so add new ones at the END of the highest number rather than renumbering to\n * keep a row's block contiguous.\n */\n// webpieces-disable no-function-outside-class -- pure accessor over L2_ROWS above, in this data module\nexport function allL2UseCases(): readonly L2UseCase[] {\n return L2_ROWS.flatMap((row: L2Row): readonly L2UseCase[] => row.useCases);\n}\n\n/**\n * REASON → ROW. The join between what a guard actually logged and the row it is an instance of.\n *\n * Keys are the exact `reason` strings the four L2 guards pass to their decision log. Two of them are\n * PREFIXES because the guard interpolates a PR number or a matched segment into the reason\n * (`already-merged PR#123`); those are matched by `l2RowForReason` on prefix, which is why they end in\n * a space or a `(`.\n *\n * l2-matrix.spec.ts reads the four guard sources and asserts every reason literal in them resolves\n * here, so a new exit with no row fails the build rather than logging `row=-` forever.\n */\nconst EXACT_REASON_ROWS: Record<string, number> = {\n // Row 1 — the universal cure that must stay reachable from inside any block.\n 'webpieces-config-read (escape hatch)': 1,\n // Row 2 — the preventive half, decided from command TEXT before any cache is read.\n // (prefix, see PREFIX_REASON_ROWS)\n // Row 4 — the skip list, in its two live spellings.\n 'merged-branch recovery/inspection (allowlisted)': 4,\n 'not-a-content-read (cure/build/metadata)': 4,\n // Row 5 — never work on main.\n 'on-main': 5,\n // Row 6/7 — the ONE place a Read is judged differently from a Bash command.\n 'on-stale-main': 6,\n 'local-main-contains-origin (up to date)': 7,\n // Row 9 — the two unhealthy-fork states.\n 'no-fork-point': 9,\n 'main-moved-conflict': 9,\n // Row 10 — healthy, and the state-B guard's \"this is not my state\" hand-off, which is the same\n // verdict about the same tree.\n 'clean-feature-branch': 10,\n 'not-on-main (state B is another guard)': 10,\n // Row 11 — every \"could not establish\", including the two dirty-tree valves the code still opens\n // (see NOT_DONE) and the unreachable forge.\n 'branch-undeterminable': L2_FAIL_OPEN_ROW,\n 'no-sync-cache': L2_FAIL_OPEN_ROW,\n 'stale-cross-branch-cache': L2_FAIL_OPEN_ROW,\n 'origin-main-unknown': L2_FAIL_OPEN_ROW,\n // `dirty-tree-on-main` and `dirty-merged-branch` used to live here. Both valves are deleted: rows\n // 6 and 8 now block on a dirty tree, because each row's cure carries uncommitted work with you.\n 'no-forge': L2_FAIL_OPEN_ROW,\n // The config-edit bypass logged by the hook adapter before any guard runs — row 1, same universal\n // cure as the config READ above.\n 'config-bypass (feature-branch-guard skipped)': 1,\n};\n\n/** Reasons the guards interpolate a value into. Matched by prefix, longest first. */\nconst PREFIX_REASON_ROWS: Record<string, number> = {\n 'already-merged PR#': 8,\n 'bare checkout of main (': 2,\n // `stale-main content read (` used to live here, mapping the Bash side of row 6. It is gone with\n // the guard exit that emitted it: on `main`, row 5 now blocks before any content-read scan runs,\n // so row 6 is what the table always said it was — the ROW-ONLY row.\n};\n\n/**\n * The row a logged reason belongs to, or null when nothing claims it.\n *\n * Null rather than a default row: a reason with no row is a HOLE in the table, and defaulting it to\n * \"fail-open\" would hide exactly the drift the exhaustiveness spec exists to catch. The guards render\n * null as `row=-`, so an unmapped reason is visible in the log too, not only in CI.\n */\n// webpieces-disable no-function-outside-class -- the matcher over the two tables above, beside them in this data module\nexport function l2RowForReason(reason: string): number | null {\n const exact = EXACT_REASON_ROWS[reason];\n if (exact !== undefined) return exact;\n for (const prefix of Object.keys(PREFIX_REASON_ROWS)) {\n if (reason.startsWith(prefix)) return PREFIX_REASON_ROWS[prefix];\n }\n return null;\n}\n\n/** Every reason string this table claims, for the exhaustiveness spec. */\n// webpieces-disable no-function-outside-class -- pure accessor over the two tables above\nexport function l2MappedReasons(): readonly string[] {\n return [...Object.keys(EXACT_REASON_ROWS), ...Object.keys(PREFIX_REASON_ROWS)];\n}\n\n/** One documented gap between a row and what the guards actually do today. Data-only. */\nexport class L2NotDone {\n constructor(readonly row: number, readonly gap: string, readonly why: string) {}\n}\n\n/**\n * WHERE THE TABLE AND THE CODE DISAGREE, stated rather than papered over.\n *\n * The L1 precedent is `## Not done — \\`o\\` is not exempt yet`: a row the runner cannot reach, named in\n * the generated doc with the reason it has not shipped. The same treatment applies here, and it is what\n * makes it safe to publish a table the guards do not yet dispatch from — a reader is told exactly which\n * rows describe intent rather than behaviour, and the log's `row=` stamps land on row 11 for every one\n * of these, so the trail never claims the strict row fired.\n */\nexport const NOT_DONE: readonly L2NotDone[] = [\n // EMPTY, and that is the goal state: every row in the table is a row the guards actually honour.\n //\n // It held three entries. Row 5's `B` half shipped (on `main` is now judged from the branch alone,\n // above the cache divider). Rows 6 and 8 held DIRTY-TREE valves, and both are now closed — each of\n // those rows cures with `git checkout -b <new> origin/main`, which carries uncommitted changes onto\n // the new branch, so a dirty tree never trapped anybody. The row 6 entry claimed the dirty argument\n // \"has teeth\" there because its cure is `git pull`; that was a fact about the MESSAGE, which printed\n // only the pull, and the fix was to print both cures rather than to suppress the block.\n //\n // Keep this array. An empty \"Not done\" is a claim worth making explicitly — the doc says so in as\n // many words — and the next divergence between a row and its code belongs here, not in prose.\n];\n"]}
1
+ {"version":3,"file":"l2-rows.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/l2-rows.ts"],"names":[],"mappings":"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","sourcesContent":["// ---------------------------------------------------------------------------\n// L2 — the BRANCH-STATE layer, as data.\n//\n// L2 answers one question: *may I work here, and is what I read current?* Drawn as a decision matrix\n// that is TEN ordered rows plus one terminal fail-open row, first match wins.\n//\n// This module holds those rows, l2-doc.ts renders them into guards/L2-branch-state.md, and a unit test\n// (l2-matrix.spec.ts) locks that file byte-identical to the renderer — the same mechanism that already\n// makes L0's fault table and L1's location table undriftable. Before this existed the L2 doc was 100%\n// hand-written and said so: *\"Until that lands this text is hand-written and can drift.\"*\n//\n// ## HOW L2 JOINS TO THE ROWS TODAY — read this before assuming it works like L1\n//\n// L1 DISPATCHES from its array: `runner.l1LocationBlock` takes the first matching row and switches on\n// its `blockId`, so deleting a row deletes a block. L2 does NOT, and pretending otherwise would be the\n// drift this table exists to remove. The four L2 guard classes each own their own ladder, and those\n// ladders diverge on purpose (guards/L2-branch-state.md, \"Deliberate divergence\": the two Bash guards\n// differ in polarity, quantifier and empty-command handling all at once, so no single parameterised\n// function serves both). Unifying them is where a wrong edit turns an allow into a session-wedging\n// block, so it is deliberately not attempted here.\n//\n// What L2 does instead is a REASON→ROW join. Every exit of every L2 guard already carries a stable\n// reason string into the decision log; `L2_ROW_FOR_REASON` maps each of those to the row it is an\n// instance of, the guards stamp that number as `row=`, and l2-matrix.spec.ts asserts the map is\n// EXHAUSTIVE against the guard sources — a new reason with no row fails the build. So `row=8` in\n// `.webpieces/logs/L2-decisions` opens guards/L2-branch-state.md at row 8 and reads the state, the cure\n// and the tools that row covers, exactly as `row=5` already does for L1.\n//\n// The rows the guards cannot yet honour are named in the doc's \"Not done\" section rather than quietly\n// rendered as if they were live. That section is generated from NOT_DONE below, so it cannot rot.\n//\n// This module is deliberately import-free at runtime, so `pnpm guards:generate` can load it without the\n// package's transitive dependencies.\n// ---------------------------------------------------------------------------\n\n/** Which tools a row covers. `B` Bash · `R` Read · `E` Write/Edit. */\nexport type L2Tool = 'B' | 'R' | 'E';\n\n/** What L2 does with a row — the same action codebook every layer reports in (GUARD_MATRIX.md). */\nexport type L2ActionKind = 'allow' | 'exempt' | 'block' | 'fail-open';\n\n/**\n * The TERMINAL fail-open row, and the one number in this table that is not from the 1-10 design.\n *\n * Everything in rows 6-10 needs the main-sync cache, and the cache is written by a fire-and-forget\n * refresher that populates it for the NEXT call — so the first tool call of every session has none.\n * \"Stop here and ALLOW\" was written as a DIVIDER in the design table, i.e. as prose between two blocks\n * of rows. Prose cannot be stamped into a log line, and this is the single most frequently taken exit\n * in the whole layer (every session's first call, every unreadable branch, every unreachable forge), so\n * it is a row with a number like any other.\n *\n * It is 11 rather than 6-with-a-renumber because row numbers are IDENTITY here: they are printed in the\n * doc and logged as `row=`, so shifting 6-10 down would silently re-point every reference. The doc\n * prints it in its true position, between rows 5 and 6, with its number shown — same treatment L1 gives\n * row 8, which is printed third and numbered 8.\n */\nexport const L2_FAIL_OPEN_ROW = 11;\n\n/** The `act` cell: the doc's literal label, plus the machine-readable kind behind it. */\nexport class L2Action {\n constructor(readonly label: string, readonly kind: L2ActionKind) {}\n}\n\nexport const L2_ALLOW = new L2Action('1 allow', 'allow');\nexport const L2_EXEMPT = new L2Action('2 exempt', 'exempt');\nexport const L2_BLOCK = new L2Action('4 block', 'block');\nexport const L2_FAIL_OPEN = new L2Action('1 allow (fail-open)', 'fail-open');\n\n/**\n * One row of the \"L2 use cases\" table: what you SEE, the state it puts you in, the verdict, the fix.\n *\n * THE POINT OF THIS CLASS is that a use case is added HERE, in code, beside the row it exercises — not\n * into a hand-written doc section that drifts. When a new situation comes up in a session, it becomes\n * one more `new L2UseCase(...)` on the row that judged it, `pnpm guards:generate` re-renders the doc,\n * and the byte-lock spec fails if anyone edits the rendered table instead.\n *\n * The four text fields are rendered VERBATIM. `reason` is the ENFORCEMENT half and is never rendered:\n * it is the exact `reason` string the guard logs for this case, so a spec can push it back through\n * `l2RowForReason` and assert it lands on the row this use case is filed under. That closes the loop\n * the L2 decision log opens — `row=` in the trail, this table on the page, one join between them.\n *\n * `reason` is REQUIRED, and a case that exercises something which is not an L2 row exit says so with\n * `NO_ROW_EXIT` rather than by omitting the argument. An optional field would make opting OUT of the\n * only real enforcement here the shortest thing to type and impossible to grep — the widening-by-absence\n * shape CLAUDE.md rejects. `grep NO_ROW_EXIT` now lists every unenforced case.\n */\nexport class L2UseCase {\n // eslint-disable-next-line @typescript-eslint/max-params -- four verbatim doc cells plus the reason behind them\n constructor(\n readonly num: number,\n readonly symptom: string,\n readonly state: string,\n readonly verdict: string,\n readonly fix: string,\n readonly reason: string,\n ) {}\n}\n\n/**\n * The `reason` for a use case that is NOT an L2 row exit, and so has nothing to join back to.\n *\n * The only legitimate case today is row 3: merge-in-progress is L4's state, and L2 exempts it without\n * logging a reason of its own. Named rather than absent, so \"this case is not enforced\" is a value in\n * the table you can grep for instead of a missing argument nobody notices.\n */\nexport const NO_ROW_EXIT = 'NO_ROW_EXIT (not an L2 row exit — another layer owns this state)';\n\n/**\n * One row of L2's decision table.\n *\n * `cure` is rendered verbatim into the doc and is LITERAL by policy: L0's cure-reachability discipline\n * says a message pointing at documentation for its own remedy cannot be tested, and it caught a fault\n * prescribing a bin that had been renamed away. `—` is the only legal non-command cure, and only on a\n * row that allows.\n */\nexport class L2Row {\n // eslint-disable-next-line @typescript-eslint/max-params -- the five cells of one doc row plus its use cases\n constructor(\n readonly num: number,\n readonly tools: readonly L2Tool[],\n /** The `state` cell, verbatim. */\n readonly state: string,\n readonly action: L2Action,\n /** The `cure` cell, verbatim. `—` when the row allows. */\n readonly cure: string,\n /**\n * The observed situations this row judges. Rendered as the \"L2 use cases\" table.\n *\n * A NON-EMPTY tuple, and required: a row nobody has ever seen fire is either dead or\n * undocumented, and both are worth knowing. Expressing that in the TYPE rather than as a\n * runtime assertion is the JwtRoles pattern — the invariant is enforced at the moment the row\n * is written, which is the only moment that changes what somebody types.\n */\n readonly useCases: readonly [L2UseCase, ...L2UseCase[]],\n ) {}\n\n /** `B R E`, the doc's own spelling of the tool cell. */\n toolCell(): string {\n return this.tools.join(' ');\n }\n}\n\n/**\n * THE ELEVEN L2 ROWS, in first-match-wins order.\n *\n * Rows 1-5 need NO cache and fire on call #1: rows 1, 2 and 4 are text matches, row 3 is a marker-file\n * scan, row 5 is one `git rev-parse`. Row 11 is the cache divider. Rows 6-10 all read the cache.\n *\n * THE ORDER OF ROW 5 IS THE MOST LOAD-BEARING THING IN THIS TABLE. Put \"on main\" BELOW the divider and\n * writes on `main` are permitted for the whole first call of every session — and permanently in a\n * multi-worktree repo, where another tree may hold the cache lock indefinitely.\n *\n * `B` tracks `E` everywhere; `R` is judged separately in exactly ONE place, rows 6/7 on `main`. A Read\n * names exactly one file so the guard can evaluate it precisely; a Bash command is opaque and gets the\n * conservative answer. Reading a CURRENT `main` is fine — the problem is that `main` is almost always\n * behind.\n */\nexport const L2_ROWS: readonly L2Row[] = [\n new L2Row(1, ['B', 'R', 'E'], 'on the **global allowlist** (inert command, or a universal cure such as reading/editing `webpieces.config.json`)', L2_ALLOW, '—', [\n new L2UseCase(1,\n 'You are blocked by some other L2 row, and need to turn the policy off to get anything done',\n 'any state — this row is ahead of every block',\n 'ALLOW: reading and editing `webpieces.config.json` is never blocked, so the mode-OFF cure is always reachable',\n 'Edit `webpieces.config.json` → `hookGuards` → `branch-state-guard` → `\"mode\": \"OFF\"`',\n 'webpieces-config-read (escape hatch)'),\n new L2UseCase(2,\n 'A Write to `webpieces.config.json` while on `main`, which row 5 would otherwise block',\n 'on `main`, editing the one file that can disable the guard',\n 'ALLOW: the hook adapter bypasses feature-branch-guard for this path before any guard runs',\n 'None needed — the edit proceeds',\n 'config-bypass (feature-branch-guard skipped)'),\n ]),\n new L2Row(2, ['B'], 'bare `git checkout main`, with no `git pull` chained into the same command', L2_BLOCK, '`pnpm wp-checkout-clean-main`', [\n new L2UseCase(3,\n '`git checkout main` after a merge, to start the next piece of work',\n 'about to land on whatever local `main` you last had — 157 commits behind, in the incident',\n 'BLOCK: decided from command TEXT alone, before the checkout, because the only `main` this could measure is the one it is about to leave',\n '`pnpm wp-checkout-clean-main` — checkout, pull, reap dead branches/worktrees, sweep orphan directories, in one command (hand-rolled, the pull must be in the SAME command as the checkout)',\n 'bare checkout of main ('),\n new L2UseCase(4,\n 'The same command inside a linked worktree, where `git checkout main` fatals anyway',\n 'linked worktree — `main` is already checked out in the primary clone',\n 'BLOCK, and the message prints the worktree form rather than a cure git would refuse',\n '`git fetch origin main`, then work off `origin/main`',\n 'bare checkout of main ('),\n ]),\n new L2Row(3, ['B', 'R', 'E'], '**merge in progress** — L4 owns this state', L2_EXEMPT, 'finish the merge: `pnpm wp-finish-upsert-pr`', [\n new L2UseCase(5,\n 'Reading and editing conflicted files during a 3-point merge, on a branch row 9 would block',\n 'merge markers on disk — `pnpm wp-start-update` has run and not finished',\n 'EXEMPT: everything is permitted, which is exactly what lets row 9 be strict',\n 'Resolve the conflicts, then `pnpm wp-finish-upsert-pr`',\n NO_ROW_EXIT),\n ]),\n new L2Row(4, ['B'], 'on the **skip list** — it gets you OUT, or tells you where you are', L2_ALLOW, '—', [\n new L2UseCase(6,\n '`git status` / `gh pr view` while blocked, to work out where you are',\n 'any state — orientation is never \"working here\"',\n 'ALLOW: metadata tells you where you are without putting stale file CONTENT in context',\n 'None needed',\n 'not-a-content-read (cure/build/metadata)'),\n new L2UseCase(7,\n '`git stash` when `git checkout -b <new> origin/main` refuses because `origin/main` touched the same files you edited',\n 'on a stale `main` or a merged branch, dirty tree, with an overlapping upstream change',\n 'ALLOW: the cure for the row that blocked you must itself never be blocked — and this is the residual step that makes rows 6 and 8 safe to block on a dirty tree',\n 'None needed — then re-run the checkout and `git stash pop`',\n 'not-a-content-read (cure/build/metadata)'),\n new L2UseCase(8,\n '`pnpm wp-start-upsert-pr` on a branch whose fork point is broken',\n 'row 9 state, running the tool row 9 prescribes',\n 'ALLOW: every `wp-*` bin is on the skip list, so no row can block its own remedy',\n 'None needed',\n 'merged-branch recovery/inspection (allowlisted)'),\n ]),\n new L2Row(5, ['B', 'E'], 'on `main`', L2_BLOCK, '`git checkout -b <new> origin/main`', [\n new L2UseCase(9,\n 'An Edit or Write to any tracked file while `git rev-parse --abbrev-ref HEAD` says `main`',\n 'on `main`, any freshness',\n 'BLOCK: decided by one `git rev-parse`, with NO cache read, so it fires on the first tool call of the session',\n '`git checkout -b <new> origin/main` — uncommitted work comes with you',\n 'on-main'),\n new L2UseCase(10,\n 'A Bash command that WRITES tracked files as a side effect — `npx expo install`, a formatter, codegen, `sed -i`, a `>` redirect',\n 'on `main`, and the write is incidental to a command whose stated purpose is something else',\n 'BLOCK: default-DENY on `main` plus row 4\\'s skip list, so a command nobody thought to enumerate is caught by not being on the list — which is the only shape that could have caught this one',\n '`git checkout -b <new> origin/main` BEFORE running anything that may write',\n 'on-main'),\n new L2UseCase(24,\n 'A build or a test run on a `main` that is perfectly up to date',\n 'on `main`, current — no staleness anywhere',\n 'BLOCK: freshness is not the question. `main` is not a place to work even when current, and the cure is a new branch, not a pull',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n new L2UseCase(16,\n 'Read is blocked, so the session reaches for `cat`, `grep` and `ls` instead — and describes a CI workflow set missing a whole workflow that existed upstream',\n 'the SIDE DOOR: same tree, different tool',\n 'BLOCK. This case used to be judged by row 6 (a stale-content blocklist on the Bash side); row 5 now subsumes it, because being on `main` is already the finding and no enumeration of readers is needed. The log used to read \"read-stale-guard handled\", which is worse than no guard — it looks covered',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n new L2UseCase(25,\n 'The FIRST command of a session, on `main`, before any cache exists',\n 'on `main`, cache absent — row 11 would fail open',\n 'BLOCK anyway: row 5 is ABOVE the cache divider and reads only `git rev-parse`, so it is armed on call #1. This is the case the cache-gated version could never catch',\n '`git checkout -b <new> origin/main`',\n 'on-main'),\n ]),\n new L2Row(L2_FAIL_OPEN_ROW, ['B', 'R', 'E'], '**the state could not be established** — branch undeterminable, no cache yet, the cache holds another branch, `origin/main` unknown, or the forge unreachable', L2_FAIL_OPEN, '— (nothing to fix; the refresher populates the cache for the next call)', [\n new L2UseCase(11,\n 'The very first tool call of a session is allowed even on a badly stale `main`',\n 'no cache — the refresher is fire-and-forget and populates it for the NEXT call',\n 'ALLOW (fail-open), logged as `ALLOW_FAIL_OPEN` so abstentions stay countable',\n 'None — the second call is judged normally',\n 'no-sync-cache'),\n new L2UseCase(12,\n 'Guards quietly stand down on a plane, or when `gh` is unauthenticated or rate-limited',\n 'the forge could not be asked whether the PR is merged',\n 'ALLOW (fail-open) logged as `no-forge` — distinct from \"asked, and it is not merged\", which used to look identical in the trail',\n 'None — restore network/`gh auth` to re-arm the merged-branch policy',\n 'no-forge'),\n new L2UseCase(14,\n 'Mid-rebase, every guard abstains',\n 'detached HEAD — there is no branch name to judge',\n 'ALLOW (fail-open), logged LOUDLY when the branch is unresolvable rather than merely detached',\n 'None — finish or abort the rebase',\n 'branch-undeterminable'),\n ]),\n new L2Row(6, ['R'], 'on `main`, behind `origin/main`', L2_BLOCK, '`git pull origin main`, or `git checkout -b <new> origin/main`', [\n new L2UseCase(13,\n 'The Read tool refuses a file on a stale `main` while you have UNCOMMITTED edits',\n 'on `main`, behind `origin/main`, dirty tree',\n 'BLOCK. This used to fail open, on the argument that the prescribed `git pull` is not a clean fast-forward when the tree is dirty. That was true of the MESSAGE, not the row: the cure cell always offered a second form, and it works dirty',\n '`git checkout -b <new> origin/main` — uncommitted changes come with you onto the new branch. If git refuses because `origin/main` touched the same files, `git stash` first (never blocked), then retry, then `git stash pop`',\n 'on-stale-main'),\n new L2UseCase(15,\n 'The Read tool refuses a file that exists, on a `main` 18 commits behind',\n 'on `main`, behind `origin/main`, clean tree',\n 'BLOCK: judged by live ancestry (`git merge-base --is-ancestor`), not hash equality, so a pull takes effect instantly',\n '`git pull origin main`, or `git checkout -b <new> origin/main`',\n 'on-stale-main'),\n ]),\n new L2Row(7, ['R'], 'on `main`, current', L2_ALLOW, '—', [\n new L2UseCase(17,\n 'Reading files on a `main` you just pulled',\n 'on `main`, and `origin/main` is an ancestor of HEAD',\n 'ALLOW: this is the ONE place a Read is judged differently from a Bash command, because a Read names exactly one file and can be evaluated precisely',\n 'None needed',\n 'local-main-contains-origin (up to date)'),\n ]),\n new L2Row(8, ['B', 'R', 'E'], 'on a branch whose PR is **already merged**', L2_BLOCK, '`git fetch origin main && git checkout -b <new> origin/main`', [\n new L2UseCase(18,\n 'You keep working on the branch after its PR merged, and the next PR reopens code review already landed',\n 'branch whose PR is merged — `merged` is monotonic, so the cached flag is trusted with no TTL',\n 'BLOCK across all three tools',\n '`git fetch origin main && git checkout -b <new> origin/main`',\n 'already-merged PR#'),\n new L2UseCase(26,\n 'You have uncommitted edits on a branch whose PR just merged',\n 'merged branch, dirty tree',\n 'BLOCK. This used to fail open too, and that valve never had an argument behind it — row 8\\'s cure carries uncommitted work onto the fresh branch, so nothing was ever trapped. It was drift from the documented design, which `read-stale-guard`\\'s own class comment still described correctly',\n '`git fetch origin main && git checkout -b <new> origin/main` — your edits come with you',\n 'already-merged PR#'),\n new L2UseCase(19,\n 'A shell-only session sails through on a merged branch',\n 'merged branch, Bash only — both FILE guards are file-scoped, so Bash reached neither',\n 'BLOCK: `merged-branch-bash-guard` exists because `branchAlreadyMerged` was being computed and logged on that very path, then thrown away',\n '`git fetch origin main && git checkout -b <new> origin/main`',\n 'already-merged PR#'),\n ]),\n new L2Row(9, ['B', 'R', 'E'], 'no fork point with `origin/main`, or `origin/main` moved and collided with your files', L2_BLOCK, '`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is open', [\n new L2UseCase(20,\n 'Your branch and `origin/main` share no merge base — usually a branch cut from a squashed-away tip',\n 'no fork point',\n 'BLOCK: nothing built on this branch can be reasoned about relative to main',\n '`pnpm wp-start-update`, or `pnpm wp-start-upsert-pr` when a PR is open',\n 'no-fork-point'),\n new L2UseCase(21,\n '`origin/main` moved and changed the same files you edited',\n 'main-moved collision',\n 'BLOCK — and row 3 then exempts everything once the merge starts, which is what makes this safe',\n '`pnpm wp-start-update`, resolve, `pnpm wp-finish-upsert-pr`',\n 'main-moved-conflict'),\n ]),\n new L2Row(10, ['B', 'R', 'E'], 'healthy feature branch', L2_ALLOW, '—', [\n new L2UseCase(22,\n 'Ordinary work on a branch cut from a current `origin/main`',\n 'healthy feature branch',\n 'ALLOW — the state every other row exists to push you back into',\n 'None needed',\n 'clean-feature-branch'),\n new L2UseCase(23,\n '`stale-main-bash-guard` sees a feature branch and hands off',\n 'not on `main` — state B belongs to `merged-branch-bash-guard`',\n 'ALLOW: the same verdict about the same tree, logged by the guard that is not responsible for it',\n 'None needed',\n 'not-on-main (state B is another guard)'),\n ]),\n];\n\n/**\n * Every use case on every row, in row order, for the doc and for the exhaustiveness specs.\n *\n * Numbering is GLOBAL and is identity, exactly as the row numbers are: a use case is cited by number in\n * review and in the doc, so add new ones at the END of the highest number rather than renumbering to\n * keep a row's block contiguous.\n */\n// webpieces-disable no-function-outside-class -- pure accessor over L2_ROWS above, in this data module\nexport function allL2UseCases(): readonly L2UseCase[] {\n return L2_ROWS.flatMap((row: L2Row): readonly L2UseCase[] => row.useCases);\n}\n\n/**\n * REASON → ROW. The join between what a guard actually logged and the row it is an instance of.\n *\n * Keys are the exact `reason` strings the four L2 guards pass to their decision log. Two of them are\n * PREFIXES because the guard interpolates a PR number or a matched segment into the reason\n * (`already-merged PR#123`); those are matched by `l2RowForReason` on prefix, which is why they end in\n * a space or a `(`.\n *\n * l2-matrix.spec.ts reads the four guard sources and asserts every reason literal in them resolves\n * here, so a new exit with no row fails the build rather than logging `row=-` forever.\n */\nconst EXACT_REASON_ROWS: Record<string, number> = {\n // Row 1 — the universal cure that must stay reachable from inside any block.\n 'webpieces-config-read (escape hatch)': 1,\n // Row 2 — the preventive half, decided from command TEXT before any cache is read.\n // (prefix, see PREFIX_REASON_ROWS)\n // Row 4 — the skip list, in its two live spellings.\n 'merged-branch recovery/inspection (allowlisted)': 4,\n 'not-a-content-read (cure/build/metadata)': 4,\n // Row 5 — never work on main.\n 'on-main': 5,\n // Row 6/7 — the ONE place a Read is judged differently from a Bash command.\n 'on-stale-main': 6,\n 'local-main-contains-origin (up to date)': 7,\n // Row 9 — the two unhealthy-fork states.\n 'no-fork-point': 9,\n 'main-moved-conflict': 9,\n // Row 10 — healthy, and the state-B guard's \"this is not my state\" hand-off, which is the same\n // verdict about the same tree.\n 'clean-feature-branch': 10,\n 'not-on-main (state B is another guard)': 10,\n // Row 11 — every \"could not establish\", including the two dirty-tree valves the code still opens\n // (see NOT_DONE) and the unreachable forge.\n 'branch-undeterminable': L2_FAIL_OPEN_ROW,\n 'no-sync-cache': L2_FAIL_OPEN_ROW,\n 'stale-cross-branch-cache': L2_FAIL_OPEN_ROW,\n 'origin-main-unknown': L2_FAIL_OPEN_ROW,\n // `dirty-tree-on-main` and `dirty-merged-branch` used to live here. Both valves are deleted: rows\n // 6 and 8 now block on a dirty tree, because each row's cure carries uncommitted work with you.\n 'no-forge': L2_FAIL_OPEN_ROW,\n // The config-edit bypass logged by the hook adapter before any guard runs — row 1, same universal\n // cure as the config READ above.\n 'config-bypass (feature-branch-guard skipped)': 1,\n};\n\n/** Reasons the guards interpolate a value into. Matched by prefix, longest first. */\nconst PREFIX_REASON_ROWS: Record<string, number> = {\n 'already-merged PR#': 8,\n 'bare checkout of main (': 2,\n // `stale-main content read (` used to live here, mapping the Bash side of row 6. It is gone with\n // the guard exit that emitted it: on `main`, row 5 now blocks before any content-read scan runs,\n // so row 6 is what the table always said it was — the ROW-ONLY row.\n};\n\n/**\n * The row a logged reason belongs to, or null when nothing claims it.\n *\n * Null rather than a default row: a reason with no row is a HOLE in the table, and defaulting it to\n * \"fail-open\" would hide exactly the drift the exhaustiveness spec exists to catch. The guards render\n * null as `row=-`, so an unmapped reason is visible in the log too, not only in CI.\n */\n// webpieces-disable no-function-outside-class -- the matcher over the two tables above, beside them in this data module\nexport function l2RowForReason(reason: string): number | null {\n const exact = EXACT_REASON_ROWS[reason];\n if (exact !== undefined) return exact;\n for (const prefix of Object.keys(PREFIX_REASON_ROWS)) {\n if (reason.startsWith(prefix)) return PREFIX_REASON_ROWS[prefix];\n }\n return null;\n}\n\n/** Every reason string this table claims, for the exhaustiveness spec. */\n// webpieces-disable no-function-outside-class -- pure accessor over the two tables above\nexport function l2MappedReasons(): readonly string[] {\n return [...Object.keys(EXACT_REASON_ROWS), ...Object.keys(PREFIX_REASON_ROWS)];\n}\n\n/** One documented gap between a row and what the guards actually do today. Data-only. */\nexport class L2NotDone {\n constructor(readonly row: number, readonly gap: string, readonly why: string) {}\n}\n\n/**\n * WHERE THE TABLE AND THE CODE DISAGREE, stated rather than papered over.\n *\n * The L1 precedent is `## Not done — \\`o\\` is not exempt yet`: a row the runner cannot reach, named in\n * the generated doc with the reason it has not shipped. The same treatment applies here, and it is what\n * makes it safe to publish a table the guards do not yet dispatch from — a reader is told exactly which\n * rows describe intent rather than behaviour, and the log's `row=` stamps land on row 11 for every one\n * of these, so the trail never claims the strict row fired.\n */\nexport const NOT_DONE: readonly L2NotDone[] = [\n // EMPTY, and that is the goal state: every row in the table is a row the guards actually honour.\n //\n // It held three entries. Row 5's `B` half shipped (on `main` is now judged from the branch alone,\n // above the cache divider). Rows 6 and 8 held DIRTY-TREE valves, and both are now closed — each of\n // those rows cures with `git checkout -b <new> origin/main`, which carries uncommitted changes onto\n // the new branch, so a dirty tree never trapped anybody. The row 6 entry claimed the dirty argument\n // \"has teeth\" there because its cure is `git pull`; that was a fact about the MESSAGE, which printed\n // only the pull, and the fix was to print both cures rather than to suppress the block.\n //\n // Keep this array. An empty \"Not done\" is a claim worth making explicitly — the doc says so in as\n // many words — and the next divergence between a row and its code belongs here, not in prose.\n];\n"]}
@@ -28,6 +28,11 @@ export declare class BranchSwitchScan {
28
28
  * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale
29
29
  * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are
30
30
  * excluded because they create the branch here and now.
31
+ *
32
+ * This scan reads raw GIT, and it has to keep doing so even though the workflow messages now
33
+ * prescribe `pnpm wp-checkout-clean-main` instead: what the guards recommend is not what an agent
34
+ * necessarily types, the raw pair is still allowed and is still the L0 recovery cure, and a scan
35
+ * that stopped recognising it would stop catching the bare `git checkout main` it exists to catch.
31
36
  */
32
37
  landsOnExistingMain(segment: string): boolean;
33
38
  /** The same question for a target already parsed (switchesIn). One spelling, two entry shapes. */
@@ -88,6 +88,11 @@ class BranchSwitchScan {
88
88
  * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale
89
89
  * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are
90
90
  * excluded because they create the branch here and now.
91
+ *
92
+ * This scan reads raw GIT, and it has to keep doing so even though the workflow messages now
93
+ * prescribe `pnpm wp-checkout-clean-main` instead: what the guards recommend is not what an agent
94
+ * necessarily types, the raw pair is still allowed and is still the L0 recovery cure, and a scan
95
+ * that stopped recognising it would stop catching the bare `git checkout main` it exists to catch.
91
96
  */
92
97
  landsOnExistingMain(segment) {
93
98
  const target = this.targetOf(segment);
@@ -1 +1 @@
1
- {"version":3,"file":"branch-switch-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-switch-scan.ts"],"names":[],"mappings":";;;AAAA,kDAAiD;AAEjD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,YAAY,GAAwB,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC;AAExF,sGAAsG;AACtG,MAAM,gBAAgB,GAAwB,IAAI,GAAG,CAAC,CAAC,YAAY,EAAE,sBAAsB,EAAE,eAAe,CAAC,CAAC,CAAC;AAE/G;;;;;;GAMG;AACH,MAAa,YAAY;IACrB,MAAM,CAAS;IACf,OAAO,CAAU;IAEjB,YAAY,MAAc,EAAE,OAAgB;QACxC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IAC3B,CAAC;CACJ;AARD,oCAQC;AAED,MAAa,gBAAgB;IACI;IAA7B,YAA6B,UAA0B,IAAI,6BAAc,EAAE;QAA9C,YAAO,GAAP,OAAO,CAAuC;IAAG,CAAC;IAE/E;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAe;QACpB,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;QACvD,IAAI,UAAU,KAAK,UAAU,IAAI,UAAU,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAEtE,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC;QAExD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACnC,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;YACrB,IAAI,IAAI,KAAK,IAAI;gBAAE,OAAO,IAAI,CAAC;YAC/B,IAAI,IAAI,KAAK,GAAG;gBAAE,OAAO,IAAI,CAAC;YAC9B,MAAM,OAAO,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;YAC5C,IAAI,OAAO,KAAK,IAAI;gBAAE,OAAO,OAAO,CAAC;YACrC,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;gBAAC,CAAC,EAAE,CAAC;gBAAC,SAAS;YAAC,CAAC;YAClD,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,SAAS;YACnC,OAAO,IAAI,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACzC,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;OAIG;IACH,mBAAmB,CAAC,OAAe;QAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;QACtC,OAAO,MAAM,KAAK,IAAI,IAAI,IAAI,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC;IAC1D,CAAC;IAED,kGAAkG;IAClG,cAAc,CAAC,MAAoB;QAC/B,OAAO,MAAM,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC;IACvD,CAAC;IAED,yEAAyE;IACzE,UAAU,CAAC,OAAe;QACtB,MAAM,KAAK,GAAmB,EAAE,CAAC;QACjC,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1D,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACtC,IAAI,MAAM,KAAK,IAAI;gBAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC5C,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAED,mGAAmG;IAC3F,aAAa,CAAC,IAAuB,EAAE,CAAS;QACpD,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACrB,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YACzB,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;YAC5D,OAAO,IAAI,YAAY,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QACxC,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,MAAM,GAAG,CAAC,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,EAAE,CAAC;YACxD,OAAO,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;QAC1D,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;CACJ;AAvED,4CAuEC","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * \"Which branch does this `git checkout` / `git switch` land me on?\" — the ONE place that question is\n * answered, for every guard that asks it.\n *\n * It used to be answered by two regexes in redirect-how-to-merge-main\n * (`/git\\s+(?:checkout|switch)\\s+main\\b/` to EXEMPT main, and a negative-lookahead twin to catch a\n * feature switch) and by a third, hand-rolled word walk in stale-main-bash-guard. The regex pair\n * assumed the branch name is the token immediately after the subcommand, so ONE flag broke it:\n *\n * git checkout main → exempted (correct)\n * git checkout -q main → `main` no longer follows `checkout`, so the exemption MISSED, the\n * lookahead concluded the target was not main, and the command was\n * blocked with \"this switches to a feature branch and then pulls main\n * into it\" — the exact opposite of what it does.\n *\n * That block landed on the cure stale-main-bash-guard itself prescribes, so the two guards\n * contradicted each other and a human had to route around both to update main. The fix is not a\n * cleverer regex: it is to TOKENIZE (CommandScanner already does the hard part) and walk the flags,\n * which is also why it must exist once rather than three times.\n *\n * Deliberately NOT \"does the string contain main\" — over-matching here is worse than the original\n * bug, because `git checkout -b feature/main-thing` and `git checkout -- main.ts` would then read as\n * \"switching to main\" and be EXEMPTED from a guard whose whole job is to catch a feature-branch pull.\n */\nconst CREATE_FLAGS: ReadonlySet<string> = new Set(['-b', '-B', '-c', '-C', '--orphan']);\n\n// Non-creating flags that consume the FOLLOWING token, so its value is never mistaken for the target.\nconst FLAGS_WITH_VALUE: ReadonlySet<string> = new Set(['--conflict', '--pathspec-from-file', '--start-point']);\n\n/**\n * The branch a checkout/switch lands on, and whether that command CREATES it.\n *\n * `created` is load-bearing rather than cosmetic: `git checkout -b x origin/main` lands on a branch\n * that is current by construction (nothing to be stale about), whereas landing on an EXISTING local\n * `main` is exactly the stale-checkout hazard. Data-only, so a class (per CLAUDE.md).\n */\nexport class BranchSwitch {\n branch: string;\n created: boolean;\n\n constructor(branch: string, created: boolean) {\n this.branch = branch;\n this.created = created;\n }\n}\n\nexport class BranchSwitchScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n /**\n * The branch this segment switches to, or null when the segment does not land on a branch at all:\n * a different command, no target word, `git checkout -` (the previous branch — unknowable here),\n * or anything after `--` (which ends option parsing, making the rest PATHSPECS, so\n * `git checkout -- main` restores a FILE named main and moves no branch).\n *\n * Flag-tolerant by construction: every leading flag is walked past, so `-q`, `--quiet`,\n * `--no-track`, `--detach` and friends change nothing about which word is the target.\n */\n targetOf(segment: string): BranchSwitch | null {\n const subcommand = this.scanner.gitSubcommand(segment);\n if (subcommand !== 'checkout' && subcommand !== 'switch') return null;\n\n const words = this.scanner.words(segment);\n const args = words.slice(words.indexOf(subcommand) + 1);\n\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n if (word === '--') return null;\n if (word === '-') return null;\n const created = this.createdBranch(args, i);\n if (created !== null) return created;\n if (FLAGS_WITH_VALUE.has(word)) { i++; continue; }\n if (word.startsWith('-')) continue;\n return new BranchSwitch(word, false);\n }\n return null;\n }\n\n /**\n * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale\n * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are\n * excluded because they create the branch here and now.\n */\n landsOnExistingMain(segment: string): boolean {\n const target = this.targetOf(segment);\n return target !== null && this.isExistingMain(target);\n }\n\n /** The same question for a target already parsed (switchesIn). One spelling, two entry shapes. */\n isExistingMain(target: BranchSwitch): boolean {\n return target.branch === 'main' && !target.created;\n }\n\n /** Every segment of a whole command that lands on a branch, in order. */\n switchesIn(command: string): readonly BranchSwitch[] {\n const found: BranchSwitch[] = [];\n for (const segment of this.scanner.commandSegments(command)) {\n const target = this.targetOf(segment);\n if (target !== null) found.push(target);\n }\n return found;\n }\n\n // `-b <name>` / `--orphan=<name>` — the new branch's name, or null when this word creates nothing.\n private createdBranch(args: readonly string[], i: number): BranchSwitch | null {\n const word = args[i];\n if (CREATE_FLAGS.has(word)) {\n const name = args[i + 1];\n if (name === undefined || name.startsWith('-')) return null;\n return new BranchSwitch(name, true);\n }\n const equals = word.indexOf('=');\n if (equals > 0 && CREATE_FLAGS.has(word.slice(0, equals))) {\n return new BranchSwitch(word.slice(equals + 1), true);\n }\n return null;\n }\n}\n"]}
1
+ {"version":3,"file":"branch-switch-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-switch-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * \"Which branch does this `git checkout` / `git switch` land me on?\" — the ONE place that question is\n * answered, for every guard that asks it.\n *\n * It used to be answered by two regexes in redirect-how-to-merge-main\n * (`/git\\s+(?:checkout|switch)\\s+main\\b/` to EXEMPT main, and a negative-lookahead twin to catch a\n * feature switch) and by a third, hand-rolled word walk in stale-main-bash-guard. The regex pair\n * assumed the branch name is the token immediately after the subcommand, so ONE flag broke it:\n *\n * git checkout main → exempted (correct)\n * git checkout -q main → `main` no longer follows `checkout`, so the exemption MISSED, the\n * lookahead concluded the target was not main, and the command was\n * blocked with \"this switches to a feature branch and then pulls main\n * into it\" — the exact opposite of what it does.\n *\n * That block landed on the cure stale-main-bash-guard itself prescribes, so the two guards\n * contradicted each other and a human had to route around both to update main. The fix is not a\n * cleverer regex: it is to TOKENIZE (CommandScanner already does the hard part) and walk the flags,\n * which is also why it must exist once rather than three times.\n *\n * Deliberately NOT \"does the string contain main\" — over-matching here is worse than the original\n * bug, because `git checkout -b feature/main-thing` and `git checkout -- main.ts` would then read as\n * \"switching to main\" and be EXEMPTED from a guard whose whole job is to catch a feature-branch pull.\n */\nconst CREATE_FLAGS: ReadonlySet<string> = new Set(['-b', '-B', '-c', '-C', '--orphan']);\n\n// Non-creating flags that consume the FOLLOWING token, so its value is never mistaken for the target.\nconst FLAGS_WITH_VALUE: ReadonlySet<string> = new Set(['--conflict', '--pathspec-from-file', '--start-point']);\n\n/**\n * The branch a checkout/switch lands on, and whether that command CREATES it.\n *\n * `created` is load-bearing rather than cosmetic: `git checkout -b x origin/main` lands on a branch\n * that is current by construction (nothing to be stale about), whereas landing on an EXISTING local\n * `main` is exactly the stale-checkout hazard. Data-only, so a class (per CLAUDE.md).\n */\nexport class BranchSwitch {\n branch: string;\n created: boolean;\n\n constructor(branch: string, created: boolean) {\n this.branch = branch;\n this.created = created;\n }\n}\n\nexport class BranchSwitchScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n /**\n * The branch this segment switches to, or null when the segment does not land on a branch at all:\n * a different command, no target word, `git checkout -` (the previous branch — unknowable here),\n * or anything after `--` (which ends option parsing, making the rest PATHSPECS, so\n * `git checkout -- main` restores a FILE named main and moves no branch).\n *\n * Flag-tolerant by construction: every leading flag is walked past, so `-q`, `--quiet`,\n * `--no-track`, `--detach` and friends change nothing about which word is the target.\n */\n targetOf(segment: string): BranchSwitch | null {\n const subcommand = this.scanner.gitSubcommand(segment);\n if (subcommand !== 'checkout' && subcommand !== 'switch') return null;\n\n const words = this.scanner.words(segment);\n const args = words.slice(words.indexOf(subcommand) + 1);\n\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n if (word === '--') return null;\n if (word === '-') return null;\n const created = this.createdBranch(args, i);\n if (created !== null) return created;\n if (FLAGS_WITH_VALUE.has(word)) { i++; continue; }\n if (word.startsWith('-')) continue;\n return new BranchSwitch(word, false);\n }\n return null;\n }\n\n /**\n * True only for landing on an EXISTING branch named `main` — the form that can hand you a stale\n * tree, and the form `git checkout main && git pull origin main` uses. `-b`/`-B`/`-c`/`-C` are\n * excluded because they create the branch here and now.\n *\n * This scan reads raw GIT, and it has to keep doing so even though the workflow messages now\n * prescribe `pnpm wp-checkout-clean-main` instead: what the guards recommend is not what an agent\n * necessarily types, the raw pair is still allowed and is still the L0 recovery cure, and a scan\n * that stopped recognising it would stop catching the bare `git checkout main` it exists to catch.\n */\n landsOnExistingMain(segment: string): boolean {\n const target = this.targetOf(segment);\n return target !== null && this.isExistingMain(target);\n }\n\n /** The same question for a target already parsed (switchesIn). One spelling, two entry shapes. */\n isExistingMain(target: BranchSwitch): boolean {\n return target.branch === 'main' && !target.created;\n }\n\n /** Every segment of a whole command that lands on a branch, in order. */\n switchesIn(command: string): readonly BranchSwitch[] {\n const found: BranchSwitch[] = [];\n for (const segment of this.scanner.commandSegments(command)) {\n const target = this.targetOf(segment);\n if (target !== null) found.push(target);\n }\n return found;\n }\n\n // `-b <name>` / `--orphan=<name>` — the new branch's name, or null when this word creates nothing.\n private createdBranch(args: readonly string[], i: number): BranchSwitch | null {\n const word = args[i];\n if (CREATE_FLAGS.has(word)) {\n const name = args[i + 1];\n if (name === undefined || name.startsWith('-')) return null;\n return new BranchSwitch(name, true);\n }\n const equals = word.indexOf('=');\n if (equals > 0 && CREATE_FLAGS.has(word.slice(0, equals))) {\n return new BranchSwitch(word.slice(equals + 1), true);\n }\n return null;\n }\n}\n"]}
@@ -8,4 +8,29 @@ export declare class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatter
8
8
  readonly files: string[];
9
9
  get fixHint(): FixHint;
10
10
  check(ctx: EditContext): readonly Violation[];
11
+ /**
12
+ * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the
13
+ * whole fix for a bug that made this rule refuse the exact cure it prescribes.
14
+ *
15
+ * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a
16
+ * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and
17
+ * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the
18
+ * documented way to say "this error is deliberately ignored", was reported as "no toError statement"
19
+ * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing
20
+ * carrying a `//` ever reached it. 34 catches in this repo already use that form.
21
+ *
22
+ * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what
23
+ * counts as "the first statement" (a blank line, a `{`, or an unrelated comment is skipped past).
24
+ * Raw is tested first because it is the only array in which the comment form survives; a live
25
+ * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the
26
+ * `$` anchor and is then matched on its stripped form, which is exactly the intent.
27
+ *
28
+ * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`
29
+ * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`
30
+ * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,
31
+ * so it was taken for the first statement — which is the mechanical reason a commented-out toError
32
+ * reported "no toError statement", and the reason a trailing `// why` on a LIVE toError reported the
33
+ * same. One replace fixes both, in the one place the question is asked.
34
+ */
35
+ private findToErrorStatement;
11
36
  }
@@ -14,23 +14,17 @@ const CATCH_PATTERN = /\bcatch\s*\(\s*(\w+)(?:\s*:\s*(\w+))?\s*\)/;
14
14
  /**
15
15
  * Matches the required toError first statement (with or without comment-out).
16
16
  * Group 1 = variable name, group 2 = param passed to toError
17
+ *
18
+ * The optional `//` prefix is what makes Fix Option 2 — "to explicitly ignore the error, write
19
+ * `//const error = toError(err);`" — a real escape. It is only reachable when the pattern is tested
20
+ * against the RAW source line; see findToErrorStatement() for why both line arrays are needed.
17
21
  */
18
22
  const TO_ERROR_PATTERN = /^\s*(?:\/\/\s*)?const\s+(\w+)\s*=\s*toError\(\s*(\w+)\s*\)\s*;?\s*$/;
19
- function findToErrorStatement(lines, startIndex) {
20
- for (let j = startIndex; j < lines.length; j += 1) {
21
- const line = lines[j].trim();
22
- if (line === '' || line === '{')
23
- continue;
24
- const match = TO_ERROR_PATTERN.exec(line);
25
- if (match) {
26
- return { varName: match[1], paramName: match[2], lineIndex: j };
27
- }
28
- // First non-blank line is not a toError call
29
- return 'not-found';
30
- }
31
- // Ran off the end of the edit content — can't validate further
32
- return 'end-of-content';
33
- }
23
+ /**
24
+ * What `stripTsNoise` leaves behind where a `//` comment was: the two slashes, then blanks to the end of
25
+ * the line. Trimming a stripped comment line therefore yields `//`, not `''`. See findToErrorStatement().
26
+ */
27
+ const COMMENT_REMNANT = /\/\/\s*$/;
34
28
  class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
35
29
  constructor(config) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }
36
30
  description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string
@@ -72,7 +66,7 @@ class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
72
66
  violations.push(new types_1.Violation(lineNum, ctx.lines[i].trim(), msg));
73
67
  }
74
68
  // Find next non-blank line after the catch opening to check for toError
75
- const toErrorResult = findToErrorStatement(lines, i + 1);
69
+ const toErrorResult = this.findToErrorStatement(ctx, i + 1);
76
70
  if (toErrorResult === 'not-found') {
77
71
  violations.push(new types_1.Violation(lineNum, ctx.lines[i].trim(), `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`));
78
72
  }
@@ -92,6 +86,50 @@ class CatchErrorPatternRule extends rule_base_1.EditRuleBase {
92
86
  (0, instruct_ai_writer_1.writeTemplateIfMissing)(ctx.workspaceRoot, 'webpieces.exceptions.md');
93
87
  return violations;
94
88
  }
89
+ /**
90
+ * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the
91
+ * whole fix for a bug that made this rule refuse the exact cure it prescribes.
92
+ *
93
+ * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a
94
+ * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and
95
+ * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the
96
+ * documented way to say "this error is deliberately ignored", was reported as "no toError statement"
97
+ * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing
98
+ * carrying a `//` ever reached it. 34 catches in this repo already use that form.
99
+ *
100
+ * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what
101
+ * counts as "the first statement" (a blank line, a `{`, or an unrelated comment is skipped past).
102
+ * Raw is tested first because it is the only array in which the comment form survives; a live
103
+ * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the
104
+ * `$` anchor and is then matched on its stripped form, which is exactly the intent.
105
+ *
106
+ * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`
107
+ * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`
108
+ * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,
109
+ * so it was taken for the first statement — which is the mechanical reason a commented-out toError
110
+ * reported "no toError statement", and the reason a trailing `// why` on a LIVE toError reported the
111
+ * same. One replace fixes both, in the one place the question is asked.
112
+ */
113
+ findToErrorStatement(ctx, startIndex) {
114
+ const stripped = ctx.strippedLines;
115
+ for (let j = startIndex; j < stripped.length; j += 1) {
116
+ const rawMatch = TO_ERROR_PATTERN.exec((ctx.lines[j] ?? '').trim());
117
+ if (rawMatch)
118
+ return { varName: rawMatch[1], paramName: rawMatch[2], lineIndex: j };
119
+ const line = stripped[j].replace(COMMENT_REMNANT, '').trim();
120
+ // Blank, an opening brace, or a comment that stripping emptied — none of these is the first
121
+ // statement, so keep looking.
122
+ if (line === '' || line === '{')
123
+ continue;
124
+ const match = TO_ERROR_PATTERN.exec(line);
125
+ if (match)
126
+ return { varName: match[1], paramName: match[2], lineIndex: j };
127
+ // First real statement is not a toError call
128
+ return 'not-found';
129
+ }
130
+ // Ran off the end of the edit content — can't validate further
131
+ return 'end-of-content';
132
+ }
95
133
  }
96
134
  exports.CatchErrorPatternRule = CatchErrorPatternRule;
97
135
  //# sourceMappingURL=catch-error-pattern.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":";;;AAAA,0DAAsF;AAGtF,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAqD;AACrD,8DAA+D;AAE/D;;;GAGG;AACH,MAAM,aAAa,GAAG,4CAA4C,CAAC;AAEnE;;;GAGG;AACH,MAAM,gBAAgB,GAAG,qEAAqE,CAAC;AAQ/F,SAAS,oBAAoB,CAAC,KAAwB,EAAE,UAAkB;IACtE,KAAK,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAChD,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC7B,IAAI,IAAI,KAAK,EAAE,IAAI,IAAI,KAAK,GAAG;YAAE,SAAS;QAE1C,MAAM,KAAK,GAAG,gBAAgB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1C,IAAI,KAAK,EAAE,CAAC;YACR,OAAO,EAAE,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC;QACpE,CAAC;QACD,6CAA6C;QAC7C,OAAO,WAAW,CAAC;IACvB,CAAC;IACD,+DAA+D;IAC/D,OAAO,gBAAgB,CAAC;AAC5B,CAAC;AAED,MAAa,qBAAsB,SAAQ,wBAAqC;IAC5E,YAAY,MAA+B,IAAI,KAAK,CAAC,MAAM,EAAE,qBAAqB,EAAE,qBAAqB,CAAC,CAAC,CAAC,CAAC;IAEpG,WAAW,GAAG,6EAA6E,CAAC,CAAC,gFAAgF;IACpK,KAAK,GAAG,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;IAClD,IAAI,OAAO;QACP,OAAO,IAAI,kBAAO,CACd,uDAAuD,EACvD,sEAAsE,EACtE;YACI,IAAI,qBAAM,CAAC,4EAA4E,EAAE,IAAI,CAAC;YAC9F,IAAI,qBAAM,CAAC,+DAA+D,CAAC;SAC9E,EACD,IAAI,wBAAa,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,IAAI,EAAE,sDAAsD,CAAC,CAChH,CAAC;IACN,CAAC;IAED,KAAK,CAAC,GAAgB;QAClB,MAAM,cAAc,GAAG,IAAI,CAAC,MAAM,CAAC,cAAc,IAAI,IAAI,CAAC;QAC1D,MAAM,UAAU,GAAQ,EAAE,CAAC;QAC3B,MAAM,KAAK,GAAG,GAAG,CAAC,aAAa,CAAC;QAEhC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YACvC,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YAC1B,MAAM,UAAU,GAAG,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAChD,IAAI,CAAC,UAAU;gBAAE,SAAS;YAE1B,MAAM,OAAO,GAAG,CAAC,GAAG,CAAC,CAAC;YACtB,IAAI,cAAc,IAAI,GAAG,CAAC,cAAc,CAAC,OAAO,EAAE,yBAAU,CAAC,mBAAmB,CAAC;gBAAE,SAAS;YAE5F,MAAM,WAAW,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;YAClC,MAAM,cAAc,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;YAErC,gFAAgF;YAChF,MAAM,WAAW,GAAG,WAAW,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;YACpD,MAAM,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,MAAM,aAAa,GAAG,KAAK,GAAG,MAAM,CAAC;YACrC,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;YAErC,uBAAuB;YACvB,IAAI,WAAW,KAAK,aAAa,EAAE,CAAC;gBAChC,UAAU,CAAC,IAAI,CAAC,IAAI,iBAAC,CACjB,OAAO,EACP,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EACnB,kCAAkC,aAAa,kDAAkD,WAAW,GAAG,CAClH,CAAC,CAAC;YACP,CAAC;YAED,mCAAmC;YACnC,IAAI,cAAc,KAAK,SAAS,EAAE,CAAC;gBAC/B,MAAM,GAAG,GAAG,cAAc;oBACtB,CAAC,CAAC,sDAAsD,aAAa,oBAAoB,cAAc,GAAG;oBAC1G,CAAC,CAAC,sDAAsD,aAAa,YAAY,CAAC;gBACtF,UAAU,CAAC,IAAI,CAAC,IAAI,iBAAC,CAAC,OAAO,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC;YAC9D,CAAC;YAED,wEAAwE;YACxE,MAAM,aAAa,GAAG,oBAAoB,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;YACzD,IAAI,aAAa,KAAK,WAAW,EAAE,CAAC;gBAChC,UAAU,CAAC,IAAI,CAAC,IAAI,iBAAC,CACjB,OAAO,EACP,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EACnB,iCAAiC,WAAW,+BAA+B,WAAW,cAAc,WAAW,iBAAiB,WAAW,cAAc,WAAW,IAAI,CAC3K,CAAC,CAAC;YACP,CAAC;iBAAM,IAAI,aAAa,KAAK,gBAAgB,EAAE,CAAC;gBAC5C,yCAAyC;gBACzC,IAAI,aAAa,CAAC,OAAO,KAAK,WAAW,EAAE,CAAC;oBACxC,MAAM,cAAc,GAAG,aAAa,CAAC,SAAS,GAAG,CAAC,CAAC;oBACnD,UAAU,CAAC,IAAI,CAAC,IAAI,iBAAC,CACjB,cAAc,EACd,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EACzC,iCAAiC,WAAW,WAAW,aAAa,CAAC,OAAO,GAAG,CAClF,CAAC,CAAC;gBACP,CAAC;gBACD,IAAI,aAAa,CAAC,SAAS,KAAK,WAAW,EAAE,CAAC;oBAC1C,MAAM,cAAc,GAAG,aAAa,CAAC,SAAS,GAAG,CAAC,CAAC;oBACnD,UAAU,CAAC,IAAI,CAAC,IAAI,iBAAC,CACjB,cAAc,EACd,GAAG,CAAC,KAAK,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EACzC,kCAAkC,WAAW,WAAW,aAAa,CAAC,SAAS,GAAG,CACrF,CAAC,CAAC;gBACP,CAAC;YACL,CAAC;QACL,CAAC;QACD,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC;YAAE,IAAA,2CAAsB,EAAC,GAAG,CAAC,aAAa,EAAE,yBAAyB,CAAC,CAAC;QAChG,OAAO,UAAU,CAAC;IACtB,CAAC;CACJ;AAvFD,sDAuFC","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES, Option } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nfunction findToErrorStatement(lines: readonly string[], startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n for (let j = startIndex; j < lines.length; j += 1) {\n const line = lines[j].trim();\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) {\n return { varName: match[1], paramName: match[2], lineIndex: j };\n }\n // First non-blank line is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = findToErrorStatement(lines, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n}\n"]}
1
+ {"version":3,"file":"catch-error-pattern.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/catch-error-pattern.ts"],"names":[],"mappings":"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","sourcesContent":["import { CatchErrorPatternConfig, RULE_NAMES, Option } from '@webpieces/rules-config';\n\nimport type { EditContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { EditRuleBase } from '../rule-base';\nimport { FixHint, DisableEscape } from '../fix-hint';\nimport { writeTemplateIfMissing } from '../instruct-ai-writer';\n\n/**\n * Matches a catch clause opening: } catch (paramName: typeAnnotation) {\n * Captures: group 1 = param name, group 2 = type annotation (if present)\n */\nconst CATCH_PATTERN = /\\bcatch\\s*\\(\\s*(\\w+)(?:\\s*:\\s*(\\w+))?\\s*\\)/;\n\n/**\n * Matches the required toError first statement (with or without comment-out).\n * Group 1 = variable name, group 2 = param passed to toError\n *\n * The optional `//` prefix is what makes Fix Option 2 — \"to explicitly ignore the error, write\n * `//const error = toError(err);`\" — a real escape. It is only reachable when the pattern is tested\n * against the RAW source line; see findToErrorStatement() for why both line arrays are needed.\n */\nconst TO_ERROR_PATTERN = /^\\s*(?:\\/\\/\\s*)?const\\s+(\\w+)\\s*=\\s*toError\\(\\s*(\\w+)\\s*\\)\\s*;?\\s*$/;\n\n/**\n * What `stripTsNoise` leaves behind where a `//` comment was: the two slashes, then blanks to the end of\n * the line. Trimming a stripped comment line therefore yields `//`, not `''`. See findToErrorStatement().\n */\nconst COMMENT_REMNANT = /\\/\\/\\s*$/;\n\ninterface ToErrorMatch {\n varName: string;\n paramName: string;\n lineIndex: number;\n}\n\nexport class CatchErrorPatternRule extends EditRuleBase<CatchErrorPatternConfig> {\n constructor(config: CatchErrorPatternConfig) { super(config, 'catch-error-pattern', 'catch-error-pattern'); }\n\n readonly description = 'Catch blocks must use: catch (err: unknown) { const error = toError(err); }'; // webpieces-disable catch-error-pattern -- example text in a description string\n override readonly files = ['**/*.ts', '**/*.tsx'];\n get fixHint(): FixHint {\n return new FixHint(\n 'Catch block does not follow the toError(err) pattern.',\n 'Name the catch parameter err (err2/err3 when nested), then pick one:',\n [\n new Option('Add as the first statement in the catch block: const error = toError(err);', true),\n new Option('To explicitly ignore the error: //const error = toError(err);'),\n ],\n new DisableEscape(this.config.disableAllowed ?? true, '// webpieces-disable catch-error-pattern -- <reason>'), // webpieces-disable catch-error-pattern -- example text in a hint string\n );\n }\n\n check(ctx: EditContext): readonly Violation[] {\n const disableAllowed = this.config.disableAllowed ?? true;\n const violations: V[] = [];\n const lines = ctx.strippedLines;\n\n for (let i = 0; i < lines.length; i += 1) {\n const stripped = lines[i];\n const catchMatch = CATCH_PATTERN.exec(stripped);\n if (!catchMatch) continue;\n\n const lineNum = i + 1;\n if (disableAllowed && ctx.isLineDisabled(lineNum, RULE_NAMES.CATCH_ERROR_PATTERN)) continue;\n\n const actualParam = catchMatch[1];\n const typeAnnotation = catchMatch[2];\n\n // Determine expected names from suffix on the actual param (err, err2, err3...)\n const suffixMatch = actualParam.match(/^err(\\d*)$/);\n const suffix = suffixMatch ? suffixMatch[1] : '';\n const expectedParam = 'err' + suffix;\n const expectedVar = 'error' + suffix;\n\n // Check parameter name\n if (actualParam !== expectedParam) {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch parameter must be named \"${expectedParam}\" (or \"err2\", \"err3\" for nested catches), got \"${actualParam}\"`,\n ));\n }\n\n // Check type annotation is unknown\n if (typeAnnotation !== 'unknown') {\n const msg = typeAnnotation\n ? `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown), got \"${typeAnnotation}\"`\n : `Catch parameter must be typed as \"unknown\": catch (${expectedParam}: unknown)`;\n violations.push(new V(lineNum, ctx.lines[i].trim(), msg));\n }\n\n // Find next non-blank line after the catch opening to check for toError\n const toErrorResult = this.findToErrorStatement(ctx, i + 1);\n if (toErrorResult === 'not-found') {\n violations.push(new V(\n lineNum,\n ctx.lines[i].trim(),\n `Catch block must call toError(${actualParam}) as first statement: const ${expectedVar} = toError(${actualParam}); or //const ${expectedVar} = toError(${actualParam});`,\n ));\n } else if (toErrorResult !== 'end-of-content') {\n // Validate variable name and param match\n if (toErrorResult.varName !== expectedVar) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `Error variable must be named \"${expectedVar}\", got \"${toErrorResult.varName}\"`,\n ));\n }\n if (toErrorResult.paramName !== actualParam) {\n const toErrorLineNum = toErrorResult.lineIndex + 1;\n violations.push(new V(\n toErrorLineNum,\n ctx.lines[toErrorResult.lineIndex].trim(),\n `toError() must be called with \"${actualParam}\", got \"${toErrorResult.paramName}\"`,\n ));\n }\n }\n }\n if (violations.length > 0) writeTemplateIfMissing(ctx.workspaceRoot, 'webpieces.exceptions.md');\n return violations;\n }\n\n /**\n * The first statement of the catch block, judged against BOTH line arrays — and that pairing is the\n * whole fix for a bug that made this rule refuse the exact cure it prescribes.\n *\n * The rule FINDS catch clauses in `ctx.strippedLines`, which is right: a `catch (e) {` inside a\n * comment is not a catch clause. But it used to also LOOK FOR the toError statement there, and\n * stripping deletes `//const error = toError(err);` down to an empty line — so Fix Option 2, the\n * documented way to say \"this error is deliberately ignored\", was reported as \"no toError statement\"\n * every single time. TO_ERROR_PATTERN's optional `//` prefix could never match, because nothing\n * carrying a `//` ever reached it. 34 catches in this repo already use that form.\n *\n * So: the RAW line decides whether the commented form is present, and the STRIPPED line decides what\n * counts as \"the first statement\" (a blank line, a `{`, or an unrelated comment is skipped past).\n * Raw is tested first because it is the only array in which the comment form survives; a live\n * statement with a trailing comment (`const error = toError(err); // why`) fails the raw test on the\n * `$` anchor and is then matched on its stripped form, which is exactly the intent.\n *\n * COMMENT_REMNANT is the other half of that, and it is a property of the stripper: `stripTsNoise`\n * KEEPS the `//` marker and blanks only what follows it, so a stripped comment line trims to `//`\n * rather than to the empty string. Without normalizing that away, `//` is neither blank nor a `{`,\n * so it was taken for the first statement — which is the mechanical reason a commented-out toError\n * reported \"no toError statement\", and the reason a trailing `// why` on a LIVE toError reported the\n * same. One replace fixes both, in the one place the question is asked.\n */\n private findToErrorStatement(ctx: EditContext, startIndex: number): ToErrorMatch | 'not-found' | 'end-of-content' {\n const stripped = ctx.strippedLines;\n for (let j = startIndex; j < stripped.length; j += 1) {\n const rawMatch = TO_ERROR_PATTERN.exec((ctx.lines[j] ?? '').trim());\n if (rawMatch) return { varName: rawMatch[1], paramName: rawMatch[2], lineIndex: j };\n\n const line = stripped[j].replace(COMMENT_REMNANT, '').trim();\n // Blank, an opening brace, or a comment that stripping emptied — none of these is the first\n // statement, so keep looking.\n if (line === '' || line === '{') continue;\n\n const match = TO_ERROR_PATTERN.exec(line);\n if (match) return { varName: match[1], paramName: match[2], lineIndex: j };\n // First real statement is not a toError call\n return 'not-found';\n }\n // Ran off the end of the edit content — can't validate further\n return 'end-of-content';\n }\n}\n"]}
@@ -58,7 +58,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
58
58
  };
59
59
  fixHint = new fix_hint_1.FixHint('This branch is already merged into main — do not keep working here.', 'Get onto a fresh branch off origin/main, then retry:', [
60
60
  new rules_config_1.Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),
61
- new rules_config_1.Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-cleanup, and installs/upgrades.'),
61
+ new rules_config_1.Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-checkout-clean-main and pnpm wp-cleanup, and installs/upgrades.'),
62
62
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
63
63
  ]);
64
64
  check(ctx) {
@@ -1 +1 @@
1
- {"version":3,"file":"merged-branch-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the merged-branch protection — the gap that let a whole session run on an\n * already-merged branch.\n *\n * feature-branch-guard blocks Write/Edit and read-stale-guard blocks the Read tool when the\n * checked-out branch's PR is already merged into main, but BOTH are file-scoped: a `runBash()` command\n * never reaches either. So an agent that only ran shell — `scripts/local.sh start lang` (boots\n * servers), `cat`/`ls` of repo files, git — sailed through, even though the very same\n * `branchAlreadyMerged` flag was loaded and logged on the Bash path (the `calls/` stream →\n * `merged=PR#…`). It was computed and thrown away; nothing consulted it for a block.\n *\n * Those two file guards intentionally leave Bash alone (\"every cure is a Bash command, so Bash is the\n * escape hatch — never wedge it\"). This guard therefore DEFAULT-DENIES Bash on a merged branch but\n * allowlists exactly the commands that get you OFF the branch (the fresh-start / cleanup git commands,\n * switching away, read-only orientation, wp-* cleanup, installs). The redirect it returns names those\n * same commands, so following it can never re-trip the guard — the agent is redirected, not wedged.\n *\n * FAIL-OPEN like its siblings: branch undeterminable, no cache yet, or a cache for a DIFFERENT branch\n * → allow. The cache is per-branch (`status.branch` is the branch it was computed FOR), so acting on\n * another branch's snapshot is never allowed.\n *\n * On the DELIBERATELY-UNFIXED staleness window: the cache is only as fresh as the last detached\n * refresh, so for a few seconds after a merge lands mid-session it can still read `merged=NO` and this\n * guard fails open. That window is tiny and self-closing — agents burst tool calls every few seconds\n * and every Bash call re-triggers the refresh, so `branchAlreadyMerged` flips within 1–3 calls and the\n * next command is caught. Closing it synchronously would require the slow `gh pr list` on the blocking\n * path (the thing the whole cache design avoids) and would mean blocking on stale/uncertain data,\n * which violates the fail-open principle every one of these guards is built on. Not worth it.\n */\nexport class MergedBranchBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'merged-branch-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n // ROW 4, the skip list — shared with stale-main-bash-guard so the two states cannot drift apart\n // about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block ordinary Bash on an already-merged branch (allowlisting only recovery/cleanup and ' +\n 'read-only inspection commands), so a session cannot proceed on a stale post-merge branch.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is already merged into main — do not keep working here.',\n 'Get onto a fresh branch off origin/main, then retry:',\n [\n new Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),\n new Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-cleanup, and installs/upgrades.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine the branch (not a git repo, git unavailable) → never block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command. (The\n // runner also warms it, but only when feature-branch-guard is loaded — do it here too so this\n // guard is self-sufficient when that one is off.)\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n // No cache yet (first command of the session), or a branch this refresh has not seen → allow;\n // the refresh we just spawned populates it.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // For THIS guard the merged flag is the ONLY block condition, so an unreachable forge means\n // it is fully abstaining — the state it exists to catch cannot be observed at all.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n\n // Merged. Allow ONLY when every segment of the command is a recovery / cleanup / read-only\n // inspection command — anything else (servers, builds, tests, cat/ls of repo files, git writes)\n // is denied with the redirect.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'merged-branch recovery/inspection (allowlisted)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);\n }\n\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forBash(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // One-line summary of the async-written cache that drove this decision (mirrors the file guards),\n // so a wrong allow/block is traceable to the exact main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('merged-branch-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n\n// git subcommands that are recovery/cleanup OR read-only orientation, and so stay allowed on a merged\n// branch. Everything NOT here (commit, merge, rebase, push, reset, add, restore, clean, cherry-pick,\n// …) is a \"keep working\" operation and is denied with the redirect. `worktree` covers add/remove/prune\n// (branch-creation-guard governs which worktree adds are legal); `branch` covers listing and `-D`\n// cleanup (branch-creation-guard governs creation); `pull` is the on-main update, itself gated by\n// redirect-how-to-merge-main. Reading git METADATA (log/diff/show) is fine — it is not the stale FILE\n// CONTENT that `cat` would surface, which is exactly why `git grep` (reads tracked content) is absent.\n"]}
1
+ {"version":3,"file":"merged-branch-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the merged-branch protection — the gap that let a whole session run on an\n * already-merged branch.\n *\n * feature-branch-guard blocks Write/Edit and read-stale-guard blocks the Read tool when the\n * checked-out branch's PR is already merged into main, but BOTH are file-scoped: a `runBash()` command\n * never reaches either. So an agent that only ran shell — `scripts/local.sh start lang` (boots\n * servers), `cat`/`ls` of repo files, git — sailed through, even though the very same\n * `branchAlreadyMerged` flag was loaded and logged on the Bash path (the `calls/` stream →\n * `merged=PR#…`). It was computed and thrown away; nothing consulted it for a block.\n *\n * Those two file guards intentionally leave Bash alone (\"every cure is a Bash command, so Bash is the\n * escape hatch — never wedge it\"). This guard therefore DEFAULT-DENIES Bash on a merged branch but\n * allowlists exactly the commands that get you OFF the branch (the fresh-start / cleanup git commands,\n * switching away, read-only orientation, wp-* cleanup, installs). The redirect it returns names those\n * same commands, so following it can never re-trip the guard — the agent is redirected, not wedged.\n *\n * FAIL-OPEN like its siblings: branch undeterminable, no cache yet, or a cache for a DIFFERENT branch\n * → allow. The cache is per-branch (`status.branch` is the branch it was computed FOR), so acting on\n * another branch's snapshot is never allowed.\n *\n * On the DELIBERATELY-UNFIXED staleness window: the cache is only as fresh as the last detached\n * refresh, so for a few seconds after a merge lands mid-session it can still read `merged=NO` and this\n * guard fails open. That window is tiny and self-closing — agents burst tool calls every few seconds\n * and every Bash call re-triggers the refresh, so `branchAlreadyMerged` flips within 1–3 calls and the\n * next command is caught. Closing it synchronously would require the slow `gh pr list` on the blocking\n * path (the thing the whole cache design avoids) and would mean blocking on stale/uncertain data,\n * which violates the fail-open principle every one of these guards is built on. Not worth it.\n */\nexport class MergedBranchBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'merged-branch-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n // ROW 4, the skip list — shared with stale-main-bash-guard so the two states cannot drift apart\n // about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block ordinary Bash on an already-merged branch (allowlisting only recovery/cleanup and ' +\n 'read-only inspection commands), so a session cannot proceed on a stale post-merge branch.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is already merged into main — do not keep working here.',\n 'Get onto a fresh branch off origin/main, then retry:',\n [\n new Option('git fetch origin main && git checkout -b <new-branch> origin/main (in a worktree: git worktree add ../<dir> -b <new> origin/main). Then re-run your command.', true),\n new Option('Still allowed here: recovery/cleanup git, read-only git status|log|diff|show|branch and gh pr list|view, switching branches/worktrees, pnpm wp-checkout-clean-main and pnpm wp-cleanup, and installs/upgrades.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine the branch (not a git repo, git unavailable) → never block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command. (The\n // runner also warms it, but only when feature-branch-guard is loaded — do it here too so this\n // guard is self-sufficient when that one is off.)\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n // No cache yet (first command of the session), or a branch this refresh has not seen → allow;\n // the refresh we just spawned populates it.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // For THIS guard the merged flag is the ONLY block condition, so an unreachable forge means\n // it is fully abstaining — the state it exists to catch cannot be observed at all.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n\n // Merged. Allow ONLY when every segment of the command is a recovery / cleanup / read-only\n // inspection command — anything else (servers, builds, tests, cat/ls of repo files, git writes)\n // is denied with the redirect.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'merged-branch recovery/inspection (allowlisted)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);\n }\n\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forBash(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // One-line summary of the async-written cache that drove this decision (mirrors the file guards),\n // so a wrong allow/block is traceable to the exact main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('merged-branch-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n\n// git subcommands that are recovery/cleanup OR read-only orientation, and so stay allowed on a merged\n// branch. Everything NOT here (commit, merge, rebase, push, reset, add, restore, clean, cherry-pick,\n// …) is a \"keep working\" operation and is denied with the redirect. `worktree` covers add/remove/prune\n// (branch-creation-guard governs which worktree adds are legal); `branch` covers listing and `-D`\n// cleanup (branch-creation-guard governs creation); `pull` is the on-main update, itself gated by\n// redirect-how-to-merge-main. Reading git METADATA (log/diff/show) is fine — it is not the stale FILE\n// CONTENT that `cat` would surface, which is exactly why `git grep` (reads tracked content) is absent.\n"]}
@@ -50,14 +50,15 @@ class MergedBranchMessage {
50
50
  ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +
51
51
  'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'
52
52
  : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +
53
- '`git checkout main && git pull origin main && pnpm wp-cleanup` is allowed and is the ' +
54
- 'shortest exit; also git worktree add/remove/prune';
53
+ '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +
54
+ 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +
55
+ 'git worktree add/remove/prune';
55
56
  return [
56
57
  'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',
57
58
  ' - the fresh-start / cleanup git commands above',
58
59
  ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',
59
60
  switching,
60
- ' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
61
+ ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',
61
62
  ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',
62
63
  ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',
63
64
  '',