@webpieces/ai-hook-rules 0.4.609 → 0.4.611
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/README.md +1 -1
- package/package.json +2 -2
- package/src/adapters/claude-code-response.js +6 -2
- package/src/adapters/claude-code-response.js.map +1 -1
- package/src/adapters/hook-core.js +13 -10
- package/src/adapters/hook-core.js.map +1 -1
- package/src/bin/dev-hook-install.js +3 -3
- package/src/bin/dev-hook-install.js.map +1 -1
- package/src/bin/guarantee-root.js +23 -10
- package/src/bin/guarantee-root.js.map +1 -1
- package/src/bin/l0-allowlist.js +1 -1
- package/src/bin/l0-allowlist.js.map +1 -1
- package/src/bin/setup.js +6 -3
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-audit-log.d.ts +0 -3
- package/src/bin/shim-audit-log.js +14 -13
- package/src/bin/shim-audit-log.js.map +1 -1
- package/src/bin/shim.js +2 -2
- package/src/bin/shim.js.map +1 -1
- package/src/core/decision-log.d.ts +75 -10
- package/src/core/decision-log.js +91 -28
- package/src/core/decision-log.js.map +1 -1
- package/src/core/l0-fault-codes.d.ts +1 -1
- package/src/core/l0-fault-codes.js +1 -1
- package/src/core/l0-fault-codes.js.map +1 -1
- package/src/core/l1-doc.js +6 -0
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.d.ts +13 -0
- package/src/core/l1-rows.js +24 -11
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/load-rules.js +2 -0
- package/src/core/load-rules.js.map +1 -1
- package/src/core/log-stream.d.ts +32 -18
- package/src/core/log-stream.js +33 -19
- package/src/core/log-stream.js.map +1 -1
- package/src/core/log-streams.d.ts +38 -0
- package/src/core/log-streams.js +45 -0
- package/src/core/log-streams.js.map +1 -0
- package/src/core/main-sync-log.d.ts +2 -2
- package/src/core/main-sync-log.js +22 -15
- package/src/core/main-sync-log.js.map +1 -1
- package/src/core/main-sync-refresh.d.ts +3 -3
- package/src/core/main-sync-refresh.js +4 -4
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/rejection-log.d.ts +1 -1
- package/src/core/rejection-log.js +13 -12
- package/src/core/rejection-log.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +11 -0
- package/src/core/rules/feature-branch-guard.js +20 -5
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/index.js +1 -0
- package/src/core/rules/index.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.d.ts +12 -1
- package/src/core/rules/merged-branch-bash-guard.js +21 -6
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +11 -0
- package/src/core/rules/read-stale-guard.js +25 -10
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +11 -0
- package/src/core/rules/stale-main-bash-guard.js +22 -7
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/whole-repo-build-guard.d.ts +88 -0
- package/src/core/rules/whole-repo-build-guard.js +176 -0
- package/src/core/rules/whole-repo-build-guard.js.map +1 -0
- package/src/core/rules/whole-repo-build-scan.d.ts +35 -0
- package/src/core/rules/whole-repo-build-scan.js +216 -0
- package/src/core/rules/whole-repo-build-scan.js.map +1 -0
- package/src/core/runner.js +34 -15
- package/src/core/runner.js.map +1 -1
- package/src/core/sync-main.d.ts +1 -1
- package/src/core/sync-main.js +2 -2
- package/src/core/sync-main.js.map +1 -1
- package/templates/ai-hook.sh +11 -8
- package/templates/guarantee-root.sh +10 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\n\nimport {\n StaleMainBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { StaleMainMessage } from './stale-main-message';\nimport { ContentReadScan } from './content-read-scan';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the\n * exact line the post-merge cleanup flow already prescribes.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── REACTIVE: content-reading Bash on a stale `main` ─────────────────────────────────────────────\n *\n * read-stale-guard blocks the Read tool when local `main` is behind origin/main — but it looks at\n * nothing else, deliberately: \"every cure is a Bash command, so Bash is the escape hatch — never\n * wedge it.\" That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the\n * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069\n * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session\n * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI\n * workflow set that was missing a 186-line workflow which existed upstream. The logs read\n * \"read-stale-guard handled\", which is worse than no guard: it looks covered.\n *\n * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,\n * `git pull`, git METADATA (log/diff/show/status) — all still run. What is blocked is a command that\n * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a\n * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same\n * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is\n * one command away and there is no reason to stop anything else.\n *\n * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the\n * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.\n *\n * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:\n * - branch undeterminable / not on `main` / no cache / cache for another branch → allow\n * - `originMain` unknown (offline) → allow\n * - origin/main already an ancestor of HEAD (ancestry, NOT equality) → allow the instant the pull lands\n * - DIRTY tree → allow: the pull is not a clean fast-forward, and resolving that means reading the\n * very files in conflict. Never trap the agent away from its own rescue.\n * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<StaleMainBashGuardConfig> {\n constructor(config: StaleMainBashGuardConfig) { super(config, 'stale-main-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block ' +\n 'content-reading Bash (cat/grep/ls/…) while local main is behind origin/main — so a session ' +\n 'neither lands on a stale main nor reasons over one through the side door the Read block leaves.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or reading files while main is behind origin/main, both give you stale content.',\n 'Pair the checkout with the pull, or update main and re-run:',\n [\n new Option('git checkout main && git pull origin main (the pull must be in the SAME command).', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('In a linked worktree `git checkout main` FATALS (\"main is already checked out at <primary clone>\") — branch off fresh main instead: git fetch origin main && git checkout -b <name> origin/main'),\n new Option('NOT blocked: `git checkout <sha>`, `git checkout -b <x> origin/main`, `git checkout -- <file>`, any other branch. Also still allowed: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → stale-main-bash-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the\n // files it must read to resolve that — the same valve read-stale-guard opens.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n const reader = this.staleContentRead(ctx);\n if (reader === null) return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);\n\n return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n if (!this.scanner.invokesGit(segment, 'checkout') && !this.scanner.invokesGit(segment, 'switch')) continue;\n if (!this.switchesToMainBranch(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * True only for landing ON the branch. `-b`/`-B`/`-c`/`-C` CREATE a branch, so\n * `git checkout -b x origin/main` is current by construction and never blocked; a `--` turns the\n * rest into pathspecs, so `git checkout -- main` restores a FILE named main and moves no branch.\n */\n private switchesToMainBranch(segment: string): boolean {\n const words = this.scanner.words(segment);\n for (let i = 0; i < words.length; i++) {\n const word = words[i];\n if (word === '--') return false;\n if (/^-[bBcC]$/.test(word)) return false;\n if (i > 1 && word === 'main') return true;\n }\n return false;\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had. '\n + 'That is not only stale FILES — it also reverts `package.json`\\'s @webpieces pin and the '\n + 'guard shim under `.claude/webpieces/`, so the very hook that would diagnose the resulting '\n + 'version drift is replaced by an older copy that reports it BACKWARDS and names the cure '\n + 'that makes it worse. Chain the pull into the same command, leaving no window in which you '\n + 'are on a stale main:\\n' + steps;\n }\n\n // The first segment that would read stale workspace content, or null when none does. The RAW\n // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted\n // prose can only ever block LESS (see BashContext.commandCode).\n private staleContentRead(ctx: BashContext): string | null {\n const scan = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {\n const hit = scan.readsStaleContent(segment);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,\n // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true;\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return true; // cannot tell → assume dirty, the fail-OPEN direction here\n }\n }\n\n private staleMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));\n }\n\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":";;;AAAA,iDAAoD;AAEpD,0DAKiC;AAGjC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAA8C;AAC9C,0CAAsC;AACtC,4DAA8D;AAC9D,kDAAsF;AACtF,sDAAkD;AAClD,kDAAiD;AACjD,6DAAwD;AACxD,2DAAsD;AACtD,mDAA+C;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,MAAa,sBAAuB,SAAQ,wBAAsC;IAC9E,YAAY,MAAgC,IAAI,KAAK,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAAC,CAAC;IAExE,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAEtC,WAAW,GAChB,qFAAqF;QACrF,6FAA6F;QAC7F,iGAAiG,CAAC;IACpF,cAAc,GAAG;QAC/B,kBAAkB,EAAE,2CAA4B;KACnD,CAAC;IACO,OAAO,GAAG,IAAI,kBAAO,CAC1B,oHAAoH,EACpH,6DAA6D,EAC7D;QACI,IAAI,iBAAM,CAAC,mFAAmF,EAAE,IAAI,CAAC;QACrG,IAAI,iBAAM,CAAC,gNAAgN,CAAC;QAC5N,IAAI,iBAAM,CAAC,iMAAiM,CAAC;QAC7M,IAAI,iBAAM,CAAC,yRAAyR,CAAC;QACrS,IAAI,iBAAM,CAAC,sGAAsG,CAAC;KACrH,CACJ,CAAC;IAEF,KAAK,CAAC,GAAgB;QAClB,0FAA0F;QAC1F,2FAA2F;QAC3F,4EAA4E;QAC5E,MAAM,IAAI,GAAG,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;QAC1C,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,EAAE,0BAA0B,IAAI,GAAG,EAAE,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;QACpG,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACrD,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,uBAAuB,CAAC,CAAC;QAEhF,qFAAqF;QACrF,IAAA,0CAAsB,EAAC,GAAG,CAAC,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,kBAAkB,IAAI,2CAA4B,CAAC,CAAC;QAE1G,wFAAwF;QACxF,IAAI,MAAM,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,wCAAwC,CAAC,CAAC;QAEhG,MAAM,MAAM,GAAG,IAAA,iCAAkB,EAAC,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;QAC7D,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,EAAE,YAAY,CAAC,CAAC;QAEtF,MAAM,KAAK,GAAG,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QACxC,gGAAgG;QAChG,8FAA8F;QAC9F,8DAA8D;QAC9D,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,0BAA0B,EAAE,KAAK,CAAC,CAAC;QACnG,2EAA2E;QAC3E,IAAI,MAAM,CAAC,UAAU,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,qBAAqB,EAAE,KAAK,CAAC,CAAC;QAE9F,8FAA8F;QAC9F,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;YACtD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,yCAAyC,EAAE,KAAK,CAAC,CAAC;QACrF,CAAC;QAED,6FAA6F;QAC7F,8EAA8E;QAC9E,IAAI,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC;YAClC,OAAO,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,oBAAoB,EAAE,KAAK,CAAC,CAAC;QACnE,CAAC;QAED,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;QAC1C,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,0CAA0C,EAAE,KAAK,CAAC,CAAC;QAEvG,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,4BAA4B,MAAM,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC,EAAE,KAAK,CAAC,CAAC;IACvH,CAAC;IAED;;;;;OAKG;IACK,kBAAkB,CAAC,GAAgB;QACvC,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9D,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC;gBAAE,SAAS;YAC3G,IAAI,CAAC,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC;gBAAE,SAAS;YAClD,OAAO,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;QACrF,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;OAIG;IACK,oBAAoB,CAAC,OAAe;QACxC,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC1C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;YACtB,IAAI,IAAI,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAChC,IAAI,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC;gBAAE,OAAO,KAAK,CAAC;YACzC,IAAI,CAAC,GAAG,CAAC,IAAI,IAAI,KAAK,MAAM;gBAAE,OAAO,IAAI,CAAC;QAC9C,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAEO,cAAc,CAAC,GAAgB;QACnC,MAAM,KAAK,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChG,OAAO,uFAAuF;cACxF,0FAA0F;cAC1F,4FAA4F;cAC5F,0FAA0F;cAC1F,4FAA4F;cAC5F,wBAAwB,GAAG,KAAK,CAAC;IAC3C,CAAC;IAED,6FAA6F;IAC7F,6FAA6F;IAC7F,gEAAgE;IACxD,gBAAgB,CAAC,GAAgB;QACrC,MAAM,IAAI,GAAG,IAAI,mCAAe,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,aAAa,EAAE,GAAG,CAAC,YAAY,CAAC,CAAC;QACpF,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAChE,MAAM,GAAG,GAAG,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;YAC5C,IAAI,GAAG,KAAK,IAAI;gBAAE,OAAO,GAAG,CAAC;QACjC,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,8FAA8F;IAC9F,oGAAoG;IAC5F,QAAQ,CAAC,aAAqB,EAAE,MAAc;QAClD,MAAM,MAAM,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,YAAY,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;YAC7E,GAAG,EAAE,aAAa;YAClB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC;QACH,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QACrC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACtC,OAAO,IAAI,CAAC;IAChB,CAAC;IAEO,OAAO,CAAC,aAAqB;QACjC,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,GAAG,GAAG,IAAA,wBAAQ,EAAC,wBAAwB,EAAE;gBAC3C,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC;YACH,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC;QACjC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC,CAAE,2DAA2D;QAC7E,CAAC;IACL,CAAC;IAEO,YAAY,CAAC,aAAqB;QACtC,OAAO,IAAI,qCAAgB,CAAC,aAAa,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,WAAW,CAAC,aAAa,CAAC,CAAC,CAAC;IACxF,CAAC;IAEO,WAAW,CAAC,aAAqB;QACrC,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,GAAG,GAAG,IAAA,wBAAQ,EAAC,wCAAwC,EAAE;gBAC3D,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC,IAAI,EAAE,CAAC;YACV,OAAO,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;QACzC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,GAAG,CAAC;QACf,CAAC;IACL,CAAC;IAEO,YAAY,CAAC,MAAsB;QACvC,OAAO,SAAS,MAAM,CAAC,MAAM,cAAc,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,eAAe,MAAM,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,CAAC;IACjJ,CAAC;IAED;;;;;;;;;OASG;IACK,QAAQ,CAAC,GAAgB,EAAE,MAAqB,EAAE,MAAc,EAAE,QAAgB,GAAG;QACzF,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAChE,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAqB,EAAE,MAAc,EAAE,QAAgB,GAAG;QACtF,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QACtD,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAc,EAAE,MAAc,EAAE,OAAe,EAAE,KAAa;QAC1F,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;QAC9D,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;IAC3D,CAAC;IAEO,QAAQ,CAAC,CAAS;QACtB,MAAM,GAAG,GAAG,GAAG,CAAC;QAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;IACvD,CAAC;IAEO,WAAW,CAAC,GAAgB,EAAE,MAAqB,EAAE,OAAgB,EAAE,MAAc,EAAE,KAAa;QACxG,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CAAC,uBAAuB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,8BAAa,EAAE,wBAAS,CAAC,CACzI,CAAC;IACN,CAAC;IAEO,aAAa,CAAC,aAAqB;QACvC,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;gBAC/C,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;CACJ;AA7ND,wDA6NC","sourcesContent":["import { execSync, spawnSync } from 'child_process';\n\nimport {\n StaleMainBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2 } from '../decision-log';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { StaleMainMessage } from './stale-main-message';\nimport { ContentReadScan } from './content-read-scan';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the\n * exact line the post-merge cleanup flow already prescribes.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── REACTIVE: content-reading Bash on a stale `main` ─────────────────────────────────────────────\n *\n * read-stale-guard blocks the Read tool when local `main` is behind origin/main — but it looks at\n * nothing else, deliberately: \"every cure is a Bash command, so Bash is the escape hatch — never\n * wedge it.\" That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the\n * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069\n * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session\n * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI\n * workflow set that was missing a 186-line workflow which existed upstream. The logs read\n * \"read-stale-guard handled\", which is worse than no guard: it looks covered.\n *\n * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,\n * `git pull`, git METADATA (log/diff/show/status) — all still run. What is blocked is a command that\n * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a\n * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same\n * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is\n * one command away and there is no reason to stop anything else.\n *\n * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the\n * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.\n *\n * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:\n * - branch undeterminable / not on `main` / no cache / cache for another branch → allow\n * - `originMain` unknown (offline) → allow\n * - origin/main already an ancestor of HEAD (ancestry, NOT equality) → allow the instant the pull lands\n * - DIRTY tree → allow: the pull is not a clean fast-forward, and resolving that means reading the\n * very files in conflict. Never trap the agent away from its own rescue.\n * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<StaleMainBashGuardConfig> {\n constructor(config: StaleMainBashGuardConfig) { super(config, 'stale-main-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block ' +\n 'content-reading Bash (cat/grep/ls/…) while local main is behind origin/main — so a session ' +\n 'neither lands on a stale main nor reasons over one through the side door the Read block leaves.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or reading files while main is behind origin/main, both give you stale content.',\n 'Pair the checkout with the pull, or update main and re-run:',\n [\n new Option('git checkout main && git pull origin main (the pull must be in the SAME command).', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('In a linked worktree `git checkout main` FATALS (\"main is already checked out at <primary clone>\") — branch off fresh main instead: git fetch origin main && git checkout -b <name> origin/main'),\n new Option('NOT blocked: `git checkout <sha>`, `git checkout -b <x> origin/main`, `git checkout -- <file>`, any other branch. Also still allowed: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → stale-main-bash-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the\n // files it must read to resolve that — the same valve read-stale-guard opens.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.failOpen(ctx, branch, 'dirty-tree-on-main', cache);\n }\n\n const reader = this.staleContentRead(ctx);\n if (reader === null) return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);\n\n return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n if (!this.scanner.invokesGit(segment, 'checkout') && !this.scanner.invokesGit(segment, 'switch')) continue;\n if (!this.switchesToMainBranch(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * True only for landing ON the branch. `-b`/`-B`/`-c`/`-C` CREATE a branch, so\n * `git checkout -b x origin/main` is current by construction and never blocked; a `--` turns the\n * rest into pathspecs, so `git checkout -- main` restores a FILE named main and moves no branch.\n */\n private switchesToMainBranch(segment: string): boolean {\n const words = this.scanner.words(segment);\n for (let i = 0; i < words.length; i++) {\n const word = words[i];\n if (word === '--') return false;\n if (/^-[bBcC]$/.test(word)) return false;\n if (i > 1 && word === 'main') return true;\n }\n return false;\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had. '\n + 'That is not only stale FILES — it also reverts `package.json`\\'s @webpieces pin and the '\n + 'guard shim under `.claude/webpieces/`, so the very hook that would diagnose the resulting '\n + 'version drift is replaced by an older copy that reports it BACKWARDS and names the cure '\n + 'that makes it worse. Chain the pull into the same command, leaving no window in which you '\n + 'are on a stale main:\\n' + steps;\n }\n\n // The first segment that would read stale workspace content, or null when none does. The RAW\n // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted\n // prose can only ever block LESS (see BashContext.commandCode).\n private staleContentRead(ctx: BashContext): string | null {\n const scan = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {\n const hit = scan.readsStaleContent(segment);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,\n // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true;\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return true; // cannot tell → assume dirty, the fail-OPEN direction here\n }\n }\n\n private staleMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));\n }\n\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, MATRIX_L2),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { WholeRepoBuildGuardConfig } from '@webpieces/rules-config';
|
|
2
|
+
import type { BashContext, Violation } from '../types';
|
|
3
|
+
import { BashRuleBase } from '../rule-base';
|
|
4
|
+
import { FixHint } from '../fix-hint';
|
|
5
|
+
/**
|
|
6
|
+
* Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow
|
|
7
|
+
* command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the
|
|
8
|
+
* decision, the log line and the two refusal messages.
|
|
9
|
+
*
|
|
10
|
+
* ─── Why: SCOPE and AGREEMENT, not a speed claim ───────────────────────────────────────────────────
|
|
11
|
+
* `nx affected --target=ci --base=<fork point>` is the command the PR gate itself runs
|
|
12
|
+
* (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo
|
|
13
|
+
* build is a different, wider command whose green says nothing extra — it just also compiles projects
|
|
14
|
+
* the change cannot reach.
|
|
15
|
+
*
|
|
16
|
+
* It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a
|
|
17
|
+
* `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo
|
|
18
|
+
* build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for
|
|
19
|
+
* LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold
|
|
20
|
+
* nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total
|
|
21
|
+
* test time under contention) — neither of which a narrower target list fixes on its own.
|
|
22
|
+
*
|
|
23
|
+
* So what this guard buys is the scope being right by default. Building the world is never the
|
|
24
|
+
* correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped
|
|
25
|
+
* the wide one.
|
|
26
|
+
*
|
|
27
|
+
* ─── The message is READ FROM CONFIG, and it is RESOLVED ───────────────────────────────────────────
|
|
28
|
+
* The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's
|
|
29
|
+
* config by load-config), so the refusal follows the project when the gate command changes. The `$(…)`
|
|
30
|
+
* in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is
|
|
31
|
+
* handing it a template, and a template pasted where no shell expands it produces a confusing failure
|
|
32
|
+
* that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git;
|
|
33
|
+
* anything else is left verbatim.
|
|
34
|
+
*
|
|
35
|
+
* ─── Two messages, chosen by ~/.webpieces/config.json ──────────────────────────────────────────────
|
|
36
|
+
* With `experimental.buildGateLogCapture` ON, the pr-gate captures its build's full output to a log
|
|
37
|
+
* file and hands the agent that path instead of a rebuild instruction — so the right advice is not
|
|
38
|
+
* "build smaller", it is "do not build; stage ② already builds and you can READ the result". That flag
|
|
39
|
+
* lives in the OPTIONAL machine-local `~/.webpieces/config.json`; absent (the state of essentially
|
|
40
|
+
* every consumer) means the first message. `HomeConfigService` is the one reader of that file.
|
|
41
|
+
*
|
|
42
|
+
* ─── Humans are not affected, by construction ──────────────────────────────────────────────────────
|
|
43
|
+
* This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing
|
|
44
|
+
* `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is
|
|
45
|
+
* deliberately left in package.json for exactly that reason. The guard is about what the AI does in a
|
|
46
|
+
* loop, not about the command being wrong for a person who chooses to run it once.
|
|
47
|
+
*/
|
|
48
|
+
export declare class WholeRepoBuildGuardRule extends BashRuleBase<WholeRepoBuildGuardConfig> {
|
|
49
|
+
constructor(config: WholeRepoBuildGuardConfig);
|
|
50
|
+
private readonly scanner;
|
|
51
|
+
private readonly homeConfig;
|
|
52
|
+
readonly description: string;
|
|
53
|
+
/**
|
|
54
|
+
* The command this rule last printed, so the fix hint and the violation message are one string and
|
|
55
|
+
* cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the
|
|
56
|
+
* getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the
|
|
57
|
+
* drift this guard's own docstring says a duplicated command string causes.
|
|
58
|
+
*/
|
|
59
|
+
private resolvedCommand;
|
|
60
|
+
get fixHint(): FixHint;
|
|
61
|
+
check(ctx: BashContext): readonly Violation[];
|
|
62
|
+
private message;
|
|
63
|
+
/**
|
|
64
|
+
* The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, injected
|
|
65
|
+
* into this guard's config by load-config exactly as the other guards' command hints are, falling
|
|
66
|
+
* back to the same DEFAULT_BUILD_COMMAND the gate itself falls back to. This guard never spells a
|
|
67
|
+
* build command of its own — a second copy is how a refusal starts teaching a command the gate
|
|
68
|
+
* does not run.
|
|
69
|
+
*/
|
|
70
|
+
private buildCommandTemplate;
|
|
71
|
+
/**
|
|
72
|
+
* The configured build command with its `$(git …)` substitutions expanded, so what is printed is
|
|
73
|
+
* runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a
|
|
74
|
+
* guard may degrade its own message, never fail the tool call it is judging.
|
|
75
|
+
*/
|
|
76
|
+
private resolvedBuildCommand;
|
|
77
|
+
private capture;
|
|
78
|
+
/**
|
|
79
|
+
* Is build-log capture on? The OPTIONAL `~/.webpieces/config.json` is absent for essentially every
|
|
80
|
+
* consumer, and absent means false, silently — so a broken or unreadable home config can never be
|
|
81
|
+
* the reason a Bash command is judged differently. Fail toward the ordinary message.
|
|
82
|
+
*/
|
|
83
|
+
private captureEnabled;
|
|
84
|
+
private allow;
|
|
85
|
+
private block;
|
|
86
|
+
private truncate;
|
|
87
|
+
private logDecision;
|
|
88
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WholeRepoBuildGuardRule = void 0;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
6
|
+
const types_1 = require("../types");
|
|
7
|
+
const rule_base_1 = require("../rule-base");
|
|
8
|
+
const fix_hint_1 = require("../fix-hint");
|
|
9
|
+
const to_error_1 = require("../to-error");
|
|
10
|
+
const l0_fault_codes_1 = require("../l0-fault-codes");
|
|
11
|
+
const decision_log_1 = require("../decision-log");
|
|
12
|
+
const command_scan_1 = require("../command-scan");
|
|
13
|
+
const whole_repo_build_scan_1 = require("./whole-repo-build-scan");
|
|
14
|
+
/**
|
|
15
|
+
* Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow
|
|
16
|
+
* command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the
|
|
17
|
+
* decision, the log line and the two refusal messages.
|
|
18
|
+
*
|
|
19
|
+
* ─── Why: SCOPE and AGREEMENT, not a speed claim ───────────────────────────────────────────────────
|
|
20
|
+
* `nx affected --target=ci --base=<fork point>` is the command the PR gate itself runs
|
|
21
|
+
* (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo
|
|
22
|
+
* build is a different, wider command whose green says nothing extra — it just also compiles projects
|
|
23
|
+
* the change cannot reach.
|
|
24
|
+
*
|
|
25
|
+
* It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a
|
|
26
|
+
* `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo
|
|
27
|
+
* build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for
|
|
28
|
+
* LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold
|
|
29
|
+
* nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total
|
|
30
|
+
* test time under contention) — neither of which a narrower target list fixes on its own.
|
|
31
|
+
*
|
|
32
|
+
* So what this guard buys is the scope being right by default. Building the world is never the
|
|
33
|
+
* correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped
|
|
34
|
+
* the wide one.
|
|
35
|
+
*
|
|
36
|
+
* ─── The message is READ FROM CONFIG, and it is RESOLVED ───────────────────────────────────────────
|
|
37
|
+
* The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's
|
|
38
|
+
* config by load-config), so the refusal follows the project when the gate command changes. The `$(…)`
|
|
39
|
+
* in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is
|
|
40
|
+
* handing it a template, and a template pasted where no shell expands it produces a confusing failure
|
|
41
|
+
* that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git;
|
|
42
|
+
* anything else is left verbatim.
|
|
43
|
+
*
|
|
44
|
+
* ─── Two messages, chosen by ~/.webpieces/config.json ──────────────────────────────────────────────
|
|
45
|
+
* With `experimental.buildGateLogCapture` ON, the pr-gate captures its build's full output to a log
|
|
46
|
+
* file and hands the agent that path instead of a rebuild instruction — so the right advice is not
|
|
47
|
+
* "build smaller", it is "do not build; stage ② already builds and you can READ the result". That flag
|
|
48
|
+
* lives in the OPTIONAL machine-local `~/.webpieces/config.json`; absent (the state of essentially
|
|
49
|
+
* every consumer) means the first message. `HomeConfigService` is the one reader of that file.
|
|
50
|
+
*
|
|
51
|
+
* ─── Humans are not affected, by construction ──────────────────────────────────────────────────────
|
|
52
|
+
* This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing
|
|
53
|
+
* `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is
|
|
54
|
+
* deliberately left in package.json for exactly that reason. The guard is about what the AI does in a
|
|
55
|
+
* loop, not about the command being wrong for a person who chooses to run it once.
|
|
56
|
+
*/
|
|
57
|
+
class WholeRepoBuildGuardRule extends rule_base_1.BashRuleBase {
|
|
58
|
+
constructor(config) { super(config, 'whole-repo-build-guard'); }
|
|
59
|
+
scanner = new command_scan_1.CommandScanner();
|
|
60
|
+
homeConfig = new rules_config_1.HomeConfigService();
|
|
61
|
+
description = 'Block a whole-monorepo build (build-all, an unnarrowed nx run-many, nx affected with no ' +
|
|
62
|
+
'--base, a bare vitest run) and name the affected-scoped command to run instead.';
|
|
63
|
+
/**
|
|
64
|
+
* The command this rule last printed, so the fix hint and the violation message are one string and
|
|
65
|
+
* cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the
|
|
66
|
+
* getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the
|
|
67
|
+
* drift this guard's own docstring says a duplicated command string causes.
|
|
68
|
+
*/
|
|
69
|
+
resolvedCommand = '';
|
|
70
|
+
get fixHint() {
|
|
71
|
+
const command = this.resolvedCommand !== '' ? this.resolvedCommand : this.buildCommandTemplate();
|
|
72
|
+
return new fix_hint_1.FixHint('That command builds the WHOLE monorepo. Build only what your change affects.', 'Build the affected projects, or one project, or one spec file — never the workspace:\n' +
|
|
73
|
+
` ${command} # the gate's own build\n` +
|
|
74
|
+
' pnpm nx run <project>:ci # one project\n' +
|
|
75
|
+
' pnpm exec vitest run <path> # one suite\n' +
|
|
76
|
+
'Disable in webpieces.config.json under hookGuards → whole-repo-build-guard (mode OFF) if intentional.');
|
|
77
|
+
}
|
|
78
|
+
check(ctx) {
|
|
79
|
+
// Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever
|
|
80
|
+
// block LESS, and this repo's own commit messages are full of the command names above.
|
|
81
|
+
const hit = new whole_repo_build_scan_1.WholeRepoBuildScan(this.scanner, ctx.effectiveCwd === ctx.workspaceRoot)
|
|
82
|
+
.firstHit(ctx.commandCode);
|
|
83
|
+
if (hit === null)
|
|
84
|
+
return this.allow(ctx, 'not-a-whole-repo-build');
|
|
85
|
+
// Resolved ONCE, here, and read by both the violation message and the fix hint.
|
|
86
|
+
this.resolvedCommand = this.resolvedBuildCommand(ctx.workspaceRoot);
|
|
87
|
+
return this.block(ctx, hit, this.message());
|
|
88
|
+
}
|
|
89
|
+
// The whole refusal. Short on purpose: it is read mid-task by an agent that needs the ONE command
|
|
90
|
+
// to run next, and a guard message long enough to skim is a guard message that gets skimmed.
|
|
91
|
+
message() {
|
|
92
|
+
if (this.captureEnabled()) {
|
|
93
|
+
return 'Blocked: that builds the WHOLE monorepo — and you should not be building at all.\n'
|
|
94
|
+
+ '`pnpm wp-review-upsert-pr` (stage ②) runs the build for you, captures the full output to a\n'
|
|
95
|
+
+ 'log file, and names that file if it fails. Read the file; do not rebuild.\n'
|
|
96
|
+
+ 'Need a check before then: pnpm exec vitest run <path>.';
|
|
97
|
+
}
|
|
98
|
+
return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\n\n'
|
|
99
|
+
+ ` ${this.resolvedCommand}\n\n`
|
|
100
|
+
+ 'Narrower still: pnpm nx run <project>:ci, or pnpm exec vitest run <path>.';
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, injected
|
|
104
|
+
* into this guard's config by load-config exactly as the other guards' command hints are, falling
|
|
105
|
+
* back to the same DEFAULT_BUILD_COMMAND the gate itself falls back to. This guard never spells a
|
|
106
|
+
* build command of its own — a second copy is how a refusal starts teaching a command the gate
|
|
107
|
+
* does not run.
|
|
108
|
+
*/
|
|
109
|
+
buildCommandTemplate() {
|
|
110
|
+
const configured = this.config.affectedBuildCommand ?? '';
|
|
111
|
+
return configured.trim() === '' ? rules_config_1.DEFAULT_BUILD_COMMAND : configured;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The configured build command with its `$(git …)` substitutions expanded, so what is printed is
|
|
115
|
+
* runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a
|
|
116
|
+
* guard may degrade its own message, never fail the tool call it is judging.
|
|
117
|
+
*/
|
|
118
|
+
resolvedBuildCommand(workspaceRoot) {
|
|
119
|
+
const template = this.buildCommandTemplate();
|
|
120
|
+
return template.replace(/\$\(([^()]*)\)/g, (match, inner) => {
|
|
121
|
+
const trimmed = inner.trim();
|
|
122
|
+
if (!trimmed.startsWith('git '))
|
|
123
|
+
return match;
|
|
124
|
+
const output = this.capture(trimmed, workspaceRoot);
|
|
125
|
+
return output === null ? match : output;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
capture(command, workspaceRoot) {
|
|
129
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
130
|
+
try {
|
|
131
|
+
return (0, child_process_1.execSync)(command, {
|
|
132
|
+
cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
133
|
+
}).trim();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const error = (0, to_error_1.toError)(err);
|
|
137
|
+
void error;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Is build-log capture on? The OPTIONAL `~/.webpieces/config.json` is absent for essentially every
|
|
143
|
+
* consumer, and absent means false, silently — so a broken or unreadable home config can never be
|
|
144
|
+
* the reason a Bash command is judged differently. Fail toward the ordinary message.
|
|
145
|
+
*/
|
|
146
|
+
captureEnabled() {
|
|
147
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
148
|
+
try {
|
|
149
|
+
return this.homeConfig.load().buildGateLogCapture;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const error = (0, to_error_1.toError)(err);
|
|
153
|
+
void error;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
allow(ctx, reason) {
|
|
158
|
+
this.logDecision(ctx, 'ALLOW', reason);
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
block(ctx, hit, message) {
|
|
162
|
+
// BLOCK_AI_CURE, not BLOCK_HUMAN: the cure is one command the agent runs itself, and the
|
|
163
|
+
// refusal hands it over already resolved.
|
|
164
|
+
this.logDecision(ctx, 'BLOCK_AI_CURE', hit.shape);
|
|
165
|
+
return [new types_1.Violation(1, this.truncate(ctx.command), message)];
|
|
166
|
+
}
|
|
167
|
+
truncate(s) {
|
|
168
|
+
const MAX = 120;
|
|
169
|
+
return s.length <= MAX ? s : s.slice(0, MAX) + '…';
|
|
170
|
+
}
|
|
171
|
+
logDecision(ctx, verdict, reason) {
|
|
172
|
+
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('whole-repo-build-guard', 'Bash', ctx.command, '-', verdict, reason, '-', l0_fault_codes_1.L0_FAULT_NONE, decision_log_1.MATRIX_L2));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
exports.WholeRepoBuildGuardRule = WholeRepoBuildGuardRule;
|
|
176
|
+
//# sourceMappingURL=whole-repo-build-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whole-repo-build-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/whole-repo-build-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { WholeRepoBuildGuardConfig, DEFAULT_BUILD_COMMAND, HomeConfigService } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { logGuardDecision, GuardDecision, Verdict, MATRIX_L2 } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { WholeRepoBuildScan, WholeRepoBuildHit } from './whole-repo-build-scan';\n\n/**\n * Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow\n * command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the\n * decision, the log line and the two refusal messages.\n *\n * ─── Why: SCOPE and AGREEMENT, not a speed claim ───────────────────────────────────────────────────\n * `nx affected --target=ci --base=<fork point>` is the command the PR gate itself runs\n * (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo\n * build is a different, wider command whose green says nothing extra — it just also compiles projects\n * the change cannot reach.\n *\n * It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a\n * `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo\n * build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for\n * LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold\n * nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total\n * test time under contention) — neither of which a narrower target list fixes on its own.\n *\n * So what this guard buys is the scope being right by default. Building the world is never the\n * correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped\n * the wide one.\n *\n * ─── The message is READ FROM CONFIG, and it is RESOLVED ───────────────────────────────────────────\n * The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's\n * config by load-config), so the refusal follows the project when the gate command changes. The `$(…)`\n * in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is\n * handing it a template, and a template pasted where no shell expands it produces a confusing failure\n * that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git;\n * anything else is left verbatim.\n *\n * ─── Two messages, chosen by ~/.webpieces/config.json ──────────────────────────────────────────────\n * With `experimental.buildGateLogCapture` ON, the pr-gate captures its build's full output to a log\n * file and hands the agent that path instead of a rebuild instruction — so the right advice is not\n * \"build smaller\", it is \"do not build; stage ② already builds and you can READ the result\". That flag\n * lives in the OPTIONAL machine-local `~/.webpieces/config.json`; absent (the state of essentially\n * every consumer) means the first message. `HomeConfigService` is the one reader of that file.\n *\n * ─── Humans are not affected, by construction ──────────────────────────────────────────────────────\n * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing\n * `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is\n * deliberately left in package.json for exactly that reason. The guard is about what the AI does in a\n * loop, not about the command being wrong for a person who chooses to run it once.\n */\nexport class WholeRepoBuildGuardRule extends BashRuleBase<WholeRepoBuildGuardConfig> {\n constructor(config: WholeRepoBuildGuardConfig) { super(config, 'whole-repo-build-guard'); }\n\n private readonly scanner = new CommandScanner();\n private readonly homeConfig = new HomeConfigService();\n\n readonly description =\n 'Block a whole-monorepo build (build-all, an unnarrowed nx run-many, nx affected with no ' +\n '--base, a bare vitest run) and name the affected-scoped command to run instead.';\n\n /**\n * The command this rule last printed, so the fix hint and the violation message are one string and\n * cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the\n * getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the\n * drift this guard's own docstring says a duplicated command string causes.\n */\n private resolvedCommand = '';\n\n get fixHint(): FixHint {\n const command = this.resolvedCommand !== '' ? this.resolvedCommand : this.buildCommandTemplate();\n return new FixHint(\n 'That command builds the WHOLE monorepo. Build only what your change affects.',\n 'Build the affected projects, or one project, or one spec file — never the workspace:\\n' +\n ` ${command} # the gate's own build\\n` +\n ' pnpm nx run <project>:ci # one project\\n' +\n ' pnpm exec vitest run <path> # one suite\\n' +\n 'Disable in webpieces.config.json under hookGuards → whole-repo-build-guard (mode OFF) if intentional.',\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n // Blocklist-shaped, so match on commandCode: stripping heredocs and quoted prose can only ever\n // block LESS, and this repo's own commit messages are full of the command names above.\n const hit = new WholeRepoBuildScan(this.scanner, ctx.effectiveCwd === ctx.workspaceRoot)\n .firstHit(ctx.commandCode);\n if (hit === null) return this.allow(ctx, 'not-a-whole-repo-build');\n\n // Resolved ONCE, here, and read by both the violation message and the fix hint.\n this.resolvedCommand = this.resolvedBuildCommand(ctx.workspaceRoot);\n return this.block(ctx, hit, this.message());\n }\n\n // The whole refusal. Short on purpose: it is read mid-task by an agent that needs the ONE command\n // to run next, and a guard message long enough to skim is a guard message that gets skimmed.\n private message(): string {\n if (this.captureEnabled()) {\n return 'Blocked: that builds the WHOLE monorepo — and you should not be building at all.\\n'\n + '`pnpm wp-review-upsert-pr` (stage ②) runs the build for you, captures the full output to a\\n'\n + 'log file, and names that file if it fails. Read the file; do not rebuild.\\n'\n + 'Need a check before then: pnpm exec vitest run <path>.';\n }\n return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\\n\\n'\n + ` ${this.resolvedCommand}\\n\\n`\n + 'Narrower still: pnpm nx run <project>:ci, or pnpm exec vitest run <path>.';\n }\n\n /**\n * The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, injected\n * into this guard's config by load-config exactly as the other guards' command hints are, falling\n * back to the same DEFAULT_BUILD_COMMAND the gate itself falls back to. This guard never spells a\n * build command of its own — a second copy is how a refusal starts teaching a command the gate\n * does not run.\n */\n private buildCommandTemplate(): string {\n const configured = this.config.affectedBuildCommand ?? '';\n return configured.trim() === '' ? DEFAULT_BUILD_COMMAND : configured;\n }\n\n /**\n * The configured build command with its `$(git …)` substitutions expanded, so what is printed is\n * runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a\n * guard may degrade its own message, never fail the tool call it is judging.\n */\n private resolvedBuildCommand(workspaceRoot: string): string {\n const template = this.buildCommandTemplate();\n return template.replace(/\\$\\(([^()]*)\\)/g, (match: string, inner: string): string => {\n const trimmed = inner.trim();\n if (!trimmed.startsWith('git ')) return match;\n const output = this.capture(trimmed, workspaceRoot);\n return output === null ? match : output;\n });\n }\n\n private capture(command: string, workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync(command, {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * Is build-log capture on? The OPTIONAL `~/.webpieces/config.json` is absent for essentially every\n * consumer, and absent means false, silently — so a broken or unreadable home config can never be\n * the reason a Bash command is judged differently. Fail toward the ordinary message.\n */\n private captureEnabled(): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return this.homeConfig.load().buildGateLogCapture;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n private allow(ctx: BashContext, reason: string): readonly Violation[] {\n this.logDecision(ctx, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: BashContext, hit: WholeRepoBuildHit, message: string): readonly Violation[] {\n // BLOCK_AI_CURE, not BLOCK_HUMAN: the cure is one command the agent runs itself, and the\n // refusal hands it over already resolved.\n this.logDecision(ctx, 'BLOCK_AI_CURE', hit.shape);\n return [new V(1, this.truncate(ctx.command), message)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, verdict: Verdict, reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('whole-repo-build-guard', 'Bash', ctx.command, '-', verdict, reason, '-', L0_FAULT_NONE, MATRIX_L2),\n );\n }\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { CommandScanner } from '../command-scan';
|
|
2
|
+
/** One whole-repo build found in a command: the segment, and the SHAPE that made it one. Data-only. */
|
|
3
|
+
export declare class WholeRepoBuildHit {
|
|
4
|
+
segment: string;
|
|
5
|
+
shape: string;
|
|
6
|
+
constructor(segment: string, shape: string);
|
|
7
|
+
}
|
|
8
|
+
export declare class WholeRepoBuildScan {
|
|
9
|
+
private readonly scanner;
|
|
10
|
+
private readonly atWorkspaceRoot;
|
|
11
|
+
constructor(scanner: CommandScanner, atWorkspaceRoot: boolean);
|
|
12
|
+
/** The first segment of `command` that builds the whole repo, or null when none does. */
|
|
13
|
+
firstHit(command: string): WholeRepoBuildHit | null;
|
|
14
|
+
private shapeOf;
|
|
15
|
+
/**
|
|
16
|
+
* The segment's words with wrappers stripped: shell prefixes (`time`, `sudo`, env assignments) by
|
|
17
|
+
* CommandScanner, then the package-manager runner and its verb. `pnpm exec vitest run` and
|
|
18
|
+
* `vitest run` reduce to the same words, which is the whole point.
|
|
19
|
+
*/
|
|
20
|
+
private effectiveWords;
|
|
21
|
+
private programName;
|
|
22
|
+
private isNarrowed;
|
|
23
|
+
private nxShape;
|
|
24
|
+
private vitestShape;
|
|
25
|
+
/**
|
|
26
|
+
* Does this nx invocation run a COMPILE/TEST target? Targets come from `-t`/`--target`/`--targets`
|
|
27
|
+
* in either spelling, comma-separated, plus (for `run-many <target>`) a second positional.
|
|
28
|
+
*
|
|
29
|
+
* An invocation naming NO target is not judged a build: nx errors on it, and guessing would block a
|
|
30
|
+
* typo with a message about build scope. An invocation naming only regeneration targets
|
|
31
|
+
* (`di-graph-generate`, `generate`) is deliberately allowed workspace-wide — see BUILD_TARGETS.
|
|
32
|
+
*/
|
|
33
|
+
private buildsAnything;
|
|
34
|
+
private hasFlag;
|
|
35
|
+
}
|