@webpieces/ai-hook-rules 0.4.697 → 0.4.698
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.
- package/package.json +2 -2
- package/src/core/command-scan.d.ts +21 -9
- package/src/core/command-scan.js +18 -16
- package/src/core/command-scan.js.map +1 -1
- package/src/core/l2-doc.js +27 -2
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-rows.d.ts +1 -1
- package/src/core/l2-rows.js +18 -4
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/read-only-inspection.js +1 -1
- package/src/core/read-only-inspection.js.map +1 -1
- package/src/core/rules/build-output-pipe-scan.js +2 -2
- package/src/core/rules/build-output-pipe-scan.js.map +1 -1
- package/src/core/rules/content-read-scan.js +1 -1
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/cure-prefix-scan.d.ts +64 -0
- package/src/core/rules/cure-prefix-scan.js +133 -0
- package/src/core/rules/cure-prefix-scan.js.map +1 -0
- package/src/core/rules/read-stale-guard.d.ts +5 -4
- package/src/core/rules/read-stale-guard.js +9 -8
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/recovery-allowlist.js +1 -1
- package/src/core/rules/recovery-allowlist.js.map +1 -1
- package/src/core/rules/shell-segment-scan.js +1 -1
- package/src/core/rules/shell-segment-scan.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +28 -0
- package/src/core/rules/stale-main-bash-guard.js +45 -1
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +19 -8
- package/src/core/rules/stale-main-message.js +25 -13
- package/src/core/rules/stale-main-message.js.map +1 -1
package/src/core/l2-rows.js.map
CHANGED
|
@@ -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` AND `E` PART COMPANY ON `main`, and rows 5/6/7 are where. A WRITE on `main` is wrong at any\n * freshness — the work lands somewhere unreviewable and unrevertable — so row 5 is `E` only, judged on\n * the branch alone, above the divider. A READ or a BUILD on a CURRENT `main` is harmless, and blocking\n * it strands the agent right after `pnpm wp-checkout-clean-main` put it there; so `B` joins `R` on the\n * FRESHNESS-gated pair below the divider (row 6 behind → block, row 7 current → allow), where \"cannot\n * tell\" fails open at row 11 by construction. `B` and `R` still differ in SHAPE inside row 6: a Read\n * names one file and is judged precisely, a Bash command is opaque and gets default-deny plus row 4.\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(28,\n '`gh pr close 123`, `gh pr comment`, `gh api …` or a `curl` while parked on a stale `main` or a merged branch',\n 'blocked state, running something that touches GitHub or a URL and nothing in this tree',\n 'ALLOW: the skip list asks one question — does this read or write repo CONTENT? `gh` talks to GitHub and `curl`/`wget` talk to a network, so the branch state has nothing to say about them. The forms that write a local file (`gh repo clone`, `gh pr checkout`, `curl -o`, any `> file`) are excluded, and `gh pr create`/`merge` remain governed by their own guards',\n 'None needed',\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, ['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(25,\n 'The FIRST edit 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 why the row is `E` only and must never be gated on the cache',\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(27,\n 'A build, a `cat` or a `curl` on `main`, on the first Bash call of a session',\n 'on `main`, cache absent — so whether `main` is behind is UNKNOWN',\n 'ALLOW (fail-open), logged `ALLOW_FAIL_OPEN`. `B` on `main` is judged by rows 6/7 and therefore lands here when the cache cannot answer; the WRITE half is not, which is why row 5 sits above this divider',\n 'None — the second call is judged normally',\n 'no-sync-cache'),\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, ['B', '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 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, same staleness, different tool',\n 'BLOCK: `B` is judged here beside `R`, so closing the Read tool no longer opens a shell-shaped hole. 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-stale-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 a `main` known to be BEHIND, and the write is incidental to a command whose stated purpose is something else',\n 'BLOCK: inside this row `B` is default-DENY plus row 4\\'s skip list, never a blocklist of readers — 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-stale-main'),\n ]),\n new L2Row(7, ['B', '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: ancestry, not hash equality, so the allow arrives the instant the pull lands rather than when the detached refresher next runs',\n 'None needed',\n 'local-main-contains-origin (up to date)'),\n new L2UseCase(24,\n '`curl`, `gh pr close` or a test run, immediately after `pnpm wp-checkout-clean-main` landed you on a perfectly current `main`',\n 'on `main`, current — no staleness anywhere',\n 'ALLOW. This used to BLOCK, from the branch alone: the tool the repo prescribes put the agent here, and the guard whose name says STALE then refused everything off a narrow allowlist for a reason that had nothing to do with staleness. WRITES here are still blocked, by row 5 — that hazard is real at any freshness',\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 WRITE on main, at any freshness. `E` only; the Bash half is rows 6/7.\n 'on-main': 5,\n // Row 6/7 — freshness, for `B` and `R` alike. Both guards log these two literals: read-stale-guard\n // for the Read tool, stale-main-bash-guard for Bash. Same cache, same ancestry test, one verdict.\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 and then MOVED: on `main`, a write is judged from\n // the branch alone (row 5, above the divider) while Bash is judged on freshness beside the Read tool\n // (rows 6/7), because a build on a CURRENT `main` harms nothing and denying it stranded agents on\n // the very `main` `pnpm wp-checkout-clean-main` had just handed them. Rows 6 and 8 held DIRTY-TREE\n // 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 TWELVE 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 THIRTEEN 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` AND `E` PART COMPANY ON `main`, and rows 5/6/7 are where. A WRITE on `main` is wrong at any\n * freshness — the work lands somewhere unreviewable and unrevertable — so row 5 is `E` only, judged on\n * the branch alone, above the divider. A READ or a BUILD on a CURRENT `main` is harmless, and blocking\n * it strands the agent right after `pnpm wp-checkout-clean-main` put it there; so `B` joins `R` on the\n * FRESHNESS-gated pair below the divider (row 6 behind → block, row 7 current → allow), where \"cannot\n * tell\" fails open at row 11 by construction. `B` and `R` still differ in SHAPE inside row 6: a Read\n * names one file and is judged precisely, a Bash command is opaque and gets default-deny plus row 4.\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(28,\n '`gh pr close 123`, `gh pr comment`, `gh api …` or a `curl` while parked on a stale `main` or a merged branch',\n 'blocked state, running something that touches GitHub or a URL and nothing in this tree',\n 'ALLOW: the skip list asks one question — does this read or write repo CONTENT? `gh` talks to GitHub and `curl`/`wget` talk to a network, so the branch state has nothing to say about them. The forms that write a local file (`gh repo clone`, `gh pr checkout`, `curl -o`, any `> file`) are excluded, and `gh pr create`/`merge` remain governed by their own guards',\n 'None needed',\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, ['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(25,\n 'The FIRST edit 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 why the row is `E` only and must never be gated on the cache',\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(27,\n 'A build, a `cat` or a `curl` on `main`, on the first Bash call of a session',\n 'on `main`, cache absent — so whether `main` is behind is UNKNOWN',\n 'ALLOW (fail-open), logged `ALLOW_FAIL_OPEN`. `B` on `main` is judged by rows 6/7 and therefore lands here when the cache cannot answer; the WRITE half is not, which is why row 5 sits above this divider',\n 'None — the second call is judged normally',\n 'no-sync-cache'),\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 // ROWS 12/13 sit ABOVE row 6 because they are judged inside its state: local `main` is established\n // BEHIND, and the only remaining question is whether the command already carries its own cure. They\n // are numbered 12/13 rather than slotted in as 6a/6b for the reason row 11 is numbered 11 — row\n // numbers are logged as `row=` and cited here, so renumbering re-points every existing reference.\n new L2Row(12, ['B'], 'on `main`, behind `origin/main`, and the command STARTS with a refresh-main cure joined to the work by `&&`', L2_ALLOW, '—', [\n new L2UseCase(29,\n '`git fetch --prune origin main -q && git pull --ff-only origin main 2>&1 | tail -1 && sed -n \\'30,75p\\' src/app.ts` — the agent cures and reads in one call',\n 'on `main`, behind `origin/main`, cure first, `&&` between',\n 'ALLOW: `&&` short-circuits, so the `sed` never runs if the pull fails — the guard was refusing a safety property the shell already enforces. Measured fleet-wide as `cure_bundled_and`, and filed as a TOOLING defect, not an agent one',\n 'None needed',\n 'cure-prefixed, && short-circuits the work'),\n ]),\n new L2Row(13, ['B'], 'on `main`, behind `origin/main`, and the cure is joined to the work by `;` (or `||`, `&`, a newline) — the work runs even if the cure fails', L2_BLOCK, '`pnpm wp-checkout-clean-main && <your command>`', [\n new L2UseCase(30,\n '`pnpm wp-checkout-clean-main >/dev/null 2>&1; git log --oneline -1; sed -n \\'598,612p\\' eslint.config.mjs` — and the agent then quotes an eslint rule out of a file 15 commits stale',\n 'on `main`, behind `origin/main`, cure first, `;` between',\n 'BLOCK: `;` discards the cure\\'s exit code, so a conflict, a dirty tree or no network leaves the `sed` reading still-stale content — and 7 of the 9 observed cases also silenced the cure with `>/dev/null 2>&1`, so the failure was invisible too. The two-step is safer because the NEXT tool call re-computes `localMain` against `originMain`, so a failed pull re-blocks; an allowed `;` compound never gets that second look',\n 'Swap the `;` for `&&` — `pnpm wp-checkout-clean-main && <your command>` — or run the cure alone and re-issue the command in the next call',\n 'cure-prefixed, work runs anyway'),\n ]),\n new L2Row(6, ['B', 'R'], 'on `main`, behind `origin/main`', L2_BLOCK, '`pnpm wp-checkout-clean-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 '`pnpm wp-checkout-clean-main`, or `git checkout -b <new> origin/main`',\n 'on-stale-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, same staleness, different tool',\n 'BLOCK: `B` is judged here beside `R`, so closing the Read tool no longer opens a shell-shaped hole. 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-stale-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 a `main` known to be BEHIND, and the write is incidental to a command whose stated purpose is something else',\n 'BLOCK: inside this row `B` is default-DENY plus row 4\\'s skip list, never a blocklist of readers — 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-stale-main'),\n ]),\n new L2Row(7, ['B', '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: ancestry, not hash equality, so the allow arrives the instant the pull lands rather than when the detached refresher next runs',\n 'None needed',\n 'local-main-contains-origin (up to date)'),\n new L2UseCase(24,\n '`curl`, `gh pr close` or a test run, immediately after `pnpm wp-checkout-clean-main` landed you on a perfectly current `main`',\n 'on `main`, current — no staleness anywhere',\n 'ALLOW. This used to BLOCK, from the branch alone: the tool the repo prescribes put the agent here, and the guard whose name says STALE then refused everything off a narrow allowlist for a reason that had nothing to do with staleness. WRITES here are still blocked, by row 5 — that hazard is real at any freshness',\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 WRITE on main, at any freshness. `E` only; the Bash half is rows 6/7.\n 'on-main': 5,\n // Row 6/7 — freshness, for `B` and `R` alike. Both guards log these two literals: read-stale-guard\n // for the Read tool, stale-main-bash-guard for Bash. Same cache, same ancestry test, one verdict.\n 'on-stale-main': 6,\n 'local-main-contains-origin (up to date)': 7,\n // Rows 12/13 — composition, judged INSIDE row 6's state: the tree is established behind, and the\n // only remaining question is whether the command carries its own cure and with which operator.\n 'cure-prefixed, && short-circuits the work': 12,\n 'cure-prefixed, work runs anyway': 13,\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 and then MOVED: on `main`, a write is judged from\n // the branch alone (row 5, above the divider) while Bash is judged on freshness beside the Read tool\n // (rows 6/7), because a build on a CURRENT `main` harms nothing and denying it stranded agents on\n // the very `main` `pnpm wp-checkout-clean-main` had just handed them. Rows 6 and 8 held DIRTY-TREE\n // 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"]}
|
|
@@ -37,7 +37,7 @@ class ReadOnlyInspectionScan {
|
|
|
37
37
|
}
|
|
38
38
|
/** True only when EVERY segment of the command is provably inert. Empty command → false. */
|
|
39
39
|
isReadOnlyInspection(command) {
|
|
40
|
-
const segments = this.scanner.
|
|
40
|
+
const segments = this.scanner.segmentsWithJoins(command);
|
|
41
41
|
if (segments.length === 0)
|
|
42
42
|
return false;
|
|
43
43
|
return segments.every((segment) => this.segmentIsInert(segment));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-only-inspection.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/read-only-inspection.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * Is this shell command pure INSPECTION — can it be trusted to change nothing?\n *\n * WHY this exists: when webpieces.config.json cannot be loaded (unparseable, or invalid against the\n * installed validator) every bash command is denied, because with no config there are no guards and\n * fail-closed is the only safe answer. That is right for work — and catastrophic for recovery, because\n * the denial also took out `cat`, `grep` and `sed -n` against webpieces.config.json itself. The guard\n * blocked the exact tools needed to see and fix the problem it was reporting, which is a dead end an\n * agent cannot escape from inside the session. (Reproduced live: mid-merge, the config legitimately\n * held `<<<<<<< HEAD` markers and every attempt to look at it was refused.)\n *\n * The rest of the system already models the escape hatch correctly — reading AND editing\n * webpieces.config.json is always allowed (hook-core's config bypass, the stale-shim recovery\n * carve-out, ContentReadScan's escape-hatch paths). This closes the one place that did not honour it.\n *\n * The bar is deliberately paranoid, because this is a bypass of ALL guards:\n * - every invoked segment's command word must be an allowlisted inspector;\n * - `git`/`gh` are excluded outright, even their read-only subcommands — the guards exist to police\n * git, and \"read-only git\" is not a line worth drawing while flying blind;\n * - any output redirect to a file (`> x`, `>> x`) makes the command a writer;\n * - the in-place/mutating flags of otherwise-read-only tools (`sed -i`, `find -delete`) are refused.\n * Anything not provably inert stays blocked.\n */\nexport class ReadOnlyInspectionScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(this.scanner);\n }\n\n /** True only when EVERY segment of the command is provably inert. Empty command → false. */\n isReadOnlyInspection(command: string): boolean {\n const segments = this.scanner.
|
|
1
|
+
{"version":3,"file":"read-only-inspection.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/read-only-inspection.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * Is this shell command pure INSPECTION — can it be trusted to change nothing?\n *\n * WHY this exists: when webpieces.config.json cannot be loaded (unparseable, or invalid against the\n * installed validator) every bash command is denied, because with no config there are no guards and\n * fail-closed is the only safe answer. That is right for work — and catastrophic for recovery, because\n * the denial also took out `cat`, `grep` and `sed -n` against webpieces.config.json itself. The guard\n * blocked the exact tools needed to see and fix the problem it was reporting, which is a dead end an\n * agent cannot escape from inside the session. (Reproduced live: mid-merge, the config legitimately\n * held `<<<<<<< HEAD` markers and every attempt to look at it was refused.)\n *\n * The rest of the system already models the escape hatch correctly — reading AND editing\n * webpieces.config.json is always allowed (hook-core's config bypass, the stale-shim recovery\n * carve-out, ContentReadScan's escape-hatch paths). This closes the one place that did not honour it.\n *\n * The bar is deliberately paranoid, because this is a bypass of ALL guards:\n * - every invoked segment's command word must be an allowlisted inspector;\n * - `git`/`gh` are excluded outright, even their read-only subcommands — the guards exist to police\n * git, and \"read-only git\" is not a line worth drawing while flying blind;\n * - any output redirect to a file (`> x`, `>> x`) makes the command a writer;\n * - the in-place/mutating flags of otherwise-read-only tools (`sed -i`, `find -delete`) are refused.\n * Anything not provably inert stays blocked.\n */\nexport class ReadOnlyInspectionScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(this.scanner);\n }\n\n /** True only when EVERY segment of the command is provably inert. Empty command → false. */\n isReadOnlyInspection(command: string): boolean {\n const segments = this.scanner.segmentsWithJoins(command);\n if (segments.length === 0) return false;\n return segments.every((segment: CommandSegment): boolean => this.segmentIsInert(segment));\n }\n\n private segmentIsInert(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return true; // pure shell structure (`done`, `fi`)\n if (this.redirectsToFile(words)) return false; // `… > file` writes, whatever the command is\n const head = path.basename(words[0]);\n if (!INSPECTORS.has(head)) return false;\n return !this.hasMutatingFlag(head, words.slice(1));\n }\n\n // `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which only rewire fds and are the single\n // most common decoration an agent appends to a diagnostic command.\n private redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n\n // Read-only tools that grow teeth with one flag: `sed -i` rewrites in place, `find -delete`/`-exec`\n // runs arbitrary commands. Matching is prefix-based so `-i.bak` and `-inplace` are caught too.\n private hasMutatingFlag(head: string, args: readonly string[]): boolean {\n const mutating = MUTATING_FLAGS[head];\n if (!mutating) return false;\n return args.some((arg: string): boolean => mutating.some((flag: string): boolean => arg.startsWith(flag)));\n }\n}\n\n// Commands whose entire job is showing what is already there. Kept to viewers/searchers/formatters:\n// no package managers, no build or test runners, no interpreters, no git.\nconst INSPECTORS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'tac', 'rev', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff', 'cmp', 'file', 'stat', 'realpath', 'readlink',\n 'basename', 'dirname', 'sort', 'uniq', 'cut', 'tr', 'column', 'fold', 'expand',\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'which', 'test', '[',\n]);\n\n// Per-command flags that turn an inspector into a mutator.\nconst MUTATING_FLAGS: Readonly<Record<string, readonly string[]>> = {\n sed: ['-i', '--in-place'],\n find: ['-delete', '-exec', '-execdir', '-ok', '-okdir', '-fprint', '-fls'],\n awk: ['-i'],\n sort: ['-o', '--output'],\n};\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
|
|
@@ -54,7 +54,7 @@ class BuildOutputPipeScan {
|
|
|
54
54
|
}
|
|
55
55
|
/** The first bounded logging command in `command`, or null when there is none. */
|
|
56
56
|
firstHit(command) {
|
|
57
|
-
const parts = this.scanner.
|
|
57
|
+
const parts = this.scanner.segmentsWithJoins(command);
|
|
58
58
|
for (let i = 0; i < parts.length; i++) {
|
|
59
59
|
const name = this.loggingCommandIn(parts[i]);
|
|
60
60
|
if (name === '')
|
|
@@ -62,7 +62,7 @@ class BuildOutputPipeScan {
|
|
|
62
62
|
// The NEXT segment carries the separator that preceded it, so "this segment pipes OUT" is
|
|
63
63
|
// "the segment after me was piped into". Splitting on `|` throws that away; CommandSegment
|
|
64
64
|
// exists precisely so it does not have to be re-derived here.
|
|
65
|
-
if (i + 1 < parts.length && parts[i + 1].
|
|
65
|
+
if (i + 1 < parts.length && parts[i + 1].join === '|')
|
|
66
66
|
return new BoundedOutputHit(name, exports.BOUND_BY_PIPE);
|
|
67
67
|
if (this.segments.redirectsToFile(this.scanner.words(parts[i].text))) {
|
|
68
68
|
return new BoundedOutputHit(name, exports.BOUND_BY_REDIRECT);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-output-pipe-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/build-output-pipe-scan.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uGAAuG;AAC1F,QAAA,qBAAqB,GAAsB;IACpD,UAAU,EAAE,qBAAqB,EAAE,qBAAqB;CAC3D,CAAC;AAEF,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,6BAAqB,CAAC,CAAC;AAEvE,mGAAmG;AACtF,QAAA,aAAa,GAAG,MAAM,CAAC;AACvB,QAAA,iBAAiB,GAAG,UAAU,CAAC;AAE5C,wGAAwG;AACxG,MAAa,gBAAgB;IACzB,OAAO,CAAS;IAChB,KAAK,CAAS;IAEd,YAAY,OAAe,EAAE,KAAa;QACtC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;CACJ;AARD,4CAQC;AAED,MAAa,mBAAmB;IAEP;IACA;IAFrB,YACqB,OAAuB,EACvB,QAA0B;QAD1B,YAAO,GAAP,OAAO,CAAgB;QACvB,aAAQ,GAAR,QAAQ,CAAkB;IAC5C,CAAC;IAEJ,kFAAkF;IAClF,QAAQ,CAAC,OAAe;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACtD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,IAAI,GAAG,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7C,IAAI,IAAI,KAAK,EAAE;gBAAE,SAAS;YAC1B,0FAA0F;YAC1F,2FAA2F;YAC3F,8DAA8D;YAC9D,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,
|
|
1
|
+
{"version":3,"file":"build-output-pipe-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/build-output-pipe-scan.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uGAAuG;AAC1F,QAAA,qBAAqB,GAAsB;IACpD,UAAU,EAAE,qBAAqB,EAAE,qBAAqB;CAC3D,CAAC;AAEF,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,6BAAqB,CAAC,CAAC;AAEvE,mGAAmG;AACtF,QAAA,aAAa,GAAG,MAAM,CAAC;AACvB,QAAA,iBAAiB,GAAG,UAAU,CAAC;AAE5C,wGAAwG;AACxG,MAAa,gBAAgB;IACzB,OAAO,CAAS;IAChB,KAAK,CAAS;IAEd,YAAY,OAAe,EAAE,KAAa;QACtC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;CACJ;AARD,4CAQC;AAED,MAAa,mBAAmB;IAEP;IACA;IAFrB,YACqB,OAAuB,EACvB,QAA0B;QAD1B,YAAO,GAAP,OAAO,CAAgB;QACvB,aAAQ,GAAR,QAAQ,CAAkB;IAC5C,CAAC;IAEJ,kFAAkF;IAClF,QAAQ,CAAC,OAAe;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACtD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,IAAI,GAAG,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7C,IAAI,IAAI,KAAK,EAAE;gBAAE,SAAS;YAC1B,0FAA0F;YAC1F,2FAA2F;YAC3F,8DAA8D;YAC9D,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG;gBAAE,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,qBAAa,CAAC,CAAC;YACxG,IAAI,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;gBACnE,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,yBAAiB,CAAC,CAAC;YACzD,CAAC;QACL,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,+FAA+F;IAC/F,yFAAyF;IACjF,gBAAgB,CAAC,OAAuB;QAC5C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC7D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAClC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,OAAO,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IAClD,CAAC;CACJ;AA/BD,kDA+BC","sourcesContent":["import { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question `build-output-pipe-guard` asks: does this command BOUND the output of a\n * webpieces command that already writes its output to a file?\n *\n * ─── The three commands, and why only these three ──────────────────────────────────────────────────\n * `wp-build`, `wp-review-upsert-pr` and `wp-finish-upsert-pr` are the commands that RUN A BUILD. Each\n * one redirects the build's full stdout+stderr to a log file, prints a ~10-second heartbeat while it\n * runs, and ends with a `FullLog :` pointer at the file. Nothing else in the `wp-*` family both takes\n * minutes and writes a log, so nothing else belongs on this list — a guard that also refused\n * `pnpm wp-cleanup | tail` would be refusing something with no cure.\n *\n * ─── What is a hit ─────────────────────────────────────────────────────────────────────────────────\n * BLOCKED a PIPE out of the command — `pnpm wp-build | tail -50`, `… 2>&1 | grep error`, `| tee`.\n * A pipe is the measured hazard and it does not depend on WHAT it feeds: the pipeline's\n * reader (`tail`, `head`, `grep`, `wc`) withholds every byte until the writer EXITS, so the\n * heartbeat never reaches the terminal, the harness sees a command silent for 600 seconds,\n * and it kills a full build. Measured in this repo's own call log: 85 piped `wp-*` calls,\n * 42 of them on one of these three commands.\n * BLOCKED a stdout REDIRECT to a file — `pnpm wp-build > /tmp/out.log`. Same silence, and it also\n * makes a SECOND copy of a log the command already wrote.\n *\n * ALLOWED the bare command, which is the whole cure.\n * ALLOWED `2>&1` on its own — it rewires fds and buffers nothing. `ShellSegmentScan.redirectsToFile`\n * owns that carve-out, and it is shared rather than re-spelled here.\n * ALLOWED every other command, piped or not. This guard is blocklist-shaped and narrow on purpose.\n */\n\n/** The commands whose output already goes to a file, so bounding it can only ever lose information. */\nexport const LOGGED_BUILD_COMMANDS: readonly string[] = [\n 'wp-build', 'wp-review-upsert-pr', 'wp-finish-upsert-pr',\n];\n\nconst LOGGED_SET: ReadonlySet<string> = new Set(LOGGED_BUILD_COMMANDS);\n\n/** How the output was bounded. The two shapes read differently in a refusal, so they are named. */\nexport const BOUND_BY_PIPE = 'pipe';\nexport const BOUND_BY_REDIRECT = 'redirect';\n\n/** One bounded invocation: WHICH command, and HOW its output was bounded. Data-only (per CLAUDE.md). */\nexport class BoundedOutputHit {\n command: string;\n shape: string;\n\n constructor(command: string, shape: string) {\n this.command = command;\n this.shape = shape;\n }\n}\n\nexport class BuildOutputPipeScan {\n constructor(\n private readonly scanner: CommandScanner,\n private readonly segments: ShellSegmentScan,\n ) {}\n\n /** The first bounded logging command in `command`, or null when there is none. */\n firstHit(command: string): BoundedOutputHit | null {\n const parts = this.scanner.segmentsWithJoins(command);\n for (let i = 0; i < parts.length; i++) {\n const name = this.loggingCommandIn(parts[i]);\n if (name === '') continue;\n // The NEXT segment carries the separator that preceded it, so \"this segment pipes OUT\" is\n // \"the segment after me was piped into\". Splitting on `|` throws that away; CommandSegment\n // exists precisely so it does not have to be re-derived here.\n if (i + 1 < parts.length && parts[i + 1].join === '|') return new BoundedOutputHit(name, BOUND_BY_PIPE);\n if (this.segments.redirectsToFile(this.scanner.words(parts[i].text))) {\n return new BoundedOutputHit(name, BOUND_BY_REDIRECT);\n }\n }\n return null;\n }\n\n // The logging command this segment invokes, or '' — `pnpm wp-build`, `npx wp-build` and a bare\n // `wp-build` are one command, which is CommandScanner's `runnerStrippedWords` to decide.\n private loggingCommandIn(segment: CommandSegment): string {\n const words = this.scanner.runnerStrippedWords(segment.text);\n if (words.length === 0) return '';\n const program = this.scanner.programName(words[0]);\n return LOGGED_SET.has(program) ? program : '';\n }\n}\n"]}
|
|
@@ -85,7 +85,7 @@ class ContentReadScan {
|
|
|
85
85
|
// No path given: either it reads stdin (fine — and doubly fine when piped into), or it
|
|
86
86
|
// walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`
|
|
87
87
|
// walks /tmp, which this repo's staleness has nothing to do with.
|
|
88
|
-
const walksTree =
|
|
88
|
+
const walksTree = segment.join !== '|' && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);
|
|
89
89
|
return walksTree ? command : null;
|
|
90
90
|
}
|
|
91
91
|
return operands.some((operand) => this.isWorkspacePath(operand)) ? command : null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":";;;;AAAA,mDAA6B;AAG7B,6DAAwD;AAExD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAa,eAAe;IAaH;IACA;IAbJ,KAAK,CAAmB;IACxB,OAAO,CAAS;IAEjC;;;;;;;OAOG;IACH,YACqB,OAAuB,EACvB,aAAqB,EACtC,YAAqB;QAFJ,YAAO,GAAP,OAAO,CAAgB;QACvB,kBAAa,GAAb,aAAa,CAAQ;QAGtC,IAAI,CAAC,KAAK,GAAG,IAAI,qCAAgB,CAAC,OAAO,CAAC,CAAC;QAC3C,IAAI,CAAC,OAAO,GAAG,YAAY,IAAI,aAAa,CAAC;IACjD,CAAC;IAED;;;;;;;;;OASG;IACH,uBAAuB,CAAC,OAAuB;QAC3C,OAAO,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;IACrF,CAAC;IAED,oGAAoG;IAC5F,eAAe,CAAC,OAAuB;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACrC,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAC/D,OAAO,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACxD,CAAC;IAED;;;;;;;OAOG;IACH,iBAAiB,CAAC,OAAuB;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QACtD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAEpC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC;QACnD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,cAAc,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAE/D,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACxC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,IAAI,CAAC;QAE/C,MAAM,QAAQ,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC5D,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,uFAAuF;YACvF,wFAAwF;YACxF,kEAAkE;YAClE,MAAM,SAAS,GAAG,CAAC,OAAO,CAAC,SAAS,IAAI,WAAW,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YACrG,OAAO,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;QACtC,CAAC;QACD,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAe,EAAW,EAAE,CAAC,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IACvG,CAAC;IAED;;;;OAIG;IACK,cAAc,CAAC,MAAc,EAAE,KAAwB;QAC3D,IAAI,MAAM,KAAK,MAAM,IAAI,MAAM,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QACxD,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACpD,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,GAAW,EAAW,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAChF,6FAA6F;QAC7F,IAAI,MAAM,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAW,EAAW,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAAE,OAAO,IAAI,CAAC;QAClG,OAAO,OAAO,MAAM,EAAE,CAAC;IAC3B,CAAC;IAED,iGAAiG;IACjG,wFAAwF;IAChF,YAAY,CAAC,OAAe,EAAE,IAAuB;QACzD,MAAM,UAAU,GAAa,EAAE,CAAC;QAChC,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACrB,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,SAAS,CAAuB,gCAAgC;YACzF,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACzB,CAAC;QACD,IAAI,aAAa,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACpF,OAAO,UAAU,CAAC;IACtB,CAAC;IAED;;;;;;;;;;OAUG;IACK,eAAe,CAAC,OAAe;QACnC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QAC1C,iGAAiG;QACjG,qFAAqF;QACrF,IAAI,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC;YAAE,OAAO,KAAK,CAAC;QAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC1F,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC7D,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,KAAK,CAAC;QAC5C,OAAO,CAAC,IAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC;IAC7C,CAAC;IAED,6FAA6F;IACrF,aAAa,CAAC,GAAW;QAC7B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACtE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACtC,CAAC;IAED,6FAA6F;IAC7F,iGAAiG;IACjG,0EAA0E;IAClE,iBAAiB,CAAC,QAAgB;QACtC,MAAM,UAAU,GAAG,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACjD,OAAO,UAAU,KAAK,uBAAuB,IAAI,UAAU,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC;IAC1F,CAAC;IAED,8FAA8F;IACtF,QAAQ,CAAC,IAAY;QACzB,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;CACJ;AA1ID,0CA0IC;AAED,qGAAqG;AACrG,kGAAkG;AAClG,MAAM,eAAe,GAAwB,IAAI,GAAG,CAAC;IACjD,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI;IAC1E,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI;IACrE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CACrC,CAAC,CAAC;AAEH,oGAAoG;AACpG,6DAA6D;AAC7D,MAAM,WAAW,GAAwB,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;AAE5F,4EAA4E;AAC5E,MAAM,aAAa,GAAwB,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n private readonly baseDir: string;\n\n /**\n * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which\n * is how an agent reaches a linked worktree, since the harness resets a cwd that left the workspace.\n * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat\n * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,\n * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.\n * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).\n */\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n effectiveCwd?: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n this.baseDir = effectiveCwd ?? workspaceRoot;\n }\n\n /**\n * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —\n * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.\n *\n * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an\n * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing\n * about a read that never touches the tree is affected by which branch the tree is on. The\n * \"content reader\" restriction is what keeps this from becoming a general escape hatch: a build,\n * a server or a git write is not a content reader and never qualifies, however its paths look.\n */\n readsOnlyOutsideContent(segment: CommandSegment): boolean {\n return this.isContentReader(segment) && this.readsStaleContent(segment) === null;\n }\n\n // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?\n private isContentReader(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.scanner.gitSubcommandOf(words) !== null) return false;\n return CONTENT_READERS.has(this.baseName(words[0]));\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`\n // walks /tmp, which this repo's staleness has nothing to do with.\n const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);\n return walksTree ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the\n * directory the command actually runs in (`baseDir`), so it counts only when that directory is\n * itself in the tree — the old code assumed every relative path meant \"inside the repo\", which is\n * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An\n * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,\n * `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`\n // stays readable from any directory — never wedge the file that turns the guard off.\n if (this.isEscapeHatchPath(operand)) return false;\n const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);\n const relative = path.relative(this.workspaceRoot, absolute);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // The cwd-walk question: is the directory this command runs in inside the tree being judged?\n private isInWorkspace(dir: string): boolean {\n const relative = path.relative(this.workspaceRoot, path.resolve(dir));\n return !relative.startsWith('..');\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
1
|
+
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n private readonly baseDir: string;\n\n /**\n * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which\n * is how an agent reaches a linked worktree, since the harness resets a cwd that left the workspace.\n * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat\n * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,\n * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.\n * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).\n */\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n effectiveCwd?: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n this.baseDir = effectiveCwd ?? workspaceRoot;\n }\n\n /**\n * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —\n * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.\n *\n * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an\n * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing\n * about a read that never touches the tree is affected by which branch the tree is on. The\n * \"content reader\" restriction is what keeps this from becoming a general escape hatch: a build,\n * a server or a git write is not a content reader and never qualifies, however its paths look.\n */\n readsOnlyOutsideContent(segment: CommandSegment): boolean {\n return this.isContentReader(segment) && this.readsStaleContent(segment) === null;\n }\n\n // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?\n private isContentReader(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.scanner.gitSubcommandOf(words) !== null) return false;\n return CONTENT_READERS.has(this.baseName(words[0]));\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`\n // walks /tmp, which this repo's staleness has nothing to do with.\n const walksTree = segment.join !== '|' && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);\n return walksTree ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the\n * directory the command actually runs in (`baseDir`), so it counts only when that directory is\n * itself in the tree — the old code assumed every relative path meant \"inside the repo\", which is\n * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An\n * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,\n * `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`\n // stays readable from any directory — never wedge the file that turns the guard off.\n if (this.isEscapeHatchPath(operand)) return false;\n const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);\n const relative = path.relative(this.workspaceRoot, absolute);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // The cwd-walk question: is the directory this command runs in inside the tree being judged?\n private isInWorkspace(dir: string): boolean {\n const relative = path.relative(this.workspaceRoot, path.resolve(dir));\n return !relative.startsWith('..');\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CommandScanner, SegmentJoin } from '../command-scan';
|
|
2
|
+
/**
|
|
3
|
+
* How a leading refresh-main cure is joined to the work behind it.
|
|
4
|
+
*
|
|
5
|
+
* `none` the command does not start with a cure at all (or is nothing BUT cure, which the
|
|
6
|
+
* row 4 skip list already allowed before this class is ever consulted)
|
|
7
|
+
* `short-circuits` `&&` — the work is skipped when the cure fails
|
|
8
|
+
* `runs-anyway` `;`, `||`, `&`, a newline — the cure's exit code is discarded
|
|
9
|
+
*/
|
|
10
|
+
export type CureJoinKind = 'none' | 'short-circuits' | 'runs-anyway';
|
|
11
|
+
/**
|
|
12
|
+
* What the scan found. Data-only, so a class (per CLAUDE.md).
|
|
13
|
+
*
|
|
14
|
+
* `operator` is the literal shell operator, carried because the refusal message must NAME it: an
|
|
15
|
+
* agent that is told "use `&&`" without being told which character it actually typed has to diff the
|
|
16
|
+
* two spellings itself, and the whole point of this block is to hand over the one edit that fixes it.
|
|
17
|
+
*/
|
|
18
|
+
export declare class CurePrefix {
|
|
19
|
+
readonly kind: CureJoinKind;
|
|
20
|
+
readonly operator: SegmentJoin;
|
|
21
|
+
constructor(kind: CureJoinKind, operator: SegmentJoin);
|
|
22
|
+
}
|
|
23
|
+
export declare class CurePrefixScan {
|
|
24
|
+
private readonly scanner;
|
|
25
|
+
private readonly shell;
|
|
26
|
+
constructor(scanner?: CommandScanner);
|
|
27
|
+
/**
|
|
28
|
+
* Classify the LEADING run of segments as a refresh-main cure, and report the operator that joins
|
|
29
|
+
* that run to whatever follows.
|
|
30
|
+
*
|
|
31
|
+
* The run may carry inert company — a leading `cd '<root>'` (which is how every guard renders a
|
|
32
|
+
* remedy that must survive a reset cwd), an `echo`, and the `2>&1 | tail -3` an agent appends by
|
|
33
|
+
* reflex. None of those changes what the cure does, and refusing a command because it was piped
|
|
34
|
+
* into `tail` is the exact defect ShellSegmentScan was written to end.
|
|
35
|
+
*/
|
|
36
|
+
classify(command: string): CurePrefix;
|
|
37
|
+
/**
|
|
38
|
+
* Does this segment bring local `main` forward — `git pull`, or the `pnpm wp-checkout-clean-main`
|
|
39
|
+
* that wraps it?
|
|
40
|
+
*
|
|
41
|
+
* A `> file` redirect disqualifies it, with `/dev/null` carved out. `>/dev/null 2>&1` on the cure
|
|
42
|
+
* is only ever an agent muting chatter, and under `&&` the exit code — the thing that matters —
|
|
43
|
+
* still governs. A redirect to a REAL path is a write, and a segment that writes the tree is not
|
|
44
|
+
* something to wave a following command through on the strength of.
|
|
45
|
+
*/
|
|
46
|
+
private advancesLocalMain;
|
|
47
|
+
/**
|
|
48
|
+
* Does this `git pull` bring `origin/main` in, rather than some other branch?
|
|
49
|
+
*
|
|
50
|
+
* The LAST positional argument is the refspec (`git pull [flags] [remote] [refspec…]`); with no
|
|
51
|
+
* refspec at all the pull takes the current branch's upstream, and the current branch is `main`
|
|
52
|
+
* wherever this class is consulted, so that form is the cure. `git pull origin some-feature`
|
|
53
|
+
* is not: it merges a feature branch into `main` and leaves local `main` still not containing
|
|
54
|
+
* `origin/main`, so the work behind the `&&` would read the same stale tree the block is about.
|
|
55
|
+
*/
|
|
56
|
+
private pullsMain;
|
|
57
|
+
/**
|
|
58
|
+
* Segments that ride along with the cure without being it: shell structure, a `cd`/`echo`, a
|
|
59
|
+
* filter the cure was piped into, and a leading `git fetch`. Anything a pipe fed cannot touch the
|
|
60
|
+
* tree, and a `cd` is how every guard renders a remedy that must survive a reset cwd — so none of
|
|
61
|
+
* them turns a cure prefix into work, and none of them is a cure either.
|
|
62
|
+
*/
|
|
63
|
+
private accompaniesTheCure;
|
|
64
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CurePrefixScan = exports.CurePrefix = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const command_scan_1 = require("../command-scan");
|
|
7
|
+
const shell_segment_scan_1 = require("./shell-segment-scan");
|
|
8
|
+
/**
|
|
9
|
+
* What the scan found. Data-only, so a class (per CLAUDE.md).
|
|
10
|
+
*
|
|
11
|
+
* `operator` is the literal shell operator, carried because the refusal message must NAME it: an
|
|
12
|
+
* agent that is told "use `&&`" without being told which character it actually typed has to diff the
|
|
13
|
+
* two spellings itself, and the whole point of this block is to hand over the one edit that fixes it.
|
|
14
|
+
*/
|
|
15
|
+
class CurePrefix {
|
|
16
|
+
kind;
|
|
17
|
+
operator;
|
|
18
|
+
constructor(kind, operator) {
|
|
19
|
+
this.kind = kind;
|
|
20
|
+
this.operator = operator;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.CurePrefix = CurePrefix;
|
|
24
|
+
const NO_CURE_PREFIX = new CurePrefix('none', 'none');
|
|
25
|
+
// The commands that ADVANCE local `main` — the one this repo prescribes, and the raw git verb it
|
|
26
|
+
// wraps. Deliberately NOT every `wp-*` bin: a prefix earns the composition allowance because it makes
|
|
27
|
+
// the tree fresh, and `pnpm wp-cleanup` does not.
|
|
28
|
+
const ADVANCING_BINS = new Set(['wp-checkout-clean-main']);
|
|
29
|
+
// The refs a pull may name and still be the CURE. `git pull origin some-feature` advances local
|
|
30
|
+
// `main` by merging a feature branch into it — which is a different and worse thing than being stale,
|
|
31
|
+
// and leaves the work reading a `main` that still does not contain `origin/main`. A pull with NO
|
|
32
|
+
// explicit ref takes the current branch's upstream, and the current branch here is `main` by the time
|
|
33
|
+
// this class is consulted, so the bare forms are the cure and are covered by the empty case below.
|
|
34
|
+
const MAIN_REFS = new Set(['main', 'origin/main', 'main:main', 'origin/main:main']);
|
|
35
|
+
// `git fetch` may LEAD the prefix — `git fetch --prune origin main && git pull --ff-only origin main`
|
|
36
|
+
// is the shape agents actually type — but it is not itself a cure and never satisfies the prefix on
|
|
37
|
+
// its own. A fetch moves the remote-tracking ref and leaves local `main` exactly as far behind as it
|
|
38
|
+
// was, so `git fetch && <work>` short-circuits on nothing: the work still reads the stale tree. That
|
|
39
|
+
// command gets the ordinary row 6 block, whose message says what a fetch alone does not fix.
|
|
40
|
+
const ACCOMPANYING_GIT_SUBCOMMANDS = new Set(['fetch']);
|
|
41
|
+
class CurePrefixScan {
|
|
42
|
+
scanner;
|
|
43
|
+
shell;
|
|
44
|
+
constructor(scanner = new command_scan_1.CommandScanner()) {
|
|
45
|
+
this.scanner = scanner;
|
|
46
|
+
this.shell = new shell_segment_scan_1.ShellSegmentScan(this.scanner);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Classify the LEADING run of segments as a refresh-main cure, and report the operator that joins
|
|
50
|
+
* that run to whatever follows.
|
|
51
|
+
*
|
|
52
|
+
* The run may carry inert company — a leading `cd '<root>'` (which is how every guard renders a
|
|
53
|
+
* remedy that must survive a reset cwd), an `echo`, and the `2>&1 | tail -3` an agent appends by
|
|
54
|
+
* reflex. None of those changes what the cure does, and refusing a command because it was piped
|
|
55
|
+
* into `tail` is the exact defect ShellSegmentScan was written to end.
|
|
56
|
+
*/
|
|
57
|
+
classify(command) {
|
|
58
|
+
const segments = this.scanner.segmentsWithJoins(command);
|
|
59
|
+
let advanced = false;
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
if (this.advancesLocalMain(segment)) {
|
|
62
|
+
advanced = true;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (this.accompaniesTheCure(segment))
|
|
66
|
+
continue;
|
|
67
|
+
// The first segment that is neither. Its join is the verdict — but only once something in
|
|
68
|
+
// the prefix has actually advanced local `main`; otherwise this is an ordinary command
|
|
69
|
+
// that happens to open with a `cd` or a fetch, and row 6 judges it as it always did.
|
|
70
|
+
if (!advanced)
|
|
71
|
+
return NO_CURE_PREFIX;
|
|
72
|
+
return new CurePrefix(segment.join === '&&' ? 'short-circuits' : 'runs-anyway', segment.join);
|
|
73
|
+
}
|
|
74
|
+
// Nothing but cure and its company — there is no work to protect, and the row 4 skip list has
|
|
75
|
+
// already spoken. Never reached from a blocked path; kept explicit rather than implied.
|
|
76
|
+
return NO_CURE_PREFIX;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Does this segment bring local `main` forward — `git pull`, or the `pnpm wp-checkout-clean-main`
|
|
80
|
+
* that wraps it?
|
|
81
|
+
*
|
|
82
|
+
* A `> file` redirect disqualifies it, with `/dev/null` carved out. `>/dev/null 2>&1` on the cure
|
|
83
|
+
* is only ever an agent muting chatter, and under `&&` the exit code — the thing that matters —
|
|
84
|
+
* still governs. A redirect to a REAL path is a write, and a segment that writes the tree is not
|
|
85
|
+
* something to wave a following command through on the strength of.
|
|
86
|
+
*/
|
|
87
|
+
advancesLocalMain(segment) {
|
|
88
|
+
const words = this.shell.effectiveWords(segment.text);
|
|
89
|
+
if (words.length === 0)
|
|
90
|
+
return false;
|
|
91
|
+
if (this.shell.redirectsToFile(words) && !words.some((w) => w.includes('/dev/null'))) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const gitSub = this.scanner.gitSubcommandOf(words);
|
|
95
|
+
if (gitSub !== null)
|
|
96
|
+
return gitSub === 'pull' && this.pullsMain(segment);
|
|
97
|
+
return this.scanner.runnerStrippedWords(segment.text)
|
|
98
|
+
.some((word) => ADVANCING_BINS.has(path.basename(word)));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Does this `git pull` bring `origin/main` in, rather than some other branch?
|
|
102
|
+
*
|
|
103
|
+
* The LAST positional argument is the refspec (`git pull [flags] [remote] [refspec…]`); with no
|
|
104
|
+
* refspec at all the pull takes the current branch's upstream, and the current branch is `main`
|
|
105
|
+
* wherever this class is consulted, so that form is the cure. `git pull origin some-feature`
|
|
106
|
+
* is not: it merges a feature branch into `main` and leaves local `main` still not containing
|
|
107
|
+
* `origin/main`, so the work behind the `&&` would read the same stale tree the block is about.
|
|
108
|
+
*/
|
|
109
|
+
pullsMain(segment) {
|
|
110
|
+
const args = this.scanner.gitSubcommandArgs(segment.text, 'pull') ?? [];
|
|
111
|
+
// Flags are not positional, and neither is a REDIRECTION token — `2>&1` and `>/dev/null` are
|
|
112
|
+
// the two decorations an agent appends by reflex, and reading `2>&1` as the refspec would
|
|
113
|
+
// reject the single most common spelling of the cure.
|
|
114
|
+
const positional = args.filter((arg) => !arg.startsWith('-') && !/[<>]/.test(arg));
|
|
115
|
+
if (positional.length <= 1)
|
|
116
|
+
return true;
|
|
117
|
+
return MAIN_REFS.has(positional[positional.length - 1]);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Segments that ride along with the cure without being it: shell structure, a `cd`/`echo`, a
|
|
121
|
+
* filter the cure was piped into, and a leading `git fetch`. Anything a pipe fed cannot touch the
|
|
122
|
+
* tree, and a `cd` is how every guard renders a remedy that must survive a reset cwd — so none of
|
|
123
|
+
* them turns a cure prefix into work, and none of them is a cure either.
|
|
124
|
+
*/
|
|
125
|
+
accompaniesTheCure(segment) {
|
|
126
|
+
if (this.shell.classify(segment).role !== 'command')
|
|
127
|
+
return true;
|
|
128
|
+
const gitSub = this.scanner.gitSubcommandOf(this.shell.effectiveWords(segment.text));
|
|
129
|
+
return gitSub !== null && ACCOMPANYING_GIT_SUBCOMMANDS.has(gitSub);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.CurePrefixScan = CurePrefixScan;
|
|
133
|
+
//# sourceMappingURL=cure-prefix-scan.js.map
|