@webpieces/ai-hook-rules 0.4.692 → 0.4.694

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.
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BuildOutputPipeScan = exports.BoundedOutputHit = exports.BOUND_BY_REDIRECT = exports.BOUND_BY_PIPE = exports.LOGGED_BUILD_COMMANDS = void 0;
4
+ /**
5
+ * Decides the one question `build-output-pipe-guard` asks: does this command BOUND the output of a
6
+ * webpieces command that already writes its output to a file?
7
+ *
8
+ * ─── The three commands, and why only these three ──────────────────────────────────────────────────
9
+ * `wp-build`, `wp-review-upsert-pr` and `wp-finish-upsert-pr` are the commands that RUN A BUILD. Each
10
+ * one redirects the build's full stdout+stderr to a log file, prints a ~10-second heartbeat while it
11
+ * runs, and ends with a `FullLog :` pointer at the file. Nothing else in the `wp-*` family both takes
12
+ * minutes and writes a log, so nothing else belongs on this list — a guard that also refused
13
+ * `pnpm wp-cleanup | tail` would be refusing something with no cure.
14
+ *
15
+ * ─── What is a hit ─────────────────────────────────────────────────────────────────────────────────
16
+ * BLOCKED a PIPE out of the command — `pnpm wp-build | tail -50`, `… 2>&1 | grep error`, `| tee`.
17
+ * A pipe is the measured hazard and it does not depend on WHAT it feeds: the pipeline's
18
+ * reader (`tail`, `head`, `grep`, `wc`) withholds every byte until the writer EXITS, so the
19
+ * heartbeat never reaches the terminal, the harness sees a command silent for 600 seconds,
20
+ * and it kills a full build. Measured in this repo's own call log: 85 piped `wp-*` calls,
21
+ * 42 of them on one of these three commands.
22
+ * BLOCKED a stdout REDIRECT to a file — `pnpm wp-build > /tmp/out.log`. Same silence, and it also
23
+ * makes a SECOND copy of a log the command already wrote.
24
+ *
25
+ * ALLOWED the bare command, which is the whole cure.
26
+ * ALLOWED `2>&1` on its own — it rewires fds and buffers nothing. `ShellSegmentScan.redirectsToFile`
27
+ * owns that carve-out, and it is shared rather than re-spelled here.
28
+ * ALLOWED every other command, piped or not. This guard is blocklist-shaped and narrow on purpose.
29
+ */
30
+ /** The commands whose output already goes to a file, so bounding it can only ever lose information. */
31
+ exports.LOGGED_BUILD_COMMANDS = [
32
+ 'wp-build', 'wp-review-upsert-pr', 'wp-finish-upsert-pr',
33
+ ];
34
+ const LOGGED_SET = new Set(exports.LOGGED_BUILD_COMMANDS);
35
+ /** How the output was bounded. The two shapes read differently in a refusal, so they are named. */
36
+ exports.BOUND_BY_PIPE = 'pipe';
37
+ exports.BOUND_BY_REDIRECT = 'redirect';
38
+ /** One bounded invocation: WHICH command, and HOW its output was bounded. Data-only (per CLAUDE.md). */
39
+ class BoundedOutputHit {
40
+ command;
41
+ shape;
42
+ constructor(command, shape) {
43
+ this.command = command;
44
+ this.shape = shape;
45
+ }
46
+ }
47
+ exports.BoundedOutputHit = BoundedOutputHit;
48
+ class BuildOutputPipeScan {
49
+ scanner;
50
+ segments;
51
+ constructor(scanner, segments) {
52
+ this.scanner = scanner;
53
+ this.segments = segments;
54
+ }
55
+ /** The first bounded logging command in `command`, or null when there is none. */
56
+ firstHit(command) {
57
+ const parts = this.scanner.segmentsWithPipes(command);
58
+ for (let i = 0; i < parts.length; i++) {
59
+ const name = this.loggingCommandIn(parts[i]);
60
+ if (name === '')
61
+ continue;
62
+ // The NEXT segment carries the separator that preceded it, so "this segment pipes OUT" is
63
+ // "the segment after me was piped into". Splitting on `|` throws that away; CommandSegment
64
+ // exists precisely so it does not have to be re-derived here.
65
+ if (i + 1 < parts.length && parts[i + 1].pipedInto)
66
+ return new BoundedOutputHit(name, exports.BOUND_BY_PIPE);
67
+ if (this.segments.redirectsToFile(this.scanner.words(parts[i].text))) {
68
+ return new BoundedOutputHit(name, exports.BOUND_BY_REDIRECT);
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+ // The logging command this segment invokes, or '' — `pnpm wp-build`, `npx wp-build` and a bare
74
+ // `wp-build` are one command, which is CommandScanner's `runnerStrippedWords` to decide.
75
+ loggingCommandIn(segment) {
76
+ const words = this.scanner.runnerStrippedWords(segment.text);
77
+ if (words.length === 0)
78
+ return '';
79
+ const program = this.scanner.programName(words[0]);
80
+ return LOGGED_SET.has(program) ? program : '';
81
+ }
82
+ }
83
+ exports.BuildOutputPipeScan = BuildOutputPipeScan;
84
+ //# sourceMappingURL=build-output-pipe-scan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-output-pipe-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/build-output-pipe-scan.ts"],"names":[],"mappings":";;;AAGA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uGAAuG;AAC1F,QAAA,qBAAqB,GAAsB;IACpD,UAAU,EAAE,qBAAqB,EAAE,qBAAqB;CAC3D,CAAC;AAEF,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,6BAAqB,CAAC,CAAC;AAEvE,mGAAmG;AACtF,QAAA,aAAa,GAAG,MAAM,CAAC;AACvB,QAAA,iBAAiB,GAAG,UAAU,CAAC;AAE5C,wGAAwG;AACxG,MAAa,gBAAgB;IACzB,OAAO,CAAS;IAChB,KAAK,CAAS;IAEd,YAAY,OAAe,EAAE,KAAa;QACtC,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;QACvB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;CACJ;AARD,4CAQC;AAED,MAAa,mBAAmB;IAEP;IACA;IAFrB,YACqB,OAAuB,EACvB,QAA0B;QAD1B,YAAO,GAAP,OAAO,CAAgB;QACvB,aAAQ,GAAR,QAAQ,CAAkB;IAC5C,CAAC;IAEJ,kFAAkF;IAClF,QAAQ,CAAC,OAAe;QACpB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACtD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACpC,MAAM,IAAI,GAAG,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YAC7C,IAAI,IAAI,KAAK,EAAE;gBAAE,SAAS;YAC1B,0FAA0F;YAC1F,2FAA2F;YAC3F,8DAA8D;YAC9D,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS;gBAAE,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,qBAAa,CAAC,CAAC;YACrG,IAAI,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;gBACnE,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,yBAAiB,CAAC,CAAC;YACzD,CAAC;QACL,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,+FAA+F;IAC/F,yFAAyF;IACjF,gBAAgB,CAAC,OAAuB;QAC5C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,mBAAmB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC7D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAC;QAClC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACnD,OAAO,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;IAClD,CAAC;CACJ;AA/BD,kDA+BC","sourcesContent":["import { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question `build-output-pipe-guard` asks: does this command BOUND the output of a\n * webpieces command that already writes its output to a file?\n *\n * ─── The three commands, and why only these three ──────────────────────────────────────────────────\n * `wp-build`, `wp-review-upsert-pr` and `wp-finish-upsert-pr` are the commands that RUN A BUILD. Each\n * one redirects the build's full stdout+stderr to a log file, prints a ~10-second heartbeat while it\n * runs, and ends with a `FullLog :` pointer at the file. Nothing else in the `wp-*` family both takes\n * minutes and writes a log, so nothing else belongs on this list — a guard that also refused\n * `pnpm wp-cleanup | tail` would be refusing something with no cure.\n *\n * ─── What is a hit ─────────────────────────────────────────────────────────────────────────────────\n * BLOCKED a PIPE out of the command — `pnpm wp-build | tail -50`, `… 2>&1 | grep error`, `| tee`.\n * A pipe is the measured hazard and it does not depend on WHAT it feeds: the pipeline's\n * reader (`tail`, `head`, `grep`, `wc`) withholds every byte until the writer EXITS, so the\n * heartbeat never reaches the terminal, the harness sees a command silent for 600 seconds,\n * and it kills a full build. Measured in this repo's own call log: 85 piped `wp-*` calls,\n * 42 of them on one of these three commands.\n * BLOCKED a stdout REDIRECT to a file — `pnpm wp-build > /tmp/out.log`. Same silence, and it also\n * makes a SECOND copy of a log the command already wrote.\n *\n * ALLOWED the bare command, which is the whole cure.\n * ALLOWED `2>&1` on its own — it rewires fds and buffers nothing. `ShellSegmentScan.redirectsToFile`\n * owns that carve-out, and it is shared rather than re-spelled here.\n * ALLOWED every other command, piped or not. This guard is blocklist-shaped and narrow on purpose.\n */\n\n/** The commands whose output already goes to a file, so bounding it can only ever lose information. */\nexport const LOGGED_BUILD_COMMANDS: readonly string[] = [\n 'wp-build', 'wp-review-upsert-pr', 'wp-finish-upsert-pr',\n];\n\nconst LOGGED_SET: ReadonlySet<string> = new Set(LOGGED_BUILD_COMMANDS);\n\n/** How the output was bounded. The two shapes read differently in a refusal, so they are named. */\nexport const BOUND_BY_PIPE = 'pipe';\nexport const BOUND_BY_REDIRECT = 'redirect';\n\n/** One bounded invocation: WHICH command, and HOW its output was bounded. Data-only (per CLAUDE.md). */\nexport class BoundedOutputHit {\n command: string;\n shape: string;\n\n constructor(command: string, shape: string) {\n this.command = command;\n this.shape = shape;\n }\n}\n\nexport class BuildOutputPipeScan {\n constructor(\n private readonly scanner: CommandScanner,\n private readonly segments: ShellSegmentScan,\n ) {}\n\n /** The first bounded logging command in `command`, or null when there is none. */\n firstHit(command: string): BoundedOutputHit | null {\n const parts = this.scanner.segmentsWithPipes(command);\n for (let i = 0; i < parts.length; i++) {\n const name = this.loggingCommandIn(parts[i]);\n if (name === '') continue;\n // The NEXT segment carries the separator that preceded it, so \"this segment pipes OUT\" is\n // \"the segment after me was piped into\". Splitting on `|` throws that away; CommandSegment\n // exists precisely so it does not have to be re-derived here.\n if (i + 1 < parts.length && parts[i + 1].pipedInto) return new BoundedOutputHit(name, BOUND_BY_PIPE);\n if (this.segments.redirectsToFile(this.scanner.words(parts[i].text))) {\n return new BoundedOutputHit(name, BOUND_BY_REDIRECT);\n }\n }\n return null;\n }\n\n // The logging command this segment invokes, or '' — `pnpm wp-build`, `npx wp-build` and a bare\n // `wp-build` are one command, which is CommandScanner's `runnerStrippedWords` to decide.\n private loggingCommandIn(segment: CommandSegment): string {\n const words = this.scanner.runnerStrippedWords(segment.text);\n if (words.length === 0) return '';\n const program = this.scanner.programName(words[0]);\n return LOGGED_SET.has(program) ? program : '';\n }\n}\n"]}
@@ -59,12 +59,13 @@ import { FixHint } from '../fix-hint';
59
59
  * naming the edit. That is a file somebody wrote wrongly, not one written for another release.
60
60
  * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
61
61
  *
62
- * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
63
- * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
64
- * to a log file and hands the agent that path instead of a rebuild instruction) and it is NOT this
65
- * guard's switch. It only picks WHICH refusal is printed once the guard is on: with capture ON the right
66
- * advice is not "build smaller", it is "do not build; stage already builds and you can READ the
67
- * result". `HomeConfigService` is the one reader of both keys.
62
+ * ─── ONE message, because the gate now always captures ─────────────────────────────────────────────
63
+ * There used to be two refusals here, chosen by `experimental.buildGateLogCapture`: one naming the
64
+ * narrower build to run, and one saying "do not build at all stage already builds and you can READ
65
+ * the result". That key is gone, because capturing the build's full output to a file is no longer
66
+ * optional. What survived is the FIRST of those, now carrying the log pointer the second one existed to
67
+ * offer: a refusal still has to answer "so what do I run?", and "run nothing" is only an answer when the
68
+ * caller was already inside the PR flow.
68
69
  *
69
70
  * ─── Humans are not affected, by construction ──────────────────────────────────────────────────────
70
71
  * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing
@@ -69,12 +69,13 @@ const whole_repo_build_scan_1 = require("./whole-repo-build-scan");
69
69
  * naming the edit. That is a file somebody wrote wrongly, not one written for another release.
70
70
  * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
71
71
  *
72
- * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
73
- * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
74
- * to a log file and hands the agent that path instead of a rebuild instruction) and it is NOT this
75
- * guard's switch. It only picks WHICH refusal is printed once the guard is on: with capture ON the right
76
- * advice is not "build smaller", it is "do not build; stage already builds and you can READ the
77
- * result". `HomeConfigService` is the one reader of both keys.
72
+ * ─── ONE message, because the gate now always captures ─────────────────────────────────────────────
73
+ * There used to be two refusals here, chosen by `experimental.buildGateLogCapture`: one naming the
74
+ * narrower build to run, and one saying "do not build at all stage already builds and you can READ
75
+ * the result". That key is gone, because capturing the build's full output to a file is no longer
76
+ * optional. What survived is the FIRST of those, now carrying the log pointer the second one existed to
77
+ * offer: a refusal still has to answer "so what do I run?", and "run nothing" is only an answer when the
78
+ * caller was already inside the PR flow.
78
79
  *
79
80
  * ─── Humans are not affected, by construction ──────────────────────────────────────────────────────
80
81
  * This is a PreToolUse hook. It sees the AI's Bash tool calls and nothing else — a human typing
@@ -145,7 +146,7 @@ class WholeRepoBuildGuardRule extends rule_base_1.BashRuleBase {
145
146
  return this.allow(ctx, 'not-a-whole-repo-build');
146
147
  // Resolved ONCE, here, and read by both the violation message and the fix hint.
147
148
  this.resolvedCommand = this.resolvedBuildCommand(ctx.workspaceRoot);
148
- return this.block(ctx, hit, this.message(home));
149
+ return this.block(ctx, hit, this.message());
149
150
  }
150
151
  /**
151
152
  * The home config, or the Error explaining why it is unusable. NOT a boolean: "absent" is already
@@ -177,18 +178,23 @@ class WholeRepoBuildGuardRule extends rule_base_1.BashRuleBase {
177
178
  }
178
179
  // The whole refusal. Short on purpose: it is read mid-task by an agent that needs the ONE command
179
180
  // to run next, and a guard message long enough to skim is a guard message that gets skimmed.
180
- message(home) {
181
- if (home.buildGateLogCapture) {
182
- return 'Blocked: that builds the WHOLE monorepoand you should not be building at all.\n'
183
- + '`pnpm wp-review-upsert-pr` (stage ②) runs the build for you, captures the full output to a\n'
184
- + 'log file, and names that file if it fails. Read the file; do not rebuild.\n'
185
- + 'Need a check before then: pnpm exec vitest run <path>.';
186
- }
187
- // `pnpm wp-build` FIRST, with the command it resolves to shown beside it. One thing to type, and
188
- // it runs `commands.pr-gate.buildCommand` through the gate's own resolver so this refusal can
189
- // never teach a command the gate does not run, however the project reconfigures it.
181
+ //
182
+ // `pnpm wp-build` FIRST, with the command it resolves to shown beside it. One thing to type, and it
183
+ // runs `commands.pr-gate.buildCommand` through the gate's own resolver so this refusal can never
184
+ // teach a command the gate does not run, however the project reconfigures it.
185
+ //
186
+ // The log note is attached to the `wp-build` line ONLY, and that placement is the whole point. A
187
+ // `FullLog :` pointer is written by `GateLogFile.pointer`, which is reachable solely through
188
+ // `BuildGateLog` / `StageOutputLog` i.e. through the gate. A bare `nx run <project>:ci` or
189
+ // `vitest run <path>` writes no such file, so a blanket "whichever you run" claim would send an
190
+ // agent to grep a path that does not exist while this same message forbids the re-run that is its
191
+ // only remaining option. A cure that names an artifact the command does not produce is worse than
192
+ // no cure at all.
193
+ message() {
190
194
  return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\n\n'
191
- + ` pnpm wp-build # runs: ${this.resolvedCommand}\n\n`
195
+ + ` pnpm wp-build # runs: ${this.resolvedCommand}\n`
196
+ + ' (its full output goes to a FILE — it prints "FullLog : <path>"; grep THAT instead of\n'
197
+ + ' rebuilding. Never re-run a build to read a different slice of it.)\n\n'
192
198
  + 'Narrower still: pnpm nx run <project>:ci, or pnpm exec vitest run <path>.';
193
199
  }
194
200
  /**
@@ -1 +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 { DEFAULT_BUILD_COMMAND, HomeConfig, HomeConfigService, InformAiError } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } 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_UNROWED } 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 * ─── EXPERIMENTAL, OFF by default, with ONE machine-local opt-in ───────────────────────────────────\n * `experimental.whole-repo-build-guard` in `~/.webpieces/config.json` is this guard's only switch, and\n * it is an OPT-IN: ON requires the explicit boolean `true`. Absent key, absent `experimental` section,\n * absent file, and an explicit `false` are ONE state, and that state is OFF.\n *\n * That direction is a standing policy, not a per-flag judgement: EVERY `experimental.*` flag in this\n * codebase ships OFF and stays OFF for two years. A flag that defaults ON is not an experiment — it is\n * a shipped behaviour that skipped its soak period, and it changes what every agent on every machine\n * can do the moment they upgrade. If few machines opt in, that is information about the experiment; it\n * is not a reason to force it on everybody.\n *\n * There is also NO webpieces.config.json entry, for a separate reason: a live incident. This guard\n * first shipped with `mode: 'ON'` by default AND a REQUIRED entry under `hookGuards`, and every\n * consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature they had never asked\n * for. The fault was the REQUIRED KEY: the failure was at config LOAD, before any command was judged,\n * and the cure was \"edit a file to get your shell back\". Keeping the switch in an OPTIONAL\n * machine-local file is what makes the default state need no file, no key and no edit.\n *\n * Three states, and only three:\n * - the file does not exist (essentially every machine), or exists without the key → the guard is\n * OFF, and inert: no block, no log, no message. Every key in that file is optional, because it is\n * MACHINE-GLOBAL and the repos on one machine pin different webpieces releases — a required key\n * there is unsatisfiable (see home-config.ts).\n * - the key is present → its boolean decides, and only `true` arms the guard.\n * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE\n * naming the edit. That is a file somebody wrote wrongly, not one written for another release.\n * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.\n *\n * ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────\n * `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output\n * to a log file and hands the agent that path instead of a rebuild instruction) and it is NOT this\n * guard's switch. It only picks WHICH refusal is printed once the guard is on: with capture ON the right\n * advice is not \"build smaller\", it is \"do not build; stage ② already builds and you can READ the\n * result\". `HomeConfigService` is the one reader of both keys.\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<EmptyRuleConfig> {\n /**\n * `affectedBuildCommand` is the project's gate command (`commands.pr-gate.buildCommand`), handed in\n * by the runner off the loaded config. It is a CONSTRUCTOR ARGUMENT rather than a config field\n * because this guard has no config entry to read one from — and that is the point.\n */\n constructor(private readonly affectedBuildCommand: string) {\n // configKey === name and is DELIBERATELY not a real key: this guard has no\n // webpieces.config.json entry at all (see RETIRED_CONFIG_KEYS), and it is loaded outside the\n // config-driven set so the fault-Y sync check never sees it. Naming it here keeps the\n // AbstractRule contract honest without putting the string in HOOK_GUARD_NAMES.\n super(new EmptyRuleConfig(), 'whole-repo-build-guard', 'whole-repo-build-guard');\n }\n\n private readonly scanner = new CommandScanner();\n private readonly homeConfig = new HomeConfigService();\n\n /** Set by check() when the home config is unreadable, so the fix hint names the same repair. */\n private homeConfigError = '';\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 if (this.homeConfigError !== '') {\n return new FixHint(\n '~/.webpieces/config.json exists but cannot be read.',\n 'Edit ~/.webpieces/config.json (editing it is always permitted, including right now), or ' +\n 'delete it outright — with no such file every webpieces command behaves exactly as it ' +\n 'does by default.',\n );\n }\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 ` pnpm wp-build # the gate's own build; runs: ${command}\\n` +\n ' pnpm nx run <project>:ci # one project\\n' +\n ' pnpm exec vitest run <path> # one suite\\n' +\n 'This guard is OFF unless a machine opts in with \"experimental\": ' +\n '{ \"whole-repo-build-guard\": true } in ~/.webpieces/config.json — remove that line to turn ' +\n 'it off again (there is no webpieces.config.json entry for it).',\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n const home = this.loadHome();\n // A file someone deliberately created must be correct — see the class docstring. Reported for\n // whatever command happens to be running, because a broken opt-in file is not a build question.\n if (home instanceof Error) return this.blockOnBrokenHomeConfig(ctx, home);\n\n // THE OPT-IN. False for every machine that has not said `true` out loud, including every machine\n // with no `~/.webpieces/config.json` at all. Being off is silent on purpose: no block, no log, no\n // file touched — the default must be indistinguishable from \"this guard does not exist\".\n this.homeConfigError = '';\n if (!home.wholeRepoBuildGuard) return [];\n\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(home));\n }\n\n /**\n * The home config, or the Error explaining why it is unusable. NOT a boolean: \"absent\" is already\n * folded into the returned HomeConfig (each key's default — for this guard, OFF), so the only thing\n * left to distinguish is \"present and wrong\", which must be reported rather than swallowed.\n */\n private loadHome(): HomeConfig | Error {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a rejected home config is converted\n // into this guard's own block, which carries the loader's fix instruction verbatim\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return this.homeConfig.load();\n } catch (err: unknown) {\n const error = toError(err);\n return error;\n }\n }\n\n private blockOnBrokenHomeConfig(ctx: BashContext, error: Error): readonly Violation[] {\n this.homeConfigError = error.message;\n this.logDecision(ctx, 'BLOCK_AI_CURE', 'home-config-invalid');\n return [new V(1, this.truncate(ctx.command), this.brokenHomeConfigMessage(error))];\n }\n\n private brokenHomeConfigMessage(error: Error): string {\n const detail = error instanceof InformAiError\n ? error.message\n : `~/.webpieces/config.json could not be read: ${error.message}`;\n return `Blocked: ~/.webpieces/config.json is present but unusable.\\n\\n${detail}`;\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(home: HomeConfig): string {\n if (home.buildGateLogCapture) {\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 // `pnpm wp-build` FIRST, with the command it resolves to shown beside it. One thing to type, and\n // it runs `commands.pr-gate.buildCommand` through the gate's own resolver — so this refusal can\n // never teach a command the gate does not run, however the project reconfigures it.\n return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\\n\\n'\n + ` pnpm wp-build # runs: ${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`, handed to\n * the constructor by the runner, falling back to the same DEFAULT_BUILD_COMMAND the gate itself\n * falls back to. This guard never spells a build command of its own — a second copy is how a refusal\n * starts teaching a command the gate does not run.\n */\n private buildCommandTemplate(): string {\n return this.affectedBuildCommand.trim() === '' ? DEFAULT_BUILD_COMMAND : this.affectedBuildCommand;\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 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_UNROWED),\n );\n }\n}\n"]}
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 { DEFAULT_BUILD_COMMAND, HomeConfig, HomeConfigService, InformAiError } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase, EmptyRuleConfig } 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_UNROWED } 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 * ─── EXPERIMENTAL, OFF by default, with ONE machine-local opt-in ───────────────────────────────────\n * `experimental.whole-repo-build-guard` in `~/.webpieces/config.json` is this guard's only switch, and\n * it is an OPT-IN: ON requires the explicit boolean `true`. Absent key, absent `experimental` section,\n * absent file, and an explicit `false` are ONE state, and that state is OFF.\n *\n * That direction is a standing policy, not a per-flag judgement: EVERY `experimental.*` flag in this\n * codebase ships OFF and stays OFF for two years. A flag that defaults ON is not an experiment — it is\n * a shipped behaviour that skipped its soak period, and it changes what every agent on every machine\n * can do the moment they upgrade. If few machines opt in, that is information about the experiment; it\n * is not a reason to force it on everybody.\n *\n * There is also NO webpieces.config.json entry, for a separate reason: a live incident. This guard\n * first shipped with `mode: 'ON'` by default AND a REQUIRED entry under `hookGuards`, and every\n * consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature they had never asked\n * for. The fault was the REQUIRED KEY: the failure was at config LOAD, before any command was judged,\n * and the cure was \"edit a file to get your shell back\". Keeping the switch in an OPTIONAL\n * machine-local file is what makes the default state need no file, no key and no edit.\n *\n * Three states, and only three:\n * - the file does not exist (essentially every machine), or exists without the key → the guard is\n * OFF, and inert: no block, no log, no message. Every key in that file is optional, because it is\n * MACHINE-GLOBAL and the repos on one machine pin different webpieces releases — a required key\n * there is unsatisfiable (see home-config.ts).\n * - the key is present → its boolean decides, and only `true` arms the guard.\n * - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE\n * naming the edit. That is a file somebody wrote wrongly, not one written for another release.\n * Editing that file is an unconditional PASS in the guards, so the block is always self-curable.\n *\n * ─── ONE message, because the gate now always captures ─────────────────────────────────────────────\n * There used to be two refusals here, chosen by `experimental.buildGateLogCapture`: one naming the\n * narrower build to run, and one saying \"do not build at all — stage ② already builds and you can READ\n * the result\". That key is gone, because capturing the build's full output to a file is no longer\n * optional. What survived is the FIRST of those, now carrying the log pointer the second one existed to\n * offer: a refusal still has to answer \"so what do I run?\", and \"run nothing\" is only an answer when the\n * caller was already inside the PR flow.\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<EmptyRuleConfig> {\n /**\n * `affectedBuildCommand` is the project's gate command (`commands.pr-gate.buildCommand`), handed in\n * by the runner off the loaded config. It is a CONSTRUCTOR ARGUMENT rather than a config field\n * because this guard has no config entry to read one from — and that is the point.\n */\n constructor(private readonly affectedBuildCommand: string) {\n // configKey === name and is DELIBERATELY not a real key: this guard has no\n // webpieces.config.json entry at all (see RETIRED_CONFIG_KEYS), and it is loaded outside the\n // config-driven set so the fault-Y sync check never sees it. Naming it here keeps the\n // AbstractRule contract honest without putting the string in HOOK_GUARD_NAMES.\n super(new EmptyRuleConfig(), 'whole-repo-build-guard', 'whole-repo-build-guard');\n }\n\n private readonly scanner = new CommandScanner();\n private readonly homeConfig = new HomeConfigService();\n\n /** Set by check() when the home config is unreadable, so the fix hint names the same repair. */\n private homeConfigError = '';\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 if (this.homeConfigError !== '') {\n return new FixHint(\n '~/.webpieces/config.json exists but cannot be read.',\n 'Edit ~/.webpieces/config.json (editing it is always permitted, including right now), or ' +\n 'delete it outright — with no such file every webpieces command behaves exactly as it ' +\n 'does by default.',\n );\n }\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 ` pnpm wp-build # the gate's own build; runs: ${command}\\n` +\n ' pnpm nx run <project>:ci # one project\\n' +\n ' pnpm exec vitest run <path> # one suite\\n' +\n 'This guard is OFF unless a machine opts in with \"experimental\": ' +\n '{ \"whole-repo-build-guard\": true } in ~/.webpieces/config.json — remove that line to turn ' +\n 'it off again (there is no webpieces.config.json entry for it).',\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n const home = this.loadHome();\n // A file someone deliberately created must be correct — see the class docstring. Reported for\n // whatever command happens to be running, because a broken opt-in file is not a build question.\n if (home instanceof Error) return this.blockOnBrokenHomeConfig(ctx, home);\n\n // THE OPT-IN. False for every machine that has not said `true` out loud, including every machine\n // with no `~/.webpieces/config.json` at all. Being off is silent on purpose: no block, no log, no\n // file touched — the default must be indistinguishable from \"this guard does not exist\".\n this.homeConfigError = '';\n if (!home.wholeRepoBuildGuard) return [];\n\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 /**\n * The home config, or the Error explaining why it is unusable. NOT a boolean: \"absent\" is already\n * folded into the returned HomeConfig (each key's default — for this guard, OFF), so the only thing\n * left to distinguish is \"present and wrong\", which must be reported rather than swallowed.\n */\n private loadHome(): HomeConfig | Error {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: a rejected home config is converted\n // into this guard's own block, which carries the loader's fix instruction verbatim\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return this.homeConfig.load();\n } catch (err: unknown) {\n const error = toError(err);\n return error;\n }\n }\n\n private blockOnBrokenHomeConfig(ctx: BashContext, error: Error): readonly Violation[] {\n this.homeConfigError = error.message;\n this.logDecision(ctx, 'BLOCK_AI_CURE', 'home-config-invalid');\n return [new V(1, this.truncate(ctx.command), this.brokenHomeConfigMessage(error))];\n }\n\n private brokenHomeConfigMessage(error: Error): string {\n const detail = error instanceof InformAiError\n ? error.message\n : `~/.webpieces/config.json could not be read: ${error.message}`;\n return `Blocked: ~/.webpieces/config.json is present but unusable.\\n\\n${detail}`;\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 //\n // `pnpm wp-build` FIRST, with the command it resolves to shown beside it. One thing to type, and it\n // runs `commands.pr-gate.buildCommand` through the gate's own resolver — so this refusal can never\n // teach a command the gate does not run, however the project reconfigures it.\n //\n // The log note is attached to the `wp-build` line ONLY, and that placement is the whole point. A\n // `FullLog :` pointer is written by `GateLogFile.pointer`, which is reachable solely through\n // `BuildGateLog` / `StageOutputLog` — i.e. through the gate. A bare `nx run <project>:ci` or\n // `vitest run <path>` writes no such file, so a blanket \"whichever you run\" claim would send an\n // agent to grep a path that does not exist while this same message forbids the re-run that is its\n // only remaining option. A cure that names an artifact the command does not produce is worse than\n // no cure at all.\n private message(): string {\n return 'Blocked: that builds the WHOLE monorepo. Build only what your change affects:\\n\\n'\n + ` pnpm wp-build # runs: ${this.resolvedCommand}\\n`\n + ' (its full output goes to a FILE — it prints \"FullLog : <path>\"; grep THAT instead of\\n'\n + ' rebuilding. Never re-run a build to read a different slice of it.)\\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`, handed to\n * the constructor by the runner, falling back to the same DEFAULT_BUILD_COMMAND the gate itself\n * falls back to. This guard never spells a build command of its own — a second copy is how a refusal\n * starts teaching a command the gate does not run.\n */\n private buildCommandTemplate(): string {\n return this.affectedBuildCommand.trim() === '' ? DEFAULT_BUILD_COMMAND : this.affectedBuildCommand;\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 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_UNROWED),\n );\n }\n}\n"]}
@@ -12,14 +12,6 @@ export declare class WholeRepoBuildScan {
12
12
  /** The first segment of `command` that builds the whole repo, or null when none does. */
13
13
  firstHit(command: string): WholeRepoBuildHit | null;
14
14
  private shapeOf;
15
- private viaRunner;
16
- /**
17
- * The segment's words with wrappers stripped: shell prefixes (`time`, `sudo`, env assignments) by
18
- * CommandScanner, then the package-manager runner and its verb. `pnpm exec vitest run` and
19
- * `vitest run` reduce to the same words, which is the whole point.
20
- */
21
- private effectiveWords;
22
- private programName;
23
15
  private isNarrowed;
24
16
  private nxShape;
25
17
  private vitestShape;
@@ -32,10 +32,9 @@ exports.WholeRepoBuildScan = exports.WholeRepoBuildHit = void 0;
32
32
  * in it narrows the scope. A flag this file has never heard of therefore fails toward ALLOW, which is
33
33
  * the right direction for a guard whose false positive costs an agent its next legitimate command.
34
34
  */
35
- // Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).
36
- const RUNNERS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);
37
- // The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.
38
- const RUNNER_VERBS = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);
35
+ // The package-manager wrapper (`pnpm exec vitest`, `npx nx`) is stripped by CommandScanner, which owns
36
+ // that list see `runnerStrippedWords`. It used to be duplicated here, and a second guard needing the
37
+ // same normalization is exactly when a duplicate starts to drift.
39
38
  // Any of these ANYWHERE in the segment means the human/agent already scoped the work, so nothing here
40
39
  // applies. `--filter` is pnpm's project selector; the nx selectors are checked again per-subcommand.
41
40
  const NARROWING_FLAGS = ['--filter', '-p', '--projects', '--project', '--dir', '--projectPath'];
@@ -107,11 +106,10 @@ class WholeRepoBuildScan {
107
106
  // exactly how a scoped command ends up blocked.
108
107
  if (this.isNarrowed(this.scanner.words(segment)))
109
108
  return null;
110
- const raw = this.scanner.words(segment);
111
- const words = this.effectiveWords(segment);
109
+ const words = this.scanner.runnerStrippedWords(segment);
112
110
  if (words.length === 0)
113
111
  return null;
114
- const program = this.programName(words[0]);
112
+ const program = this.scanner.programName(words[0]);
115
113
  const args = words.slice(1);
116
114
  if (WHOLE_REPO_SCRIPTS.has(program))
117
115
  return `${program} builds every project`;
@@ -122,38 +120,11 @@ class WholeRepoBuildScan {
122
120
  // `test` ONLY as a package-manager script — `pnpm test`, `npm test`, `yarn test`. A NAKED `test`
123
121
  // is POSIX test(1) (the `[` builtin), never a build, and treating the bare word as the script
124
122
  // is how a fragment of somebody's data ends up classified as a workspace-wide test run.
125
- if (program === 'test' && args.length === 0 && this.atWorkspaceRoot && this.viaRunner(raw)) {
123
+ if (program === 'test' && args.length === 0 && this.atWorkspaceRoot && this.scanner.viaRunner(segment)) {
126
124
  return 'the root `test` script runs every spec in the repo';
127
125
  }
128
126
  return null;
129
127
  }
130
- // Did a package-manager runner precede the program (`pnpm …`, `npx …`)? Read off the RAW words,
131
- // before effectiveWords strips it, which is the only place that fact still exists.
132
- viaRunner(raw) {
133
- return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));
134
- }
135
- /**
136
- * The segment's words with wrappers stripped: shell prefixes (`time`, `sudo`, env assignments) by
137
- * CommandScanner, then the package-manager runner and its verb. `pnpm exec vitest run` and
138
- * `vitest run` reduce to the same words, which is the whole point.
139
- */
140
- effectiveWords(segment) {
141
- let words = this.scanner.words(segment);
142
- while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {
143
- words = words.slice(1);
144
- // The runner's own flags (`--silent`, `-r`) sit between it and the program.
145
- while (words.length > 0 && words[0].startsWith('-'))
146
- words = words.slice(1);
147
- if (words.length > 0 && RUNNER_VERBS.has(words[0]))
148
- words = words.slice(1);
149
- }
150
- return words;
151
- }
152
- // `./node_modules/.bin/nx` and `/usr/local/bin/pnpm` are the same programs as `nx` and `pnpm`.
153
- programName(token) {
154
- const base = token.split('/').pop() ?? token;
155
- return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;
156
- }
157
128
  // Did the caller already scope this command? Judged over the WHOLE segment, so a selector attached
158
129
  // to the runner (`pnpm --filter core-util test`) counts just as much as one attached to nx.
159
130
  isNarrowed(words) {
@@ -1 +1 @@
1
- {"version":3,"file":"whole-repo-build-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/whole-repo-build-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * Decides the one question whole-repo-build-guard asks of a command: would this segment build (or\n * test) the ENTIRE monorepo rather than the part the change touches?\n *\n * ─── Why this is a SHAPE test and not a string match ───────────────────────────────────────────────\n * The command that motivated the guard is `pnpm run build-all`, but matching that literal buys about a\n * day: `npm run build-all`, `pnpm nx run-many -t ci`, `pnpm exec vitest run` and `time pnpm build-all`\n * are the same build with a different spelling, and an agent that is blocked reaches for the next\n * spelling rather than the smaller build. So the classifier normalizes away the package-manager\n * wrapper first, then judges the PROGRAM and its narrowing arguments:\n *\n * BLOCKED a whole-repo script name — `build-all`, `webpieces:ci`, `wp-ci`, … — under any runner\n * BLOCKED `nx run-many` of a BUILD target with no `-p`/`--projects`, or with `--all`\n * BLOCKED `nx affected` of a BUILD target with no `--base` (a far wider base than the fork point)\n * BLOCKED `nx <target>` with no project argument (`nx test`)\n * BLOCKED `vitest`/`vitest run` with no path, `--project` or `--dir` to narrow it\n * BLOCKED a bare `pnpm test` AT THE WORKSPACE ROOT — the root `test` script is the whole suite.\n * Only under a RUNNER: a naked `test` word is POSIX test(1), and classifying it as the\n * script blocked a polling loop whose jq filter merely contained `test(`.\n *\n * ALLOWED `nx affected --target=ci --base=<anything>` — the gate's own command\n * ALLOWED `nx run <project>:<target>`, `nx run-many -t test -p a b`, `nx test core-util`\n * ALLOWED `vitest run packages/core/core-util`, `vitest run --project core-util`\n * ALLOWED anything carrying `--filter` (pnpm's own narrowing), and every non-build command\n * ALLOWED a workspace-wide REGENERATION — `nx run-many --target=di-graph-generate` — which is\n * supposed to cover every project and which this repo's own docs prescribe\n *\n * Narrowing is judged POSITIVELY: a command is blocked only when the shape is whole-repo AND nothing\n * in it narrows the scope. A flag this file has never heard of therefore fails toward ALLOW, which is\n * the right direction for a guard whose false positive costs an agent its next legitimate command.\n */\n\n// Package-manager wrappers that precede the real program (`pnpm exec vitest`, `npx nx`, `yarn build-all`).\nconst RUNNERS: ReadonlySet<string> = new Set(['pnpm', 'npm', 'yarn', 'npx', 'pnpx', 'bun', 'bunx']);\n\n// The runner's own verb, consumed with it: `pnpm run build-all` and `pnpm build-all` are one command.\nconst RUNNER_VERBS: ReadonlySet<string> = new Set(['run', 'run-script', 'exec', 'dlx', 'x']);\n\n// Any of these ANYWHERE in the segment means the human/agent already scoped the work, so nothing here\n// applies. `--filter` is pnpm's project selector; the nx selectors are checked again per-subcommand.\nconst NARROWING_FLAGS: readonly string[] = ['--filter', '-p', '--projects', '--project', '--dir', '--projectPath'];\n\n// Script/bin names that ARE \"build the world\" in every repo that defines them. `wp-ci` is webpieces'\n// own whole-repo validator and is listed as a PROGRAM as well as a script: blocking `build-all` while\n// leaving the thing it delegates to open would be a guard with a labelled side door.\nconst WHOLE_REPO_SCRIPTS: ReadonlySet<string> = new Set([\n 'build-all', 'build:all', 'buildall',\n 'test-all', 'test:all', 'lint-all', 'ci-all', 'ci:all',\n 'webpieces:ci', 'wp-ci',\n]);\n\n// nx verbs that are NOT \"run this target across the workspace\". Anything else in the first position is\n// read as a TARGET name (`nx test`, `nx build`), which is whole-repo unless a project follows.\nconst NX_COMMANDS: ReadonlySet<string> = new Set([\n 'run', 'run-many', 'affected', 'affected:graph', 'print-affected',\n 'show', 'graph', 'dep-graph', 'reset', 'daemon', 'list', 'report', 'migrate',\n 'generate', 'g', 'new', 'format', 'repair', 'connect', 'add', 'init', 'release',\n 'sync', 'watch', 'exec', 'login', 'logout', 'record', 'view-logs', 'import',\n]);\n\n/**\n * The targets this guard is about. A workspace-wide `nx run-many -t di-graph-generate` or\n * `-t architecture:generate` is a REGENERATION and is supposed to cover every project — the repo's own\n * docs prescribe exactly that, and blocking it would make the guard wrong about a command an agent has\n * to run. Only the compile/test/lint family is a \"build of the world\".\n */\nconst BUILD_TARGETS: ReadonlySet<string> = new Set([\n 'ci', 'build', 'test', 'lint', 'e2e', 'typecheck', 'check', 'compile', 'verify',\n]);\n\n// vitest's leading verb, dropped before looking for a path filter.\nconst VITEST_VERBS: ReadonlySet<string> = new Set(['run', 'watch', 'dev', 'related', 'bench', 'list', 'typecheck', 'init']);\n\n// vitest flags whose VALUE is the next token, so the value is never mistaken for a path filter.\nconst VITEST_VALUE_FLAGS: ReadonlySet<string> = new Set([\n '--reporter', '--config', '-c', '--mode', '-m', '--shard', '--pool', '--environment',\n '--outputFile', '--maxWorkers', '--minWorkers', '--testNamePattern', '-t', '--retry',\n '--testTimeout', '--hookTimeout', '--exclude', '--include', '--coverage.reporter',\n]);\n\n/** One whole-repo build found in a command: the segment, and the SHAPE that made it one. Data-only. */\nexport class WholeRepoBuildHit {\n segment: string;\n shape: string;\n\n constructor(segment: string, shape: string) {\n this.segment = segment;\n this.shape = shape;\n }\n}\n\nexport class WholeRepoBuildScan {\n constructor(\n private readonly scanner: CommandScanner,\n // True when the command runs at the workspace ROOT, where a bare `pnpm test` is the whole suite.\n private readonly atWorkspaceRoot: boolean,\n ) {}\n\n /** The first segment of `command` that builds the whole repo, or null when none does. */\n firstHit(command: string): WholeRepoBuildHit | null {\n for (const segment of this.scanner.commandSegments(command)) {\n const shape = this.shapeOf(segment);\n if (shape !== null) return new WholeRepoBuildHit(segment, shape);\n }\n return null;\n }\n\n // The whole-repo shape this segment is, or null. One dispatch per program family.\n private shapeOf(segment: string): string | null {\n // Narrowing is judged on the RAW words, BEFORE the runner is stripped: `pnpm --filter core-util\n // test` carries its selector on the RUNNER, and stripping first throws that fact away — which is\n // exactly how a scoped command ends up blocked.\n if (this.isNarrowed(this.scanner.words(segment))) return null;\n\n const raw = this.scanner.words(segment);\n const words = this.effectiveWords(segment);\n if (words.length === 0) return null;\n\n const program = this.programName(words[0]);\n const args = words.slice(1);\n\n if (WHOLE_REPO_SCRIPTS.has(program)) return `${program} builds every project`;\n if (program === 'nx') return this.nxShape(args);\n if (program === 'vitest') return this.vitestShape(args);\n // `test` ONLY as a package-manager script — `pnpm test`, `npm test`, `yarn test`. A NAKED `test`\n // is POSIX test(1) (the `[` builtin), never a build, and treating the bare word as the script\n // is how a fragment of somebody's data ends up classified as a workspace-wide test run.\n if (program === 'test' && args.length === 0 && this.atWorkspaceRoot && this.viaRunner(raw)) {\n return 'the root `test` script runs every spec in the repo';\n }\n return null;\n }\n\n // Did a package-manager runner precede the program (`pnpm …`, `npx …`)? Read off the RAW words,\n // before effectiveWords strips it, which is the only place that fact still exists.\n private viaRunner(raw: readonly string[]): boolean {\n return raw.length > 0 && RUNNERS.has(this.programName(raw[0]));\n }\n\n /**\n * The segment's words with wrappers stripped: shell prefixes (`time`, `sudo`, env assignments) by\n * CommandScanner, then the package-manager runner and its verb. `pnpm exec vitest run` and\n * `vitest run` reduce to the same words, which is the whole point.\n */\n private effectiveWords(segment: string): readonly string[] {\n let words = this.scanner.words(segment);\n while (words.length > 0 && RUNNERS.has(this.programName(words[0]))) {\n words = words.slice(1);\n // The runner's own flags (`--silent`, `-r`) sit between it and the program.\n while (words.length > 0 && words[0].startsWith('-')) words = words.slice(1);\n if (words.length > 0 && RUNNER_VERBS.has(words[0])) words = words.slice(1);\n }\n return words;\n }\n\n // `./node_modules/.bin/nx` and `/usr/local/bin/pnpm` are the same programs as `nx` and `pnpm`.\n private programName(token: string): string {\n const base = token.split('/').pop() ?? token;\n return base.endsWith('.cmd') ? base.slice(0, -'.cmd'.length) : base;\n }\n\n // Did the caller already scope this command? Judged over the WHOLE segment, so a selector attached\n // to the runner (`pnpm --filter core-util test`) counts just as much as one attached to nx.\n private isNarrowed(words: readonly string[]): boolean {\n return words.some((w: string): boolean =>\n NARROWING_FLAGS.some((flag: string): boolean => w === flag || w.startsWith(`${flag}=`)));\n }\n\n private nxShape(args: readonly string[]): string | null {\n const positional = args.filter((a: string): boolean => !a.startsWith('-'));\n const subcommand = positional[0] ?? '';\n\n if (subcommand === 'run-many') {\n if (!this.buildsAnything(args, positional)) return null;\n if (this.hasFlag(args, '--all')) return 'nx run-many --all builds every project';\n return 'nx run-many with no --projects builds every project';\n }\n if (subcommand === 'affected') {\n if (this.hasFlag(args, '--base')) return null;\n if (!this.buildsAnything(args, positional)) return null;\n return 'nx affected with no --base compares against a far wider base than the fork point';\n }\n if (subcommand === '' || NX_COMMANDS.has(subcommand)) return null;\n // `nx <target>` — whole-workspace unless a project name follows it.\n if (positional.length > 1) return null;\n if (!BUILD_TARGETS.has(subcommand)) return null;\n return `nx ${subcommand} with no project runs that target everywhere`;\n }\n\n private vitestShape(args: readonly string[]): string | null {\n let rest = args;\n if (rest.length > 0 && VITEST_VERBS.has(rest[0])) rest = rest.slice(1);\n for (let i = 0; i < rest.length; i++) {\n const word = rest[i];\n if (!word.startsWith('-')) return null; // a path filter — narrowed\n if (VITEST_VALUE_FLAGS.has(word)) i++; // skip the flag's value\n }\n return 'vitest with no path filter runs every spec in the repo';\n }\n\n /**\n * Does this nx invocation run a COMPILE/TEST target? Targets come from `-t`/`--target`/`--targets`\n * in either spelling, comma-separated, plus (for `run-many <target>`) a second positional.\n *\n * An invocation naming NO target is not judged a build: nx errors on it, and guessing would block a\n * typo with a message about build scope. An invocation naming only regeneration targets\n * (`di-graph-generate`, `generate`) is deliberately allowed workspace-wide — see BUILD_TARGETS.\n */\n private buildsAnything(args: readonly string[], positional: readonly string[]): boolean {\n const targets: string[] = positional.slice(1);\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n const flag = ['-t', '--target', '--targets'].find(\n (f: string): boolean => word === f || word.startsWith(`${f}=`));\n if (flag === undefined) continue;\n const value = word === flag ? (args[i + 1] ?? '') : word.slice(flag.length + 1);\n targets.push(...value.split(','));\n }\n return targets.some((t: string): boolean => BUILD_TARGETS.has(t.trim()));\n }\n\n // `--base` / `--base=<x>` / `--base <x>` — the attached and detached spellings of one flag.\n private hasFlag(args: readonly string[], flag: string): boolean {\n return args.some((a: string): boolean => a === flag || a.startsWith(`${flag}=`));\n }\n}\n"]}
1
+ {"version":3,"file":"whole-repo-build-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/whole-repo-build-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import { CommandScanner } from '../command-scan';\n\n/**\n * Decides the one question whole-repo-build-guard asks of a command: would this segment build (or\n * test) the ENTIRE monorepo rather than the part the change touches?\n *\n * ─── Why this is a SHAPE test and not a string match ───────────────────────────────────────────────\n * The command that motivated the guard is `pnpm run build-all`, but matching that literal buys about a\n * day: `npm run build-all`, `pnpm nx run-many -t ci`, `pnpm exec vitest run` and `time pnpm build-all`\n * are the same build with a different spelling, and an agent that is blocked reaches for the next\n * spelling rather than the smaller build. So the classifier normalizes away the package-manager\n * wrapper first, then judges the PROGRAM and its narrowing arguments:\n *\n * BLOCKED a whole-repo script name — `build-all`, `webpieces:ci`, `wp-ci`, … — under any runner\n * BLOCKED `nx run-many` of a BUILD target with no `-p`/`--projects`, or with `--all`\n * BLOCKED `nx affected` of a BUILD target with no `--base` (a far wider base than the fork point)\n * BLOCKED `nx <target>` with no project argument (`nx test`)\n * BLOCKED `vitest`/`vitest run` with no path, `--project` or `--dir` to narrow it\n * BLOCKED a bare `pnpm test` AT THE WORKSPACE ROOT — the root `test` script is the whole suite.\n * Only under a RUNNER: a naked `test` word is POSIX test(1), and classifying it as the\n * script blocked a polling loop whose jq filter merely contained `test(`.\n *\n * ALLOWED `nx affected --target=ci --base=<anything>` — the gate's own command\n * ALLOWED `nx run <project>:<target>`, `nx run-many -t test -p a b`, `nx test core-util`\n * ALLOWED `vitest run packages/core/core-util`, `vitest run --project core-util`\n * ALLOWED anything carrying `--filter` (pnpm's own narrowing), and every non-build command\n * ALLOWED a workspace-wide REGENERATION — `nx run-many --target=di-graph-generate` — which is\n * supposed to cover every project and which this repo's own docs prescribe\n *\n * Narrowing is judged POSITIVELY: a command is blocked only when the shape is whole-repo AND nothing\n * in it narrows the scope. A flag this file has never heard of therefore fails toward ALLOW, which is\n * the right direction for a guard whose false positive costs an agent its next legitimate command.\n */\n\n// The package-manager wrapper (`pnpm exec vitest`, `npx nx`) is stripped by CommandScanner, which owns\n// that list — see `runnerStrippedWords`. It used to be duplicated here, and a second guard needing the\n// same normalization is exactly when a duplicate starts to drift.\n\n// Any of these ANYWHERE in the segment means the human/agent already scoped the work, so nothing here\n// applies. `--filter` is pnpm's project selector; the nx selectors are checked again per-subcommand.\nconst NARROWING_FLAGS: readonly string[] = ['--filter', '-p', '--projects', '--project', '--dir', '--projectPath'];\n\n// Script/bin names that ARE \"build the world\" in every repo that defines them. `wp-ci` is webpieces'\n// own whole-repo validator and is listed as a PROGRAM as well as a script: blocking `build-all` while\n// leaving the thing it delegates to open would be a guard with a labelled side door.\nconst WHOLE_REPO_SCRIPTS: ReadonlySet<string> = new Set([\n 'build-all', 'build:all', 'buildall',\n 'test-all', 'test:all', 'lint-all', 'ci-all', 'ci:all',\n 'webpieces:ci', 'wp-ci',\n]);\n\n// nx verbs that are NOT \"run this target across the workspace\". Anything else in the first position is\n// read as a TARGET name (`nx test`, `nx build`), which is whole-repo unless a project follows.\nconst NX_COMMANDS: ReadonlySet<string> = new Set([\n 'run', 'run-many', 'affected', 'affected:graph', 'print-affected',\n 'show', 'graph', 'dep-graph', 'reset', 'daemon', 'list', 'report', 'migrate',\n 'generate', 'g', 'new', 'format', 'repair', 'connect', 'add', 'init', 'release',\n 'sync', 'watch', 'exec', 'login', 'logout', 'record', 'view-logs', 'import',\n]);\n\n/**\n * The targets this guard is about. A workspace-wide `nx run-many -t di-graph-generate` or\n * `-t architecture:generate` is a REGENERATION and is supposed to cover every project — the repo's own\n * docs prescribe exactly that, and blocking it would make the guard wrong about a command an agent has\n * to run. Only the compile/test/lint family is a \"build of the world\".\n */\nconst BUILD_TARGETS: ReadonlySet<string> = new Set([\n 'ci', 'build', 'test', 'lint', 'e2e', 'typecheck', 'check', 'compile', 'verify',\n]);\n\n// vitest's leading verb, dropped before looking for a path filter.\nconst VITEST_VERBS: ReadonlySet<string> = new Set(['run', 'watch', 'dev', 'related', 'bench', 'list', 'typecheck', 'init']);\n\n// vitest flags whose VALUE is the next token, so the value is never mistaken for a path filter.\nconst VITEST_VALUE_FLAGS: ReadonlySet<string> = new Set([\n '--reporter', '--config', '-c', '--mode', '-m', '--shard', '--pool', '--environment',\n '--outputFile', '--maxWorkers', '--minWorkers', '--testNamePattern', '-t', '--retry',\n '--testTimeout', '--hookTimeout', '--exclude', '--include', '--coverage.reporter',\n]);\n\n/** One whole-repo build found in a command: the segment, and the SHAPE that made it one. Data-only. */\nexport class WholeRepoBuildHit {\n segment: string;\n shape: string;\n\n constructor(segment: string, shape: string) {\n this.segment = segment;\n this.shape = shape;\n }\n}\n\nexport class WholeRepoBuildScan {\n constructor(\n private readonly scanner: CommandScanner,\n // True when the command runs at the workspace ROOT, where a bare `pnpm test` is the whole suite.\n private readonly atWorkspaceRoot: boolean,\n ) {}\n\n /** The first segment of `command` that builds the whole repo, or null when none does. */\n firstHit(command: string): WholeRepoBuildHit | null {\n for (const segment of this.scanner.commandSegments(command)) {\n const shape = this.shapeOf(segment);\n if (shape !== null) return new WholeRepoBuildHit(segment, shape);\n }\n return null;\n }\n\n // The whole-repo shape this segment is, or null. One dispatch per program family.\n private shapeOf(segment: string): string | null {\n // Narrowing is judged on the RAW words, BEFORE the runner is stripped: `pnpm --filter core-util\n // test` carries its selector on the RUNNER, and stripping first throws that fact away — which is\n // exactly how a scoped command ends up blocked.\n if (this.isNarrowed(this.scanner.words(segment))) return null;\n\n const words = this.scanner.runnerStrippedWords(segment);\n if (words.length === 0) return null;\n\n const program = this.scanner.programName(words[0]);\n const args = words.slice(1);\n\n if (WHOLE_REPO_SCRIPTS.has(program)) return `${program} builds every project`;\n if (program === 'nx') return this.nxShape(args);\n if (program === 'vitest') return this.vitestShape(args);\n // `test` ONLY as a package-manager script — `pnpm test`, `npm test`, `yarn test`. A NAKED `test`\n // is POSIX test(1) (the `[` builtin), never a build, and treating the bare word as the script\n // is how a fragment of somebody's data ends up classified as a workspace-wide test run.\n if (program === 'test' && args.length === 0 && this.atWorkspaceRoot && this.scanner.viaRunner(segment)) {\n return 'the root `test` script runs every spec in the repo';\n }\n return null;\n }\n\n // Did the caller already scope this command? Judged over the WHOLE segment, so a selector attached\n // to the runner (`pnpm --filter core-util test`) counts just as much as one attached to nx.\n private isNarrowed(words: readonly string[]): boolean {\n return words.some((w: string): boolean =>\n NARROWING_FLAGS.some((flag: string): boolean => w === flag || w.startsWith(`${flag}=`)));\n }\n\n private nxShape(args: readonly string[]): string | null {\n const positional = args.filter((a: string): boolean => !a.startsWith('-'));\n const subcommand = positional[0] ?? '';\n\n if (subcommand === 'run-many') {\n if (!this.buildsAnything(args, positional)) return null;\n if (this.hasFlag(args, '--all')) return 'nx run-many --all builds every project';\n return 'nx run-many with no --projects builds every project';\n }\n if (subcommand === 'affected') {\n if (this.hasFlag(args, '--base')) return null;\n if (!this.buildsAnything(args, positional)) return null;\n return 'nx affected with no --base compares against a far wider base than the fork point';\n }\n if (subcommand === '' || NX_COMMANDS.has(subcommand)) return null;\n // `nx <target>` — whole-workspace unless a project name follows it.\n if (positional.length > 1) return null;\n if (!BUILD_TARGETS.has(subcommand)) return null;\n return `nx ${subcommand} with no project runs that target everywhere`;\n }\n\n private vitestShape(args: readonly string[]): string | null {\n let rest = args;\n if (rest.length > 0 && VITEST_VERBS.has(rest[0])) rest = rest.slice(1);\n for (let i = 0; i < rest.length; i++) {\n const word = rest[i];\n if (!word.startsWith('-')) return null; // a path filter — narrowed\n if (VITEST_VALUE_FLAGS.has(word)) i++; // skip the flag's value\n }\n return 'vitest with no path filter runs every spec in the repo';\n }\n\n /**\n * Does this nx invocation run a COMPILE/TEST target? Targets come from `-t`/`--target`/`--targets`\n * in either spelling, comma-separated, plus (for `run-many <target>`) a second positional.\n *\n * An invocation naming NO target is not judged a build: nx errors on it, and guessing would block a\n * typo with a message about build scope. An invocation naming only regeneration targets\n * (`di-graph-generate`, `generate`) is deliberately allowed workspace-wide — see BUILD_TARGETS.\n */\n private buildsAnything(args: readonly string[], positional: readonly string[]): boolean {\n const targets: string[] = positional.slice(1);\n for (let i = 0; i < args.length; i++) {\n const word = args[i];\n const flag = ['-t', '--target', '--targets'].find(\n (f: string): boolean => word === f || word.startsWith(`${f}=`));\n if (flag === undefined) continue;\n const value = word === flag ? (args[i + 1] ?? '') : word.slice(flag.length + 1);\n targets.push(...value.split(','));\n }\n return targets.some((t: string): boolean => BUILD_TARGETS.has(t.trim()));\n }\n\n // `--base` / `--base=<x>` / `--base <x>` — the attached and detached spellings of one flag.\n private hasFlag(args: readonly string[], flag: string): boolean {\n return args.some((a: string): boolean => a === flag || a.startsWith(`${flag}=`));\n }\n}\n"]}
@@ -377,8 +377,7 @@ function misplacedCdBlock(command, tree) {
377
377
  * consumer's next Bash call a fault-Y block ("this rule has no entry") for a feature nobody opted into
378
378
  * — which is exactly what whole-repo-build-guard did on its first release. Each one decides for itself
379
379
  * whether it acts: whole-repo-build-guard reads ~/.webpieces/config.json for a machine-local OPT-IN
380
- * (absent means OFF), commit-message-substitution-guard acts unconditionally (see loadKeylessBashRules).
381
- *
380
+ * (absent means OFF); commit-message-substitution-guard and build-output-pipe-guard act unconditionally.
382
381
  * They still honour excludePaths, and they do not run in `rules` mode (code-style-only hook).
383
382
  */
384
383
  // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape