@webpieces/ai-hook-rules 0.4.396 → 0.4.398

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.396",
3
+ "version": "0.4.398",
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.4.396"
34
+ "@webpieces/rules-config": "0.4.398"
35
35
  },
36
36
  "publishConfig": {
37
37
  "access": "public"
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared shell-command scanning for the bash guards.
3
+ *
4
+ * A guard that bans a command family (`git merge`, `git push`, …) must answer one question
5
+ * precisely: *does this command actually invoke `git <subcommand>`?* A bare
6
+ * `/\bgit\s+merge\b/.test(command)` gets that wrong in both directions:
7
+ *
8
+ * - False positive: `grep 'git merge main' notes.md` or `echo "git rebase main"` merely MENTION
9
+ * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated
10
+ * the merge/rebase ban.
11
+ * - False positive: `\b` sits between `e` and `-`, so `/\bgit\s+merge\b/` matches the read-only
12
+ * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.
13
+ *
14
+ * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a
15
+ * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply
16
+ * not the token `merge`). No lookahead regex needed.
17
+ */
18
+ export declare class CommandScanner {
19
+ /**
20
+ * Split a raw command into individually-invoked segments.
21
+ *
22
+ * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command
23
+ * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned
24
+ * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.
25
+ *
26
+ * Quoted spans are opaque: a separator inside quotes is literal text, so
27
+ * `git commit -m "fix; ship it"` stays one segment. (Corollary: a `$(…)` nested inside double
28
+ * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)
29
+ */
30
+ commandSegments(command: string): readonly string[];
31
+ /**
32
+ * The git subcommand a segment invokes, or null when the segment does not invoke git at all
33
+ * (a different program, a mere mention inside quotes, an empty segment).
34
+ *
35
+ * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.
36
+ */
37
+ gitSubcommand(segment: string): string | null;
38
+ /** True when this segment actually invokes `git <subcommand>`. */
39
+ invokesGit(segment: string, subcommand: string): boolean;
40
+ /** True when ANY segment of the command invokes one of `subcommands`. */
41
+ commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean;
42
+ /**
43
+ * Split one segment into shell words, dropping quote characters (so the ARGUMENT of
44
+ * `echo "git merge main"` is the single word `git merge main`, never the word `git`).
45
+ */
46
+ private tokenize;
47
+ private stripPrefixes;
48
+ }
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ /**
3
+ * Shared shell-command scanning for the bash guards.
4
+ *
5
+ * A guard that bans a command family (`git merge`, `git push`, …) must answer one question
6
+ * precisely: *does this command actually invoke `git <subcommand>`?* A bare
7
+ * `/\bgit\s+merge\b/.test(command)` gets that wrong in both directions:
8
+ *
9
+ * - False positive: `grep 'git merge main' notes.md` or `echo "git rebase main"` merely MENTION
10
+ * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated
11
+ * the merge/rebase ban.
12
+ * - False positive: `\b` sits between `e` and `-`, so `/\bgit\s+merge\b/` matches the read-only
13
+ * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.
14
+ *
15
+ * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a
16
+ * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply
17
+ * not the token `merge`). No lookahead regex needed.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.CommandScanner = void 0;
21
+ // Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).
22
+ const COMMAND_PREFIXES = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);
23
+ // git's own global flags that consume the FOLLOWING token as their value, so
24
+ // `git -C /some/path merge main` still resolves to the `merge` subcommand.
25
+ const GIT_FLAGS_WITH_VALUE = new Set([
26
+ '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',
27
+ ]);
28
+ const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
29
+ class CommandScanner {
30
+ /**
31
+ * Split a raw command into individually-invoked segments.
32
+ *
33
+ * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command
34
+ * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned
35
+ * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.
36
+ *
37
+ * Quoted spans are opaque: a separator inside quotes is literal text, so
38
+ * `git commit -m "fix; ship it"` stays one segment. (Corollary: a `$(…)` nested inside double
39
+ * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)
40
+ */
41
+ commandSegments(command) {
42
+ const segments = [];
43
+ let current = '';
44
+ let quote = null;
45
+ for (let i = 0; i < command.length; i++) {
46
+ const ch = command[i];
47
+ if (quote !== null) {
48
+ current += ch;
49
+ // A backslash-escaped quote does not close the span (only meaningful inside "…").
50
+ if (ch === quote && command[i - 1] !== '\\')
51
+ quote = null;
52
+ continue;
53
+ }
54
+ if (ch === '"' || ch === "'") {
55
+ quote = ch;
56
+ current += ch;
57
+ continue;
58
+ }
59
+ if (ch === '\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {
60
+ // Consume the second char of `&&` / `||` so it does not start an empty segment.
61
+ if ((ch === '|' || ch === '&') && command[i + 1] === ch)
62
+ i++;
63
+ segments.push(current);
64
+ current = '';
65
+ continue;
66
+ }
67
+ current += ch;
68
+ }
69
+ segments.push(current);
70
+ return segments.map((s) => s.trim()).filter((s) => s.length > 0);
71
+ }
72
+ /**
73
+ * The git subcommand a segment invokes, or null when the segment does not invoke git at all
74
+ * (a different program, a mere mention inside quotes, an empty segment).
75
+ *
76
+ * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.
77
+ */
78
+ gitSubcommand(segment) {
79
+ const tokens = this.stripPrefixes(this.tokenize(segment));
80
+ if (tokens.length === 0 || tokens[0] !== 'git')
81
+ return null;
82
+ let i = 1;
83
+ while (i < tokens.length) {
84
+ const token = tokens[i];
85
+ if (GIT_FLAGS_WITH_VALUE.has(token)) {
86
+ i += 2;
87
+ continue;
88
+ }
89
+ // `--git-dir=/x` style (value attached) and any other global flag.
90
+ if (token.startsWith('-')) {
91
+ i++;
92
+ continue;
93
+ }
94
+ return token;
95
+ }
96
+ return null;
97
+ }
98
+ /** True when this segment actually invokes `git <subcommand>`. */
99
+ invokesGit(segment, subcommand) {
100
+ return this.gitSubcommand(segment) === subcommand;
101
+ }
102
+ /** True when ANY segment of the command invokes one of `subcommands`. */
103
+ commandInvokesAnyGit(command, subcommands) {
104
+ return this.commandSegments(command).some((seg) => subcommands.some((sub) => this.invokesGit(seg, sub)));
105
+ }
106
+ /**
107
+ * Split one segment into shell words, dropping quote characters (so the ARGUMENT of
108
+ * `echo "git merge main"` is the single word `git merge main`, never the word `git`).
109
+ */
110
+ tokenize(segment) {
111
+ const tokens = [];
112
+ let current = '';
113
+ let started = false;
114
+ let quote = null;
115
+ for (let i = 0; i < segment.length; i++) {
116
+ const ch = segment[i];
117
+ if (quote !== null) {
118
+ if (ch === quote)
119
+ quote = null;
120
+ else
121
+ current += ch;
122
+ started = true;
123
+ continue;
124
+ }
125
+ if (ch === '"' || ch === "'") {
126
+ quote = ch;
127
+ started = true;
128
+ continue;
129
+ }
130
+ if (/\s/.test(ch)) {
131
+ if (started) {
132
+ tokens.push(current);
133
+ current = '';
134
+ started = false;
135
+ }
136
+ continue;
137
+ }
138
+ current += ch;
139
+ started = true;
140
+ }
141
+ if (started)
142
+ tokens.push(current);
143
+ return tokens;
144
+ }
145
+ stripPrefixes(tokens) {
146
+ let i = 0;
147
+ while (i < tokens.length) {
148
+ const token = tokens[i];
149
+ if (COMMAND_PREFIXES.has(token)) {
150
+ i++;
151
+ continue;
152
+ }
153
+ if (ENV_ASSIGNMENT.test(token)) {
154
+ i++;
155
+ continue;
156
+ }
157
+ break;
158
+ }
159
+ return tokens.slice(i);
160
+ }
161
+ }
162
+ exports.CommandScanner = CommandScanner;
163
+ //# sourceMappingURL=command-scan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command-scan.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/command-scan.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Shared shell-command scanning for the bash guards.\n *\n * A guard that bans a command family (`git merge`, `git push`, …) must answer one question\n * precisely: *does this command actually invoke `git <subcommand>`?* A bare\n * `/\\bgit\\s+merge\\b/.test(command)` gets that wrong in both directions:\n *\n * - False positive: `grep 'git merge main' notes.md` or `echo \"git rebase main\"` merely MENTION\n * the phrase. A diagnostic grep was blocked this way while triaging the incident that motivated\n * the merge/rebase ban.\n * - False positive: `\\b` sits between `e` and `-`, so `/\\bgit\\s+merge\\b/` matches the read-only\n * `git merge-base origin/main HEAD` — which appears in this repo's own documented build command.\n *\n * Both classes vanish if you tokenize instead of substring-match: a command invokes git only when a\n * segment's first word IS `git`, and the subcommand is then an exact token (`merge-base` is simply\n * not the token `merge`). No lookahead regex needed.\n */\n\n// Wrappers/prefixes that may precede the real command word (`sudo git merge`, `GIT_DIR=x git merge`).\nconst COMMAND_PREFIXES: ReadonlySet<string> = new Set(['sudo', 'command', 'nohup', 'time', 'env', 'exec']);\n\n// git's own global flags that consume the FOLLOWING token as their value, so\n// `git -C /some/path merge main` still resolves to the `merge` subcommand.\nconst GIT_FLAGS_WITH_VALUE: ReadonlySet<string> = new Set([\n '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path',\n]);\n\nconst ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;\n\nexport class CommandScanner {\n /**\n * Split a raw command into individually-invoked segments.\n *\n * Splits on `&&`, `||`, `;`, `|`, `&`, newline, and the `(`/`)` of subshells and `$(…)` command\n * substitution — the last of these matters, since it means `--base=$(git rebase main)` is scanned\n * as its own `git rebase main` segment rather than hiding inside a `pnpm …` segment.\n *\n * Quoted spans are opaque: a separator inside quotes is literal text, so\n * `git commit -m \"fix; ship it\"` stays one segment. (Corollary: a `$(…)` nested inside double\n * quotes is not split out. Bash would expand it; we do not scan it. Contrived enough to accept.)\n */\n commandSegments(command: string): readonly string[] {\n const segments: string[] = [];\n let current = '';\n let quote: string | null = null;\n\n for (let i = 0; i < command.length; i++) {\n const ch = command[i];\n\n if (quote !== null) {\n current += ch;\n // A backslash-escaped quote does not close the span (only meaningful inside \"…\").\n if (ch === quote && command[i - 1] !== '\\\\') quote = null;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n current += ch;\n continue;\n }\n\n if (ch === '\\n' || ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')') {\n // Consume the second char of `&&` / `||` so it does not start an empty segment.\n if ((ch === '|' || ch === '&') && command[i + 1] === ch) i++;\n segments.push(current);\n current = '';\n continue;\n }\n\n current += ch;\n }\n segments.push(current);\n\n return segments.map((s: string) => s.trim()).filter((s: string) => s.length > 0);\n }\n\n /**\n * The git subcommand a segment invokes, or null when the segment does not invoke git at all\n * (a different program, a mere mention inside quotes, an empty segment).\n *\n * Returns the subcommand as an EXACT token: `git merge-base …` yields `'merge-base'`, never `'merge'`.\n */\n gitSubcommand(segment: string): string | null {\n const tokens = this.stripPrefixes(this.tokenize(segment));\n if (tokens.length === 0 || tokens[0] !== 'git') return null;\n\n let i = 1;\n while (i < tokens.length) {\n const token = tokens[i];\n if (GIT_FLAGS_WITH_VALUE.has(token)) { i += 2; continue; }\n // `--git-dir=/x` style (value attached) and any other global flag.\n if (token.startsWith('-')) { i++; continue; }\n return token;\n }\n return null;\n }\n\n /** True when this segment actually invokes `git <subcommand>`. */\n invokesGit(segment: string, subcommand: string): boolean {\n return this.gitSubcommand(segment) === subcommand;\n }\n\n /** True when ANY segment of the command invokes one of `subcommands`. */\n commandInvokesAnyGit(command: string, subcommands: readonly string[]): boolean {\n return this.commandSegments(command).some((seg: string) =>\n subcommands.some((sub: string) => this.invokesGit(seg, sub)));\n }\n\n /**\n * Split one segment into shell words, dropping quote characters (so the ARGUMENT of\n * `echo \"git merge main\"` is the single word `git merge main`, never the word `git`).\n */\n private tokenize(segment: string): readonly string[] {\n const tokens: string[] = [];\n let current = '';\n let started = false;\n let quote: string | null = null;\n\n for (let i = 0; i < segment.length; i++) {\n const ch = segment[i];\n\n if (quote !== null) {\n if (ch === quote) quote = null;\n else current += ch;\n started = true;\n continue;\n }\n\n if (ch === '\"' || ch === \"'\") {\n quote = ch;\n started = true;\n continue;\n }\n\n if (/\\s/.test(ch)) {\n if (started) {\n tokens.push(current);\n current = '';\n started = false;\n }\n continue;\n }\n\n current += ch;\n started = true;\n }\n if (started) tokens.push(current);\n\n return tokens;\n }\n\n private stripPrefixes(tokens: readonly string[]): readonly string[] {\n let i = 0;\n while (i < tokens.length) {\n const token = tokens[i];\n if (COMMAND_PREFIXES.has(token)) { i++; continue; }\n if (ENV_ASSIGNMENT.test(token)) { i++; continue; }\n break;\n }\n return tokens.slice(i);\n }\n}\n"]}
@@ -8,6 +8,7 @@ const rules_config_1 = require("@webpieces/rules-config");
8
8
  const types_1 = require("../types");
9
9
  const rule_base_1 = require("../rule-base");
10
10
  const fix_hint_1 = require("../fix-hint");
11
+ const command_scan_1 = require("../command-scan");
11
12
  const DEFAULT_MERGE_COMPLETE_COMMAND = 'pnpm wp-finish-upsert-pr';
12
13
  function fixHintFor(mergeCompleteCommand) {
13
14
  return new fix_hint_1.FixHint('A merge is in progress and not yet validated — this command is blocked.', 'You started a merge but never called the finish-merge command, so a 3-point merge is still in progress.\n'
@@ -34,12 +35,15 @@ function findUnvalidatedMerge(workspaceRoot) {
34
35
  }
35
36
  return null;
36
37
  }
38
+ const BLOCKED_GIT_SUBCOMMANDS = ['commit', 'push', 'merge', 'rebase'];
39
+ const SCANNER = new command_scan_1.CommandScanner();
37
40
  // Operations that would let an agent route around the merge gate.
41
+ //
42
+ // Routed through CommandScanner rather than `/\bgit\s+merge\b/`: that pattern matches the read-only
43
+ // `git merge-base origin/main HEAD` (`\b` sits between `e` and `-`), which appears in this repo's own
44
+ // documented build command — so an in-progress merge used to block a harmless diff-scope lookup.
38
45
  function isBlockedDuringMerge(cmd) {
39
- return /\bgit\s+commit\b/.test(cmd)
40
- || /\bgit\s+push\b/.test(cmd)
41
- || /\bgit\s+merge\b/.test(cmd)
42
- || /\bgit\s+rebase\b/.test(cmd)
46
+ return SCANNER.commandInvokesAnyGit(cmd, BLOCKED_GIT_SUBCOMMANDS)
43
47
  || /\bgh\s+pr\s+(create|edit|merge)\b/.test(cmd);
44
48
  }
45
49
  function truncate(s) {
@@ -1 +1 @@
1
- {"version":3,"file":"merge-in-progress-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merge-in-progress-guard.ts"],"names":[],"mappings":";;;;AAAA,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAgI;AAGhI,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AAEtC,MAAM,8BAA8B,GAAG,0BAA0B,CAAC;AAElE,SAAS,UAAU,CAAC,oBAA4B;IAC5C,OAAO,IAAI,kBAAO,CACd,yEAAyE,EACzE,2GAA2G;UACzG,kEAAkE;UAClE,KAAK,oBAAoB,IAAI;UAC7B,gGAAgG;UAChG,2EAA2E;UAC3E,kHAAkH,CACvH,CAAC;AACN,CAAC;AAED,8FAA8F;AAC9F,sFAAsF;AACtF,SAAS,oBAAoB,CAAC,aAAqB;IAC/C,kGAAkG;IAClG,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,gCAAiB,EAAE,6BAAc,CAAC,CAAC;IACjF,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY,CAAC;QAAE,OAAO,IAAI,CAAC;IAC9C,KAAK,MAAM,KAAK,IAAI,EAAE,CAAC,WAAW,CAAC,YAAY,CAAC,EAAE,CAAC;QAC/C,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,KAAK,EAAE,qCAAsB,CAAC,CAAC;QACtE,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC;YAAE,SAAS;QACrC,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC5C,IAAI,CAAC,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC;YAAE,OAAO,MAAM,CAAC;IAC3D,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,kEAAkE;AAClE,SAAS,oBAAoB,CAAC,GAAW;IACrC,OAAO,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC;WAC5B,gBAAgB,CAAC,IAAI,CAAC,GAAG,CAAC;WAC1B,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC;WAC3B,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC;WAC5B,mCAAmC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,QAAQ,CAAC,CAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC;IAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;AACvD,CAAC;AAED,MAAa,wBAAyB,SAAQ,wBAAwC;IACjE,oBAAoB,CAAS;IAE9C,YAAY,MAAkC;QAC1C,KAAK,CAAC,MAAM,EAAE,yBAAyB,CAAC,CAAC;QACzC,IAAI,CAAC,oBAAoB,GAAG,MAAM,CAAC,oBAAoB,IAAI,8BAA8B,CAAC;IAC9F,CAAC;IAEQ,WAAW,GAAG,6GAA6G,CAAC;IACrI,IAAI,OAAO,KAAc,OAAO,UAAU,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC;IAExE,KAAK,CAAC,GAAgB;QAClB,IAAI,CAAC,oBAAoB,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAClD,MAAM,MAAM,GAAG,oBAAoB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACvD,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,CAAC;QACvB,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,2EAA2E;kBACzE,WAAW,MAAM,EAAE,CACxB,CAAC,CAAC;IACP,CAAC;CACJ;AAtBD,4DAsBC","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { WEBPIECES_TMP_DIR, MERGE_INFO_DIR, MERGE_IN_PROGRESS_FILE, MergeInProgressGuardConfig } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\n\nconst DEFAULT_MERGE_COMPLETE_COMMAND = 'pnpm wp-finish-upsert-pr';\n\nfunction fixHintFor(mergeCompleteCommand: string): FixHint {\n return new FixHint(\n 'A merge is in progress and not yet validated — this command is blocked.',\n 'You started a merge but never called the finish-merge command, so a 3-point merge is still in progress.\\n'\n + 'Resolve the remaining conflicts in the working tree, then run:\\n'\n + ` ${mergeCompleteCommand}\\n`\n + 'That scans for leftover conflict markers and runs the build; only when green does it commit,\\n'\n + 'unblock commit/push/PR, render the dashboard, and create/update the PR.\\n'\n + 'Add to memory: while a merge is in progress, do not run other commands — finish it with the command above first.',\n );\n}\n\n// Returns the path of the first UNVALIDATED merge marker found, or null. We detect validation\n// by a raw substring (no JSON.parse) so a malformed marker can never crash the guard.\nfunction findUnvalidatedMerge(workspaceRoot: string): string | null {\n // Per-feature merge dirs live under `.webpieces/merge-info/<feature>/`; scan that home's subdirs.\n const mergeInfoDir = path.join(workspaceRoot, WEBPIECES_TMP_DIR, MERGE_INFO_DIR);\n if (!fs.existsSync(mergeInfoDir)) return null;\n for (const entry of fs.readdirSync(mergeInfoDir)) {\n const marker = path.join(mergeInfoDir, entry, MERGE_IN_PROGRESS_FILE);\n if (!fs.existsSync(marker)) continue;\n const raw = fs.readFileSync(marker, 'utf8');\n if (!/\"validated\"\\s*:\\s*true/.test(raw)) return marker;\n }\n return null;\n}\n\n// Operations that would let an agent route around the merge gate.\nfunction isBlockedDuringMerge(cmd: string): boolean {\n return /\\bgit\\s+commit\\b/.test(cmd)\n || /\\bgit\\s+push\\b/.test(cmd)\n || /\\bgit\\s+merge\\b/.test(cmd)\n || /\\bgit\\s+rebase\\b/.test(cmd)\n || /\\bgh\\s+pr\\s+(create|edit|merge)\\b/.test(cmd);\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class MergeInProgressGuardRule extends BashRuleBase<MergeInProgressGuardConfig> {\n private readonly mergeCompleteCommand: string;\n\n constructor(config: MergeInProgressGuardConfig) {\n super(config, 'merge-in-progress-guard');\n this.mergeCompleteCommand = config.mergeCompleteCommand ?? DEFAULT_MERGE_COMPLETE_COMMAND;\n }\n\n readonly description = 'Block commit/push/merge/PR while a 3-point merge marker is unvalidated, forcing the merge-complete command.';\n get fixHint(): FixHint { return fixHintFor(this.mergeCompleteCommand); }\n\n check(ctx: BashContext): readonly Violation[] {\n if (!isBlockedDuringMerge(ctx.command)) return [];\n const marker = findUnvalidatedMerge(ctx.workspaceRoot);\n if (!marker) return [];\n return [new V(\n 1,\n truncate(ctx.command),\n 'A merge is in progress and not yet validated — this command is blocked.\\n'\n + `Marker: ${marker}`,\n )];\n }\n}\n"]}
1
+ {"version":3,"file":"merge-in-progress-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merge-in-progress-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { WEBPIECES_TMP_DIR, MERGE_INFO_DIR, MERGE_IN_PROGRESS_FILE, MergeInProgressGuardConfig } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\n\nconst DEFAULT_MERGE_COMPLETE_COMMAND = 'pnpm wp-finish-upsert-pr';\n\nfunction fixHintFor(mergeCompleteCommand: string): FixHint {\n return new FixHint(\n 'A merge is in progress and not yet validated — this command is blocked.',\n 'You started a merge but never called the finish-merge command, so a 3-point merge is still in progress.\\n'\n + 'Resolve the remaining conflicts in the working tree, then run:\\n'\n + ` ${mergeCompleteCommand}\\n`\n + 'That scans for leftover conflict markers and runs the build; only when green does it commit,\\n'\n + 'unblock commit/push/PR, render the dashboard, and create/update the PR.\\n'\n + 'Add to memory: while a merge is in progress, do not run other commands — finish it with the command above first.',\n );\n}\n\n// Returns the path of the first UNVALIDATED merge marker found, or null. We detect validation\n// by a raw substring (no JSON.parse) so a malformed marker can never crash the guard.\nfunction findUnvalidatedMerge(workspaceRoot: string): string | null {\n // Per-feature merge dirs live under `.webpieces/merge-info/<feature>/`; scan that home's subdirs.\n const mergeInfoDir = path.join(workspaceRoot, WEBPIECES_TMP_DIR, MERGE_INFO_DIR);\n if (!fs.existsSync(mergeInfoDir)) return null;\n for (const entry of fs.readdirSync(mergeInfoDir)) {\n const marker = path.join(mergeInfoDir, entry, MERGE_IN_PROGRESS_FILE);\n if (!fs.existsSync(marker)) continue;\n const raw = fs.readFileSync(marker, 'utf8');\n if (!/\"validated\"\\s*:\\s*true/.test(raw)) return marker;\n }\n return null;\n}\n\nconst BLOCKED_GIT_SUBCOMMANDS: readonly string[] = ['commit', 'push', 'merge', 'rebase'];\nconst SCANNER = new CommandScanner();\n\n// Operations that would let an agent route around the merge gate.\n//\n// Routed through CommandScanner rather than `/\\bgit\\s+merge\\b/`: that pattern matches the read-only\n// `git merge-base origin/main HEAD` (`\\b` sits between `e` and `-`), which appears in this repo's own\n// documented build command — so an in-progress merge used to block a harmless diff-scope lookup.\nfunction isBlockedDuringMerge(cmd: string): boolean {\n return SCANNER.commandInvokesAnyGit(cmd, BLOCKED_GIT_SUBCOMMANDS)\n || /\\bgh\\s+pr\\s+(create|edit|merge)\\b/.test(cmd);\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class MergeInProgressGuardRule extends BashRuleBase<MergeInProgressGuardConfig> {\n private readonly mergeCompleteCommand: string;\n\n constructor(config: MergeInProgressGuardConfig) {\n super(config, 'merge-in-progress-guard');\n this.mergeCompleteCommand = config.mergeCompleteCommand ?? DEFAULT_MERGE_COMPLETE_COMMAND;\n }\n\n readonly description = 'Block commit/push/merge/PR while a 3-point merge marker is unvalidated, forcing the merge-complete command.';\n get fixHint(): FixHint { return fixHintFor(this.mergeCompleteCommand); }\n\n check(ctx: BashContext): readonly Violation[] {\n if (!isBlockedDuringMerge(ctx.command)) return [];\n const marker = findUnvalidatedMerge(ctx.workspaceRoot);\n if (!marker) return [];\n return [new V(\n 1,\n truncate(ctx.command),\n 'A merge is in progress and not yet validated — this command is blocked.\\n'\n + `Marker: ${marker}`,\n )];\n }\n}\n"]}
@@ -3,8 +3,12 @@ import type { BashContext, Violation } from '../types';
3
3
  import { BashRuleBase } from '../rule-base';
4
4
  import { FixHint } from '../fix-hint';
5
5
  export declare class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {
6
+ private readonly scanner;
6
7
  constructor(config: RedirectHowToMergeMainConfig);
7
- readonly description = "Block direct git merge/rebase/pull from main on feature branches. Use the squash-update process instead.";
8
+ readonly description = "Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.";
8
9
  readonly fixHint: FixHint;
9
10
  check(ctx: BashContext): readonly Violation[];
11
+ private checkSegment;
12
+ private checkPull;
13
+ private block;
10
14
  }
@@ -6,39 +6,92 @@ const rules_config_1 = require("@webpieces/rules-config");
6
6
  const types_1 = require("../types");
7
7
  const rule_base_1 = require("../rule-base");
8
8
  const fix_hint_1 = require("../fix-hint");
9
+ const command_scan_1 = require("../command-scan");
9
10
  const INSTRUCT_FILE = 'webpieces.git-workflow.md';
10
- const FIX_HINT = new fix_hint_1.FixHint('Direct merge/rebase/pull from main on a feature branch is blocked.', "To bring main's changes into your feature branch, run 'pnpm wp-start-update' to squash-update from main — never `git merge/rebase/pull main`. This preserves the 3-point fork-point system (fork-point=A, feature-HEAD=B, main-HEAD=C) needed for clean PR diffs. READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the full flow (incl. worktrees).\n"
11
+ const UPDATE_COMMAND = 'pnpm wp-start-update';
12
+ const FIX_HINT = new fix_hint_1.FixHint('`git merge` / `git rebase` are never run by AI — on any branch, in any form.', 'To bring main\'s changes into your feature branch:\n'
13
+ + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\n`
14
+ + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\n'
15
+ + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\n'
16
+ + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\n'
17
+ + '\n'
18
+ + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\n'
19
+ + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\n'
20
+ + 'warn them, in these words:\n'
21
+ + '\n'
22
+ + ' "I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\n'
23
+ + ` \`${UPDATE_COMMAND}\` / \`pnpm wp-finish-upsert-pr\` does a 3-point merge and is the correct\n`
24
+ + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\n'
25
+ + ' this is a genuine exception."\n'
26
+ + '\n'
27
+ + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\n'
28
+ + 'full flow (incl. worktrees).\n'
11
29
  + 'Add that info to memory so you remember next time.');
12
- const WRONG_UPDATE_PATTERNS = [
13
- /git\s+merge\s+(origin\/main|main)\b/,
14
- /git\s+rebase\s+(origin\/main|main)\b/,
15
- /git\s+pull\s+origin\s+main\b/,
16
- ];
30
+ // `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create
31
+ // a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule
32
+ // protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be
33
+ // cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.
34
+ const UNDO_FLAG = /--(?:abort|quit)\b/;
17
35
  function truncate(s) {
18
36
  const MAX = 120;
19
37
  return s.length <= MAX ? s : s.slice(0, MAX) + '…';
20
38
  }
39
+ // Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip
40
+ // this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.
41
+ const SWITCHES_TO_NON_MAIN = /git\s+(?:checkout|switch)\s+(?!main\b|-\s|-$)\S+/;
21
42
  class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
43
+ scanner = new command_scan_1.CommandScanner();
22
44
  constructor(config) { super(config, 'redirect-how-to-merge-main'); }
23
- description = 'Block direct git merge/rebase/pull from main on feature branches. Use the squash-update process instead.';
45
+ description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';
24
46
  fixHint = FIX_HINT;
25
47
  check(ctx) {
26
- const matched = WRONG_UPDATE_PATTERNS.some((p) => p.test(ctx.command));
27
- if (!matched)
28
- return [];
29
- // Allow 'git checkout main && git pull origin main' — switching to main first is the recommended workflow
30
- if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command)) {
31
- return [];
48
+ for (const segment of this.scanner.commandSegments(ctx.command)) {
49
+ const violation = this.checkSegment(ctx, segment);
50
+ if (violation !== null)
51
+ return [violation];
32
52
  }
53
+ return [];
54
+ }
55
+ checkSegment(ctx, segment) {
56
+ // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.
57
+ //
58
+ // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook
59
+ // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to
60
+ // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a
61
+ // prior cleanup, read as "we're on main, this is fine" and was waved through. That is the
62
+ // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on
63
+ // ANY branch, there is no branch to consult — and so no HEAD to spoof.
64
+ if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {
65
+ if (UNDO_FLAG.test(segment))
66
+ return null;
67
+ return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');
68
+ }
69
+ // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form
70
+ // (`git checkout main && git pull origin main`), so it must consult the branch — which is
71
+ // exactly why it also needs the branch-switch check that (1) no longer requires.
72
+ if (this.scanner.invokesGit(segment, 'pull') && /\borigin\s+main\b/.test(segment)) {
73
+ return this.checkPull(ctx, segment);
74
+ }
75
+ return null;
76
+ }
77
+ checkPull(ctx, segment) {
78
+ if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {
79
+ return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');
80
+ }
81
+ // The recommended `git checkout main && git pull origin main`.
82
+ if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command))
83
+ return null;
33
84
  const currentBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
34
85
  cwd: ctx.workspaceRoot,
35
86
  encoding: 'utf8',
36
87
  }).trim();
37
- if (currentBranch === 'main') {
38
- return [];
39
- }
88
+ if (currentBranch === 'main')
89
+ return null;
90
+ return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);
91
+ }
92
+ block(ctx, segment, what) {
40
93
  const docPath = new rules_config_1.RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);
41
- return [new types_1.Violation(1, truncate(ctx.command), `Direct merge/rebase from main on branch '${currentBranch}' is blocked. Use 'pnpm wp-start-update'; full flow: READ ${docPath}.`)];
94
+ return new types_1.Violation(1, truncate(segment), `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it and warn them to push back. Full flow: READ ${docPath}.`);
42
95
  }
43
96
  }
44
97
  exports.RedirectHowToMergeMainRule = RedirectHowToMergeMainRule;
@@ -1 +1 @@
1
- {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAEzC,0DAAuF;AAGvF,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AAEtC,MAAM,aAAa,GAAG,2BAA2B,CAAC;AAElD,MAAM,QAAQ,GAAG,IAAI,kBAAO,CACxB,oEAAoE,EACpE,iYAAiY;MAC/X,oDAAoD,CACzD,CAAC;AAEF,MAAM,qBAAqB,GAAa;IACpC,qCAAqC;IACrC,sCAAsC;IACtC,8BAA8B;CACjC,CAAC;AAEF,SAAS,QAAQ,CAAC,CAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC;IAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;AACvD,CAAC;AAED,MAAa,0BAA2B,SAAQ,wBAA0C;IACtF,YAAY,MAAoC,IAAI,KAAK,CAAC,MAAM,EAAE,4BAA4B,CAAC,CAAC,CAAC,CAAC;IAEzF,WAAW,GAAG,0GAA0G,CAAC;IACzH,OAAO,GAAG,QAAQ,CAAC;IAE5B,KAAK,CAAC,GAAgB;QAClB,MAAM,OAAO,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;QAC/E,IAAI,CAAC,OAAO;YAAE,OAAO,EAAE,CAAC;QAExB,0GAA0G;QAC1G,IAAI,oCAAoC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACzD,OAAO,EAAE,CAAC;QACd,CAAC;QAED,MAAM,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC9D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QAEV,IAAI,aAAa,KAAK,MAAM,EAAE,CAAC;YAC3B,OAAO,EAAE,CAAC;QACd,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC,iBAAiB,CAAC,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QACzF,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,4CAA4C,aAAa,6DAA6D,OAAO,GAAG,CACnI,CAAC,CAAC;IACP,CAAC;CACJ;AA/BD,gEA+BC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\n\nconst FIX_HINT = new FixHint(\n 'Direct merge/rebase/pull from main on a feature branch is blocked.',\n \"To bring main's changes into your feature branch, run 'pnpm wp-start-update' to squash-update from main never `git merge/rebase/pull main`. This preserves the 3-point fork-point system (fork-point=A, feature-HEAD=B, main-HEAD=C) needed for clean PR diffs. READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the full flow (incl. worktrees).\\n\"\n + 'Add that info to memory so you remember next time.',\n);\n\nconst WRONG_UPDATE_PATTERNS: RegExp[] = [\n /git\\s+merge\\s+(origin\\/main|main)\\b/,\n /git\\s+rebase\\s+(origin\\/main|main)\\b/,\n /git\\s+pull\\s+origin\\s+main\\b/,\n];\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block direct git merge/rebase/pull from main on feature branches. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n const matched = WRONG_UPDATE_PATTERNS.some((p: RegExp) => p.test(ctx.command));\n if (!matched) return [];\n\n // Allow 'git checkout main && git pull origin main'switching to main first is the recommended workflow\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) {\n return [];\n }\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 [];\n }\n\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return [new V(\n 1,\n truncate(ctx.command),\n `Direct merge/rebase from main on branch '${currentBranch}' is blocked. Use 'pnpm wp-start-update'; full flow: READ ${docPath}.`,\n )];\n }\n}\n"]}
1
+ {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst UPDATE_COMMAND = 'pnpm wp-start-update';\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\\n`\n + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\\n'\n + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\\n'\n + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\\n'\n + '\\n'\n + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\\n'\n + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\\n'\n + 'warn them, in these words:\\n'\n + '\\n'\n + ' \"I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\\n'\n + ` \\`${UPDATE_COMMAND}\\` / \\`pnpm wp-finish-upsert-pr\\` does a 3-point merge and is the correct\\n`\n + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\\n'\n + ' this is a genuine exception.\"\\n'\n + '\\n'\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\\n'\n + 'full flow (incl. worktrees).\\n'\n + 'Add that info to memory so you remember next time.',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\n// Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip\n// this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.\nconst SWITCHES_TO_NON_MAIN = /git\\s+(?:checkout|switch)\\s+(?!main\\b|-\\s|-$)\\S+/;\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main`.\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) return null;\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return new V(\n 1,\n truncate(segment),\n `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it and warn them to push back. Full flow: READ ${docPath}.`,\n );\n }\n}\n"]}