@webpieces/ai-hook-rules 0.3.375 → 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/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"
|
|
@@ -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"]}
|