import type { BashContext, Violation } from '../types'; import { BashRuleBase, EmptyRuleConfig } from '../rule-base'; import { FixHint } from '../fix-hint'; /** * Blocks a Bash command that would build or test the WHOLE monorepo, and hands back the narrow * command to run instead. Which shapes count is `WholeRepoBuildScan`'s job; this rule owns the * decision, the log line and the two refusal messages. * * ─── Why: SCOPE and AGREEMENT, not a speed claim ─────────────────────────────────────────────────── * `nx affected --target=ci --base=` is the command the PR gate itself runs * (`commands.pr-gate.buildCommand`), so a green local result is evidence about the gate. A whole-repo * build is a different, wider command whose green says nothing extra — it just also compiles projects * the change cannot reach. * * It is NOT automatically faster, and this guard deliberately does not claim it is. Measured on a * `core-util` change, `affected` selected the IDENTICAL 20 projects / 104 tasks as the whole-repo * build: a package at the BASE of the dependency graph prunes nothing. The pruning win is real for * LEAF projects and absent for base ones. The long builds people blamed on scope were caused by a cold * nx cache and by CPU contention between agents running full sweeps at once (measured: ~3.2x total * test time under contention) — neither of which a narrower target list fixes on its own. * * So what this guard buys is the scope being right by default. Building the world is never the * correct inner-loop move in a monorepo; the correct one has existed all along, and nothing stopped * the wide one. * * ─── The message is READ FROM CONFIG, and it is RESOLVED ─────────────────────────────────────────── * The replacement command comes from `commands.pr-gate.buildCommand` (injected into this guard's * config by load-config), so the refusal follows the project when the gate command changes. The `$(…)` * in it is EXPANDED before printing: handing an agent `--base=$(git merge-base origin/main HEAD)` is * handing it a template, and a template pasted where no shell expands it produces a confusing failure * that reads like the guard's advice was wrong. Only `$(git …)` is expanded, and only read-only git; * anything else is left verbatim. * * ─── EXPERIMENTAL, OFF by default, with ONE machine-local opt-in ─────────────────────────────────── * `experimental.whole-repo-build-guard` in `~/.webpieces/config.json` is this guard's only switch, and * it is an OPT-IN: ON requires the explicit boolean `true`. Absent key, absent `experimental` section, * absent file, and an explicit `false` are ONE state, and that state is OFF. * * That direction is a standing policy, not a per-flag judgement: EVERY `experimental.*` flag in this * codebase ships OFF and stays OFF for two years. A flag that defaults ON is not an experiment — it is * a shipped behaviour that skipped its soak period, and it changes what every agent on every machine * can do the moment they upgrade. If few machines opt in, that is information about the experiment; it * is not a reason to force it on everybody. * * There is also NO webpieces.config.json entry, for a separate reason: a live incident. This guard * first shipped with `mode: 'ON'` by default AND a REQUIRED entry under `hookGuards`, and every * consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature they had never asked * for. The fault was the REQUIRED KEY: the failure was at config LOAD, before any command was judged, * and the cure was "edit a file to get your shell back". Keeping the switch in an OPTIONAL * machine-local file is what makes the default state need no file, no key and no edit. * * Three states, and only three: * - the file does not exist (essentially every machine), or exists without the key → the guard is * OFF, and inert: no block, no log, no message. Every key in that file is optional, because it is * MACHINE-GLOBAL and the repos on one machine pin different webpieces releases — a required key * there is unsatisfiable (see home-config.ts). * - the key is present → its boolean decides, and only `true` arms the guard. * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE * naming the edit. That is a file somebody wrote wrongly, not one written for another release. * Editing that file is an unconditional PASS in the guards, so the block is always self-curable. * * ─── ONE message, because the gate now always captures ───────────────────────────────────────────── * There used to be two refusals here, chosen by `experimental.buildGateLogCapture`: one naming the * narrower build to run, and one saying "do not build at all — stage ② already builds and you can READ * the result". That key is gone, because capturing the build's full output to a file is no longer * optional. What survived is the FIRST of those, now carrying the log pointer the second one existed to * offer: a refusal still has to answer "so what do I run?", and "run nothing" is only an answer when the * caller was already inside the PR flow. * * ─── Humans are not affected, by construction ────────────────────────────────────────────────────── * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing * `pnpm run build-all` in their own terminal never reaches a hook, and the `build-all` script itself is * deliberately left in package.json for exactly that reason. The guard is about what the AI does in a * loop, not about the command being wrong for a person who chooses to run it once. */ export declare class WholeRepoBuildGuardRule extends BashRuleBase { private readonly affectedBuildCommand; /** * `affectedBuildCommand` is the project's gate command (`commands.pr-gate.buildCommand`), handed in * by the runner off the loaded config. It is a CONSTRUCTOR ARGUMENT rather than a config field * because this guard has no config entry to read one from — and that is the point. */ constructor(affectedBuildCommand: string); private readonly scanner; private readonly homeConfig; /** Set by check() when the home config is unreadable, so the fix hint names the same repair. */ private homeConfigError; readonly description: string; /** * The command this rule last printed, so the fix hint and the violation message are one string and * cannot disagree. Empty until check() runs (fixHint is also read without it), at which point the * getter falls back to the configured TEMPLATE — never to a second literal, which is exactly the * drift this guard's own docstring says a duplicated command string causes. */ private resolvedCommand; get fixHint(): FixHint; check(ctx: BashContext): readonly Violation[]; /** * The home config, or the Error explaining why it is unusable. NOT a boolean: "absent" is already * folded into the returned HomeConfig (each key's default — for this guard, OFF), so the only thing * left to distinguish is "present and wrong", which must be reported rather than swallowed. */ private loadHome; private blockOnBrokenHomeConfig; private brokenHomeConfigMessage; private message; /** * The project's build command, unexpanded. ONE source: `commands.pr-gate.buildCommand`, handed to * the constructor by the runner, falling back to the same DEFAULT_BUILD_COMMAND the gate itself * falls back to. This guard never spells a build command of its own — a second copy is how a refusal * starts teaching a command the gate does not run. */ private buildCommandTemplate; /** * The configured build command with its `$(git …)` substitutions expanded, so what is printed is * runnable as-is. Expansion is limited to git, and any failure leaves the template untouched — a * guard may degrade its own message, never fail the tool call it is judging. */ private resolvedBuildCommand; private capture; private allow; private block; private truncate; private logDecision; }