import { BranchStateGuardConfig } from '@webpieces/rules-config'; import type { BashContext, Violation } from '../types'; import { BashRuleBase } from '../rule-base'; import { FixHint } from '../fix-hint'; /** * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own: * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that * contains the damage once it is already there. * * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ────────────────── * * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`. * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157 * commits behind** origin. That checkout did not merely produce stale files — it reverted: * * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`; * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy * whose message stated the drift BACKWARDS ("your installed webpieces is older than required") * and named a single cure, `pnpm install`; * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match * the stale pin, and had to undo it with the `git pull` that should have come first. * * The shim on current main already diagnoses drift correctly — it distinguishes "the pin is newer" * from "the pin is stale, and `pnpm install` would downgrade you". None of that helped, because the * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive * and why it lives here rather than in a second rule: same failure, one step earlier, one switch. * * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the * checkout, so the only `main` it could measure is the one it is about to leave. The interesting * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving. * * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull` * appears anywhere in the SAME command. * ALLOWED `git checkout main && git pull origin main`, the pairing this forces — and * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the * orphan-directory sweep welded on. The message prescribes the one command; the raw pair * stays legal because it is plain git and because it is the L0 recovery cure, where * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on. * ALLOWED `git checkout -b origin/main` (current by construction), `git checkout `, * `git checkout -- `, and any other branch. * * ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ──────────────────────────────── * * The finding is not "you are on `main`" — it is **"what you would read here is out of date"**. So the * ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only * when local `main` is known to be BEHIND `origin/main`. Unknown → allow. Current → allow. * * WHY, when this guard spent a release judging the branch alone: because the branch alone denies * everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly * where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` — the * command this repo tells it to run — and the next `curl`, `gh pr close` or test run is refused by a * guard whose own name says STALE. The tool that got it there could not be the cure for being there, * and the refusal had nothing to do with staleness, which is the confusion reported from the field. * * THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right * about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place * whatever `main`'s freshness — unreviewable, and unrevertable as a unit — so feature-branch-guard * stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms * nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there. * Same tree, different hazard, so one precondition was never right for both. * * THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks "does local `main` * already contain the cached `origin/main`?", so the block lifts the instant a pull lands rather than * waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses — one * implementation, so the Read and Bash halves of one state can never disagree about whether the pull * took. * * FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable: * no cache (the first call of every session), a cache for another branch, an empty `originMain` * (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is * fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no * synchronous `git fetch` is ever run on the blocking path. * * THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape * merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read * BLOCKLIST could not replace it — enumerating readers catches `cat` and `grep`, and structurally * cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands * whose stated purpose is something else and whose effect is to modify tracked files. What changed is * WHEN that polarity applies, not the polarity. * * BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests, * installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes. * ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the * blocked state, everything that gets you OUT or tells you where you are: * `git checkout -b origin/main`, `git switch`, `git pull`/`fetch`, * `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this * tree), `curl`/`wget`, every `wp-*` bin, installs. * * There is no dirty-tree valve here and none in read-stale-guard either: the cure is * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.) */ export declare class StaleMainBashGuardRule extends BashRuleBase { constructor(config: BranchStateGuardConfig); private readonly scanner; private readonly recovery; private readonly switches; private readonly recoveryList; private readonly freshness; readonly description: string; readonly defaultOptions: { hangTimeoutMinutes: number; }; readonly fixHint: FixHint; check(ctx: BashContext): readonly Violation[]; /** * ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`. * * Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and * that is on purpose — the Read and the Bash halves of one state must not disagree about whether * `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one * file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the * row 4 skip list already applied above. * * Every exit that is not "established BEHIND" is an ALLOW, and the not-established ones are * `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing. */ private checkFreshness; /** * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same * command, or null. The pull is looked for across the WHOLE command, not the matched segment, * because `git checkout main && git pull origin main` splits into two segments and the pairing is * the point. */ private bareCheckoutOfMain; /** * The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what * the guard's name promised all along. * * The two versions before this one are both instructive. The oldest opened with how many commits * behind `main` was, which invited the wrong cure: an agent that reads "behind" reaches for a pull, * lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said * `main` is not a place to work "whether or not it is current" — true of a WRITE, but this guard * does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable. * * So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you * read here is out of date; Bash is default-deny in this state rather than a list of readers, * because a command's stated purpose never says whether it also WRITES; and the branch cure fetches, * so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN * stays legitimate — on a CURRENT `main` nothing here fires at all. * * ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b` * carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard * prints it, because a Read really can be cured by staying put. A Bash session cannot — the next * command is as likely to write as to read.) */ private staleMainMessage; private pairingMessage; /** * The guard could not ESTABLISH the state it judges on, so it judged nothing. * * A sibling of allow() rather than a reason string passed to it, because the difference has to * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'` * suffix on the free-text reason, which meant an abstention and a real approval were the same * verdict and the abstentions could not be counted — so nobody could tell whether these guards * were protecting anything or quietly standing down. Never block on data you could not * establish; but say out loud, in a field, that you did not establish it. */ private failOpen; private allow; private block; private truncate; private logDecision; private currentBranch; }