@webpieces/ai-hook-rules 0.4.662 → 0.4.663
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.663",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/tooling/ai-hook-rules"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@webpieces/rules-config": "0.4.
|
|
28
|
+
"@webpieces/rules-config": "0.4.663"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
31
|
"access": "public"
|
|
@@ -39,14 +39,17 @@ import { FixHint } from '../fix-hint';
|
|
|
39
39
|
* they had never asked for. An experimental feature is opted into from a machine-local file whose absent
|
|
40
40
|
* state is byte-for-byte the old behaviour, or it is not experimental.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
42
|
+
* Four states, and only four:
|
|
43
43
|
* - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and
|
|
44
44
|
* logs nothing, for every command, including the ones it would otherwise refuse.
|
|
45
45
|
* - the file exists and defines the key → the key's value decides.
|
|
46
|
-
* - the file exists
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.
|
|
47
|
+
* Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin
|
|
48
|
+
* different webpieces releases — a required key there is unsatisfiable (see home-config.ts). The
|
|
49
|
+
* default can only fail towards "never opted in", which is the safe direction.
|
|
50
|
+
* - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE
|
|
51
|
+
* naming the edit. That is a file somebody wrote wrongly, not one written for another release.
|
|
52
|
+
* Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
|
|
50
53
|
*
|
|
51
54
|
* ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
|
|
52
55
|
* `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
|
|
@@ -49,14 +49,17 @@ const whole_repo_build_scan_1 = require("./whole-repo-build-scan");
|
|
|
49
49
|
* they had never asked for. An experimental feature is opted into from a machine-local file whose absent
|
|
50
50
|
* state is byte-for-byte the old behaviour, or it is not experimental.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
52
|
+
* Four states, and only four:
|
|
53
53
|
* - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and
|
|
54
54
|
* logs nothing, for every command, including the ones it would otherwise refuse.
|
|
55
55
|
* - the file exists and defines the key → the key's value decides.
|
|
56
|
-
* - the file exists
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
56
|
+
* - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.
|
|
57
|
+
* Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin
|
|
58
|
+
* different webpieces releases — a required key there is unsatisfiable (see home-config.ts). The
|
|
59
|
+
* default can only fail towards "never opted in", which is the safe direction.
|
|
60
|
+
* - the file exists and is unparseable, or a key it DOES define has the wrong type → HARD FAILURE
|
|
61
|
+
* naming the edit. That is a file somebody wrote wrongly, not one written for another release.
|
|
62
|
+
* Editing that file is an unconditional PASS in the guards, so the block is always self-curable.
|
|
60
63
|
*
|
|
61
64
|
* ─── Two messages, chosen by a DIFFERENT key ───────────────────────────────────────────────────────
|
|
62
65
|
* `experimental.buildGateLogCapture` is a separate feature (the pr-gate captures its build's full output
|
|
@@ -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":";;;AAAA,iDAAyC;AAEzC,0DAA8G;AAG9G,oCAA0C;AAC1C,4CAA6D;AAC7D,0CAAsC;AACtC,0CAAsC;AACtC,sDAAkD;AAClD,kDAA8F;AAC9F,kDAAiD;AACjD,mEAAgF;AAEhF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AACH,MAAa,uBAAwB,SAAQ,wBAA6B;IAMzC;IAL7B;;;;OAIG;IACH,YAA6B,oBAA4B;QACrD,2EAA2E;QAC3E,6FAA6F;QAC7F,sFAAsF;QACtF,+EAA+E;QAC/E,KAAK,CAAC,IAAI,2BAAe,EAAE,EAAE,wBAAwB,EAAE,wBAAwB,CAAC,CAAC;QALxD,yBAAoB,GAApB,oBAAoB,CAAQ;IAMzD,CAAC;IAEgB,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,UAAU,GAAG,IAAI,gCAAiB,EAAE,CAAC;IAEtD,gGAAgG;IACxF,eAAe,GAAG,EAAE,CAAC;IAEpB,WAAW,GAChB,0FAA0F;QAC1F,iFAAiF,CAAC;IAEtF;;;;;OAKG;IACK,eAAe,GAAG,EAAE,CAAC;IAE7B,IAAI,OAAO;QACP,IAAI,IAAI,CAAC,eAAe,KAAK,EAAE,EAAE,CAAC;YAC9B,OAAO,IAAI,kBAAO,CACd,qDAAqD,EACrD,0FAA0F;gBAC1F,uFAAuF;gBACvF,kBAAkB,CACrB,CAAC;QACN,CAAC;QACD,MAAM,OAAO,GAAG,IAAI,CAAC,eAAe,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,EAAE,CAAC;QACjG,OAAO,IAAI,kBAAO,CACd,8EAA8E,EAC9E,wFAAwF;YACxF,KAAK,OAAO,6BAA6B;YACzC,8CAA8C;YAC9C,+CAA+C;YAC/C,kEAAkE;YAClE,+EAA+E;YAC/E,sCAAsC,CACzC,CAAC;IACN,CAAC;IAED,KAAK,CAAC,GAAgB;QAClB,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC;QAC7B,8FAA8F;QAC9F,gGAAgG;QAChG,IAAI,IAAI,YAAY,KAAK;YAAE,OAAO,IAAI,CAAC,uBAAuB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAE1E,4FAA4F;QAC5F,gGAAgG;QAChG,+BAA+B;QAC/B,IAAI,CAAC,eAAe,GAAG,EAAE,CAAC;QAC1B,IAAI,CAAC,IAAI,CAAC,mBAAmB;YAAE,OAAO,EAAE,CAAC;QAEzC,+FAA+F;QAC/F,uFAAuF;QACvF,MAAM,GAAG,GAAG,IAAI,0CAAkB,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,YAAY,KAAK,GAAG,CAAC,aAAa,CAAC;aACnF,QAAQ,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;QAC/B,IAAI,GAAG,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,wBAAwB,CAAC,CAAC;QAEnE,gFAAgF;QAChF,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACpE,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;IACpD,CAAC;IAED;;;;OAIG;IACK,QAAQ;QACZ,+FAA+F;QAC/F,mFAAmF;QACnF,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QAClC,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,OAAO,KAAK,CAAC;QACjB,CAAC;IACL,CAAC;IAEO,uBAAuB,CAAC,GAAgB,EAAE,KAAY;QAC1D,IAAI,CAAC,eAAe,GAAG,KAAK,CAAC,OAAO,CAAC;QACrC,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,qBAAqB,CAAC,CAAC;QAC9D,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IACvF,CAAC;IAEO,uBAAuB,CAAC,KAAY;QACxC,MAAM,MAAM,GAAG,KAAK,YAAY,4BAAa;YACzC,CAAC,CAAC,KAAK,CAAC,OAAO;YACf,CAAC,CAAC,+CAA+C,KAAK,CAAC,OAAO,EAAE,CAAC;QACrE,OAAO,iEAAiE,MAAM,EAAE,CAAC;IACrF,CAAC;IAED,kGAAkG;IAClG,6FAA6F;IACrF,OAAO,CAAC,IAAgB;QAC5B,IAAI,IAAI,CAAC,mBAAmB,EAAE,CAAC;YAC3B,OAAO,oFAAoF;kBACrF,8FAA8F;kBAC9F,6EAA6E;kBAC7E,wDAAwD,CAAC;QACnE,CAAC;QACD,OAAO,mFAAmF;cACpF,OAAO,IAAI,CAAC,eAAe,MAAM;cACjC,2EAA2E,CAAC;IACtF,CAAC;IAED;;;;;OAKG;IACK,oBAAoB;QACxB,OAAO,IAAI,CAAC,oBAAoB,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,oCAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC;IACvG,CAAC;IAED;;;;OAIG;IACK,oBAAoB,CAAC,aAAqB;QAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAC7C,OAAO,QAAQ,CAAC,OAAO,CAAC,iBAAiB,EAAE,CAAC,KAAa,EAAE,KAAa,EAAU,EAAE;YAChF,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;YAC7B,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC;gBAAE,OAAO,KAAK,CAAC;YAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,aAAa,CAAC,CAAC;YACpD,OAAO,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC;QAC5C,CAAC,CAAC,CAAC;IACP,CAAC;IAEO,OAAO,CAAC,OAAe,EAAE,aAAqB;QAClD,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,OAAO,EAAE;gBACrB,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;IAEO,KAAK,CAAC,GAAgB,EAAE,MAAc;QAC1C,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC;QACvC,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,GAAsB,EAAE,OAAe;QACnE,yFAAyF;QACzF,0CAA0C;QAC1C,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC;QAClD,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,OAAgB,EAAE,MAAc;QAClE,IAAA,+BAAgB,EACZ,GAAG,CAAC,aAAa,EACjB,IAAI,4BAAa,CAAC,wBAAwB,EAAE,MAAM,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,8BAAa,EAAE,gCAAiB,CAAC,CAChI,CAAC;IACN,CAAC;CACJ;AAtLD,0DAsLC","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: the ONLY switch is ~/.webpieces/config.json ─────────────────────────────────────\n * `experimental.whole-repo-build-guard` (a boolean) decides whether this guard blocks anything at all.\n * There is NO webpieces.config.json entry — deliberately, and the reason is a live incident: this guard\n * first shipped as an ordinary validated guard with `mode: 'ON'` by default AND a required entry under\n * `hookGuards`, so every consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature\n * they had never asked for. An experimental feature is opted into from a machine-local file whose absent\n * state is byte-for-byte the old behaviour, or it is not experimental.\n *\n * Three states, and only three:\n * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and\n * logs nothing, for every command, including the ones it would otherwise refuse.\n * - the file exists and defines the key → the key's value decides.\n * - the file exists and is unparseable, or does not define the key → HARD FAILURE naming the edit. A\n * file someone deliberately created must be correct, and `HomeConfigService` requires that key\n * precisely because guessing either way is wrong (see readRequiredBoolean). Editing that file is an\n * 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 ` ${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 'Turn this guard off for this machine by setting \"experimental\": ' +\n '{ \"whole-repo-build-guard\": false } in ~/.webpieces/config.json (there is no ' +\n '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 EXPERIMENTAL GATE, and the state of essentially every consumer. Silent on purpose: no\n // block, no log, no file touched — \"no ~/.webpieces/config.json\" must be indistinguishable from\n // \"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 (all-defaults, guard off), so the only thing left to\n * 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 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`, 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: the ONLY switch is ~/.webpieces/config.json ─────────────────────────────────────\n * `experimental.whole-repo-build-guard` (a boolean) decides whether this guard blocks anything at all.\n * There is NO webpieces.config.json entry — deliberately, and the reason is a live incident: this guard\n * first shipped as an ordinary validated guard with `mode: 'ON'` by default AND a required entry under\n * `hookGuards`, so every consumer that upgraded hit fault Y — EVERY Bash call blocked — for a feature\n * they had never asked for. An experimental feature is opted into from a machine-local file whose absent\n * state is byte-for-byte the old behaviour, or it is not experimental.\n *\n * Four states, and only four:\n * - the file does not exist (essentially every consumer) → this guard is INERT. It blocks nothing and\n * logs nothing, for every command, including the ones it would otherwise refuse.\n * - the file exists and defines the key → the key's value decides.\n * - the file exists but does NOT define the key → the guard is OFF, exactly as if there were no file.\n * Every key in that file is optional, because it is MACHINE-GLOBAL and the repos on one machine pin\n * different webpieces releases — a required key there is unsatisfiable (see home-config.ts). The\n * default can only fail towards \"never opted in\", which is the safe direction.\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 ` ${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 'Turn this guard off for this machine by setting \"experimental\": ' +\n '{ \"whole-repo-build-guard\": false } in ~/.webpieces/config.json (there is no ' +\n '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 EXPERIMENTAL GATE, and the state of essentially every consumer. Silent on purpose: no\n // block, no log, no file touched — \"no ~/.webpieces/config.json\" must be indistinguishable from\n // \"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 (all-defaults, guard off), so the only thing left to\n * 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 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`, 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"]}
|