@webpieces/ai-hook-rules 0.4.478 → 0.4.480

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.478",
3
+ "version": "0.4.480",
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",
@@ -32,7 +32,7 @@
32
32
  "directory": "packages/tooling/ai-hook-rules"
33
33
  },
34
34
  "dependencies": {
35
- "@webpieces/rules-config": "0.4.478"
35
+ "@webpieces/rules-config": "0.4.480"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -1,6 +1,7 @@
1
1
  import { ExcludePaths } from '@webpieces/rules-config';
2
2
  import { ToolKind, NormalizedToolInput, BlockedResult, HookMode, Rule, Violation, EditContext, FileContext, BashContext } from './types';
3
3
  export declare function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[];
4
+ export declare function effectiveBashCwd(command: string, cwd: string): string;
4
5
  export declare function isGitOrGhCommand(command: string): boolean;
5
6
  export declare function run(toolKind: ToolKind, input: NormalizedToolInput, cwd: string, mode?: HookMode): BlockedResult | null;
6
7
  export declare function runBash(command: string, cwd: string, mode?: HookMode): BlockedResult | null;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.filterByExcludedPaths = filterByExcludedPaths;
4
+ exports.effectiveBashCwd = effectiveBashCwd;
4
5
  exports.isGitOrGhCommand = isGitOrGhCommand;
5
6
  exports.run = run;
6
7
  exports.runBash = runBash;
@@ -11,6 +12,7 @@ const path = tslib_1.__importStar(require("path"));
11
12
  const child_process_1 = require("child_process");
12
13
  const rules_config_1 = require("@webpieces/rules-config");
13
14
  const build_context_1 = require("./build-context");
15
+ const command_scan_1 = require("./command-scan");
14
16
  const load_rules_1 = require("./load-rules");
15
17
  const match_rule_1 = require("./rules/match-rule");
16
18
  const main_sync_refresh_1 = require("./main-sync-refresh");
@@ -47,6 +49,35 @@ function gitToplevel(cwd) {
47
49
  const r = (0, child_process_1.spawnSync)('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
48
50
  return r.status === 0 ? (r.stdout ?? '').trim() : null;
49
51
  }
52
+ // True when `cwd` sits inside a git repo OTHER than the one `workspaceRoot` governs (a nested clone).
53
+ // Not in a git repo / git unavailable (null) is NOT foreign — it falls through to the normal guards.
54
+ // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
55
+ function isForeignGitRepo(cwd, workspaceRoot) {
56
+ const gitRoot = gitToplevel(cwd);
57
+ return gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot);
58
+ }
59
+ // The cwd a command actually runs from, resolving any leading `cd`/`pushd` in the command itself.
60
+ // PreToolUse fires BEFORE the command runs, so the shell's `cwd` is the pre-`cd` directory; a command
61
+ // like `cd repositories/clone && git push` really executes in `repositories/clone`. Every git-boundary
62
+ // and excludePaths decision must key off THIS directory, not the pre-`cd` one, or a nested clone is
63
+ // judged against the outer repo (the two defects this repairs).
64
+ //
65
+ // Reuses CommandScanner so quoting is handled exactly as the guards handle it: `echo "cd sub && git
66
+ // push"` is ONE opaque segment whose first word is `echo`, so the quoted `cd` is never picked up —
67
+ // the prose/quoted `cd` cannot be weaponised into a scope escape. `cd a && cd b` resolves left to
68
+ // right (last wins), matching the shell.
69
+ // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
70
+ function effectiveBashCwd(command, cwd) {
71
+ const scanner = new command_scan_1.CommandScanner();
72
+ let effective = cwd;
73
+ for (const segment of scanner.commandSegments(command)) {
74
+ const words = scanner.words(segment);
75
+ if ((words[0] === 'cd' || words[0] === 'pushd') && words[1] !== undefined) {
76
+ effective = path.resolve(effective, words[1]);
77
+ }
78
+ }
79
+ return effective;
80
+ }
50
81
  // A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).
51
82
  const GIT_OR_GH_RE = /(?:^|[;&|]\s*)(?:git|gh)\b/;
52
83
  function isGitOrGhCommand(command) {
@@ -156,6 +187,20 @@ function runRead(filePath, cwd, mode = 'all') {
156
187
  function isInstallerCommand(command) {
157
188
  return shim_1.INSTALLER_ALLOW_JS.test(command.trim());
158
189
  }
190
+ // Force-to-root: git/gh commands must run from the repo root, where the guards can reason about git
191
+ // state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip. Returns
192
+ // null when the command is not a subdir git/gh invocation (nothing to block).
193
+ // webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
194
+ function gitFromSubdirBlock(command, cwd, workspaceRoot) {
195
+ if (!isGitOrGhCommand(command) || path.resolve(cwd) === path.resolve(workspaceRoot))
196
+ return null;
197
+ const report = `❌ Run git/gh commands from the repo root, not a subdirectory.\n` +
198
+ ` You are in: ${cwd}\n` +
199
+ ` cd to the repo root first: cd ${workspaceRoot}\n` +
200
+ ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;
201
+ (0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('force-to-root', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'git/gh from subdir'));
202
+ return new types_1.BlockedResult(report);
203
+ }
159
204
  function runBashInternal(command, cwd, mode) {
160
205
  if (isInstallerCommand(command)) {
161
206
  // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,
@@ -169,31 +214,31 @@ function runBashInternal(command, cwd, mode) {
169
214
  if (loaded.configPath === null)
170
215
  return new types_1.BlockedResult(CONFIG_MISSING_REPORT);
171
216
  const workspaceRoot = path.dirname(loaded.configPath);
172
- // Git-repo-boundary governance. The hook now always runs (via $CLAUDE_PROJECT_DIR), so this is
173
- // where out-of-scope work is let through deliberately instead of the old accidental 127.
174
- const gitRoot = gitToplevel(cwd);
175
- if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) {
176
- // cwd is inside a DIFFERENT git repo than this webpieces.config governs (e.g. a clone under
177
- // repositories/). Out of scope → allow, hands-off. Intentional, not a silent hole.
217
+ // The directory the command actually runs from (after any in-command `cd`), not the pre-`cd`
218
+ // shell cwd. Both the git-boundary check and the excludePaths filter below key off this, so a
219
+ // self-contained `cd <nested clone> && …` is judged against the clone, not the outer repo.
220
+ const effectiveCwd = effectiveBashCwd(command, cwd);
221
+ // Git-repo-boundary governance: the command runs inside a DIFFERENT git repo than this
222
+ // webpieces.config governs (e.g. a clone under repositories/). Out of scope → allow, hands-off.
223
+ // Intentional, not a silent hole. (The hook always runs via $CLAUDE_PROJECT_DIR, so this is where
224
+ // out-of-scope work is let through deliberately instead of the old accidental 127.)
225
+ if (isForeignGitRepo(effectiveCwd, workspaceRoot)) {
178
226
  (0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));
179
227
  return null;
180
228
  }
181
- const rules = filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot), mode);
229
+ // Honour excludePaths.guards on the bash path too (not just Read/Edit): a command whose effective
230
+ // cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how
231
+ // runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which
232
+ // matches no exclusion glob, so a plain command at the repo root is unaffected.
233
+ const rules = filterByExcludedPaths(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot), mode), path.relative(workspaceRoot, effectiveCwd), loaded.excludePaths);
182
234
  if (rules.length === 0)
183
235
  return null;
184
236
  const outOfSync = checkConfigSync(rules, loaded.rulesConfig);
185
237
  if (outOfSync)
186
238
  return outOfSync;
187
- // Force-to-root: git/gh commands must run from the repo root, where the guards can reason about
188
- // git state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip.
189
- if (isGitOrGhCommand(command) && path.resolve(cwd) !== path.resolve(workspaceRoot)) {
190
- const report = `❌ Run git/gh commands from the repo root, not a subdirectory.\n` +
191
- ` You are in: ${cwd}\n` +
192
- ` cd to the repo root first: cd ${workspaceRoot}\n` +
193
- ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;
194
- (0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('force-to-root', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'git/gh from subdir'));
195
- return new types_1.BlockedResult(report);
196
- }
239
+ const subdirBlock = gitFromSubdirBlock(command, cwd, workspaceRoot);
240
+ if (subdirBlock)
241
+ return subdirBlock;
197
242
  // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs
198
243
  // far more bash than edits, so refreshing here means the guard's next file-edit check reads a
199
244
  // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { loadAndValidate, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport { INSTALLER_ALLOW_JS } from '../bin/shim';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop rules whose category is excluded for this file path (webpieces.config.json → excludePaths).\n// Two independent glob lists: `guards` suppresses file-scoped guards (e.g. feature-branch-guard),\n// `rules` suppresses code-style rules — so a vendored tree can be exempt from one but not the other.\n// Only file tools reach here; bash git/PR guards (no file path) are never affected.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n return rules.filter((r: Rule): boolean => {\n const patterns = isHookGuard(r.name) ? ex.guards : ex.rules;\n return !patterns.some((p: string): boolean => globMatches(p, relativePath));\n });\n}\n\n// The git repo root of `cwd`, or null if cwd is not in a git repo / git is unavailable. This is the\n// repo-boundary signal: the guards only govern commands whose repo IS the one this webpieces.config\n// governs; a command run inside a nested clone (different git root) is out of scope.\nfunction gitToplevel(cwd: string): string | null {\n const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n return r.status === 0 ? (r.stdout ?? '').trim() : null;\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\nconst CONFIG_MISSING_REPORT =\n 'webpieces.config.json not found.\\n' +\n 'Tell the human: run `./node_modules/.bin/wp-setup-ai-hooks` to initialize the project configuration.\\n' +\n 'Do not proceed until the human has done this.';\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool\n// there is, so this path is an explicit single-rule allowlist rather than the general rule loop.\nconst READ_SCOPED_GUARDS: ReadonlySet<string> = new Set(['read-stale-guard']);\n\n/**\n * The Read path. Deliberately NOT `run()`:\n *\n * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks\n * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds\n * any new rule instantly blocks the agent from reading the very config file it must edit to fix\n * it. Reads must never carry that failure mode.\n * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.\n * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).\n *\n * Returns null (allow) unless the one guard fires.\n */\n// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape\nexport function runRead(filePath: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n // Code-style mode has nothing to say about a read.\n if (mode === 'rules') return null;\n\n const loaded = loadAndValidate(cwd);\n // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo\n // must still be readable.\n if (loaded.configPath === null) return null;\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Same git-repo-boundary governance as bash: a read inside a different clone is out of scope.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) return null;\n\n const relativePath = path.relative(workspaceRoot, filePath);\n const all = loadRules(loaded.rulesConfig, workspaceRoot);\n const rules = filterByExcludedPaths(\n all.filter((r: Rule): boolean => READ_SCOPED_GUARDS.has(r.name)),\n relativePath,\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const ctx = new FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);\n const groups = runFileRules(rules, ctx);\n if (groups.length === 0) return null;\n\n return new BlockedResult(formatReport(relativePath, groups));\n}\n\n// Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock). Mirrors the fail-closed shim's INSTALLER_ALLOW_ERE\n// (missing-bin case); INSTALLER_ALLOW_JS is its locked JS twin. Match is tight (`pnpm install` /\n// `npm i` + `--flags` only, no chaining) so `pnpm install && rm -rf /` still falls to the guards.\nfunction isInstallerCommand(command: string): boolean {\n return INSTALLER_ALLOW_JS.test(command.trim());\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isInstallerCommand(command)) {\n // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,\n // then git toplevel) so a bypass logged from a subdir/nested clone never scatters a stray\n // `.webpieces` tree. This runs before loadAndValidate, so resolveRepoRoot (not workspaceRoot).\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'installer bypass (always allowed)'));\n return null;\n }\n\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Git-repo-boundary governance. The hook now always runs (via $CLAUDE_PROJECT_DIR), so this is\n // where out-of-scope work is let through deliberately instead of the old accidental 127.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) {\n // cwd is inside a DIFFERENT git repo than this webpieces.config governs (e.g. a clone under\n // repositories/). Out of scope → allow, hands-off. Intentional, not a silent hole.\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n const rules = filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode);\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n // Force-to-root: git/gh commands must run from the repo root, where the guards can reason about\n // git state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip.\n if (isGitOrGhCommand(command) && path.resolve(cwd) !== path.resolve(workspaceRoot)) {\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` You are in: ${cwd}\\n` +\n ` cd to the repo root first: cd ${workspaceRoot}\\n` +\n ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(workspaceRoot, new GuardDecision('force-to-root', 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n }\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n maybeRefreshMainSync(rules, workspaceRoot);\n\n const ctx = buildBashContext(command, workspaceRoot);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(workspaceRoot, new GuardDecision(ruleNames, 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'bash-guard block'));\n const report = formatReport('<bash>', groups);\n return new BlockedResult(report);\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n const lines = [\n 'webpieces.config.json is out of sync — new built-in rules are present that have no entry in webpieces.config.json.',\n '',\n 'Tell the human: the following rules need to be configured. Ask for each one:',\n ' - Should this rule be ON, OFF, NEW_AND_MODIFIED_CODE, or NEW_AND_MODIFIED_FILES?',\n ' - What values do you want for the options listed below?',\n 'Then update webpieces.config.json and retry.',\n '',\n 'Do NOT proceed until webpieces.config.json has an entry for every rule below.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n lines.push(`Example entry for webpieces.config.json:`);\n lines.push(` \"${rule.name}\": { \"mode\": \"ON\" }`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/runner.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { loadAndValidate, WebpiecesRulesConfig, ExcludePaths, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES, RepoRootFinder } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { CommandScanner } from './command-scan';\nimport { loadRules, loadMatchRules, globMatches } from './load-rules';\nimport { MatchRule } from './rules/match-rule';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport { INSTALLER_ALLOW_JS } from '../bin/shim';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup, RuleFailError,\n EditContext, FileContext, BashContext,\n} from './types';\n\n// Restrict loaded rules to the category this hook invocation runs. The two split hooks each pass a\n// disjoint category ('rules' = code-style, 'guards' = the hookGuards section); 'all' runs both (the\n// openclaw plugin adapter, a single before_tool_call hook). isHookGuard is the shared classifier in\n// @webpieces/rules-config.\nfunction filterByMode(rules: readonly Rule[], mode: HookMode): readonly Rule[] {\n if (mode === 'all') return rules;\n if (mode === 'guards') return rules.filter((r: Rule): boolean => isHookGuard(r.name));\n return rules.filter((r: Rule): boolean => !isHookGuard(r.name));\n}\n\n// Drop rules whose category is excluded for this file path (webpieces.config.json → excludePaths).\n// Two independent glob lists: `guards` suppresses file-scoped guards (e.g. feature-branch-guard),\n// `rules` suppresses code-style rules — so a vendored tree can be exempt from one but not the other.\n// Only file tools reach here; bash git/PR guards (no file path) are never affected.\nexport function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[] {\n return rules.filter((r: Rule): boolean => {\n const patterns = isHookGuard(r.name) ? ex.guards : ex.rules;\n return !patterns.some((p: string): boolean => globMatches(p, relativePath));\n });\n}\n\n// The git repo root of `cwd`, or null if cwd is not in a git repo / git is unavailable. This is the\n// repo-boundary signal: the guards only govern commands whose repo IS the one this webpieces.config\n// governs; a command run inside a nested clone (different git root) is out of scope.\nfunction gitToplevel(cwd: string): string | null {\n const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n return r.status === 0 ? (r.stdout ?? '').trim() : null;\n}\n\n// True when `cwd` sits inside a git repo OTHER than the one `workspaceRoot` governs (a nested clone).\n// Not in a git repo / git unavailable (null) is NOT foreign — it falls through to the normal guards.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction isForeignGitRepo(cwd: string, workspaceRoot: string): boolean {\n const gitRoot = gitToplevel(cwd);\n return gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot);\n}\n\n// The cwd a command actually runs from, resolving any leading `cd`/`pushd` in the command itself.\n// PreToolUse fires BEFORE the command runs, so the shell's `cwd` is the pre-`cd` directory; a command\n// like `cd repositories/clone && git push` really executes in `repositories/clone`. Every git-boundary\n// and excludePaths decision must key off THIS directory, not the pre-`cd` one, or a nested clone is\n// judged against the outer repo (the two defects this repairs).\n//\n// Reuses CommandScanner so quoting is handled exactly as the guards handle it: `echo \"cd sub && git\n// push\"` is ONE opaque segment whose first word is `echo`, so the quoted `cd` is never picked up —\n// the prose/quoted `cd` cannot be weaponised into a scope escape. `cd a && cd b` resolves left to\n// right (last wins), matching the shell.\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nexport function effectiveBashCwd(command: string, cwd: string): string {\n const scanner = new CommandScanner();\n let effective = cwd;\n for (const segment of scanner.commandSegments(command)) {\n const words = scanner.words(segment);\n if ((words[0] === 'cd' || words[0] === 'pushd') && words[1] !== undefined) {\n effective = path.resolve(effective, words[1]);\n }\n }\n return effective;\n}\n\n// A git or gh invocation anywhere in the command (start, or after a ;/&&/|| separator or pipe).\nconst GIT_OR_GH_RE = /(?:^|[;&|]\\s*)(?:git|gh)\\b/;\nexport function isGitOrGhCommand(command: string): boolean {\n return GIT_OR_GH_RE.test(command);\n}\n\n// Fire-and-forget the detached refresher when feature-branch-guard is loaded and active, so the\n// cache (.webpieces/main-sync-status.json) stays fresh as the AI works. The guard rule itself also\n// triggers this on Write/Edit; this covers the Bash path so the cache is warm on every command.\nfunction maybeRefreshMainSync(rules: readonly Rule[], workspaceRoot: string): void {\n const guard = rules.find((r: Rule): boolean => r.name === 'feature-branch-guard');\n if (guard && guard.shouldRun()) {\n triggerMainSyncRefresh(workspaceRoot, DEFAULT_HANG_TIMEOUT_MINUTES);\n }\n}\n\nconst CONFIG_MISSING_REPORT =\n 'webpieces.config.json not found.\\n' +\n 'Tell the human: run `./node_modules/.bin/wp-setup-ai-hooks` to initialize the project configuration.\\n' +\n 'Do not proceed until the human has done this.';\n\nexport function run(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode = 'all',\n): BlockedResult | null {\n return runInternal(toolKind, input, cwd, mode);\n}\n\nfunction runInternal(\n toolKind: ToolKind,\n input: NormalizedToolInput,\n cwd: string,\n mode: HookMode,\n): BlockedResult | null {\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Always allow edits to webpieces.config.json — it's the fix target when out of sync\n if (path.resolve(input.filePath) === path.resolve(loaded.configPath)) {\n return null;\n }\n\n // Built-in/custom rules PLUS the client-authored match-rules (content guards). Match-rules run only\n // in the file-edit path (they are code-style, so filterByMode keeps them out of the bash/guards path).\n const allRules = [...loadRules(loaded.rulesConfig, workspaceRoot), ...loadMatchRules(loaded.matchRules)];\n const modeRules = filterByMode(allRules, mode);\n if (modeRules.length === 0) return null;\n\n // Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under\n // repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole\n // rule set and is fully hands-off — no violations AND no config-sync nag on those files.\n const relativePath = path.relative(workspaceRoot, input.filePath);\n const rules = filterByExcludedPaths(modeRules, relativePath, loaded.excludePaths);\n if (rules.length === 0) return null;\n\n // Config-sync applies only to built-in/custom rules; match-rules have their own validated section\n // (loadAndValidate already rejected an invalid `match-rules`), so they must not trip the sync nag.\n const outOfSync = checkConfigSync(rules.filter((r: Rule) => !(r instanceof MatchRule)), loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const contexts = buildContexts(toolKind, input, workspaceRoot);\n\n const editGroups = runEditRules(rules, contexts.editContexts);\n const fileGroups = runFileRules(rules, contexts.fileContext);\n const allGroups = [...editGroups, ...fileGroups];\n\n if (allGroups.length === 0) return null;\n\n const report = formatReport(relativePath, allGroups);\n return new BlockedResult(report);\n}\n\nexport function runBash(command: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n return runBashInternal(command, cwd, mode);\n}\n\n// The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool\n// there is, so this path is an explicit single-rule allowlist rather than the general rule loop.\nconst READ_SCOPED_GUARDS: ReadonlySet<string> = new Set(['read-stale-guard']);\n\n/**\n * The Read path. Deliberately NOT `run()`:\n *\n * - NO config-sync check. A rule present in code but missing from webpieces.config.json blocks\n * every Write/Edit/Bash by design — but applying that to Read would mean an upgrade that adds\n * any new rule instantly blocks the agent from reading the very config file it must edit to fix\n * it. Reads must never carry that failure mode.\n * - NO general rule loop. Only READ_SCOPED_GUARDS run, so no code-style rule can ever see a Read.\n * - Fails OPEN everywhere, including on a thrown rule (the caller catches and allows).\n *\n * Returns null (allow) unless the one guard fires.\n */\n// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape\nexport function runRead(filePath: string, cwd: string, mode: HookMode = 'all'): BlockedResult | null {\n // Code-style mode has nothing to say about a read.\n if (mode === 'rules') return null;\n\n const loaded = loadAndValidate(cwd);\n // No config → nothing to enforce. Unlike the edit path we do NOT block: an unconfigured repo\n // must still be readable.\n if (loaded.configPath === null) return null;\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // Same git-repo-boundary governance as bash: a read inside a different clone is out of scope.\n const gitRoot = gitToplevel(cwd);\n if (gitRoot !== null && path.resolve(gitRoot) !== path.resolve(workspaceRoot)) return null;\n\n const relativePath = path.relative(workspaceRoot, filePath);\n const all = loadRules(loaded.rulesConfig, workspaceRoot);\n const rules = filterByExcludedPaths(\n all.filter((r: Rule): boolean => READ_SCOPED_GUARDS.has(r.name)),\n relativePath,\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const ctx = new FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);\n const groups = runFileRules(rules, ctx);\n if (groups.length === 0) return null;\n\n return new BlockedResult(formatReport(relativePath, groups));\n}\n\n// Installer bypass — package-manager install commands ALWAYS pass, ahead of any config load. A\n// webpieces.config.json that is ahead of the installed validator (new rule tokens the published\n// binary doesn't know yet) makes loadAndValidate() throw and would deny `pnpm install` — the very\n// command that updates the validator (deadlock). Mirrors the fail-closed shim's INSTALLER_ALLOW_ERE\n// (missing-bin case); INSTALLER_ALLOW_JS is its locked JS twin. Match is tight (`pnpm install` /\n// `npm i` + `--flags` only, no chaining) so `pnpm install && rm -rf /` still falls to the guards.\nfunction isInstallerCommand(command: string): boolean {\n return INSTALLER_ALLOW_JS.test(command.trim());\n}\n\n// Force-to-root: git/gh commands must run from the repo root, where the guards can reason about git\n// state coherently. From a subdir, BLOCK with an actionable cd message — never silently skip. Returns\n// null when the command is not a subdir git/gh invocation (nothing to block).\n// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape\nfunction gitFromSubdirBlock(command: string, cwd: string, workspaceRoot: string): BlockedResult | null {\n if (!isGitOrGhCommand(command) || path.resolve(cwd) === path.resolve(workspaceRoot)) return null;\n const report =\n `❌ Run git/gh commands from the repo root, not a subdirectory.\\n` +\n ` You are in: ${cwd}\\n` +\n ` cd to the repo root first: cd ${workspaceRoot}\\n` +\n ` Then re-run your command. (The webpieces guards evaluate the repo's git state at its root.)`;\n logGuardDecision(workspaceRoot, new GuardDecision('force-to-root', 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'git/gh from subdir'));\n return new BlockedResult(report);\n}\n\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): BlockedResult | null {\n if (isInstallerCommand(command)) {\n // Anchor the audit-log write at the repo root that owns `.webpieces` (config-walk-up first,\n // then git toplevel) so a bypass logged from a subdir/nested clone never scatters a stray\n // `.webpieces` tree. This runs before loadAndValidate, so resolveRepoRoot (not workspaceRoot).\n const root = new RepoRootFinder().resolveRepoRoot(cwd);\n logGuardDecision(root, new GuardDecision('-', 'Bash', command, branchForLog(root), 'ALLOW', 'installer bypass (always allowed)'));\n return null;\n }\n\n const loaded = loadAndValidate(cwd);\n if (loaded.configPath === null) return new BlockedResult(CONFIG_MISSING_REPORT);\n\n const workspaceRoot = path.dirname(loaded.configPath);\n\n // The directory the command actually runs from (after any in-command `cd`), not the pre-`cd`\n // shell cwd. Both the git-boundary check and the excludePaths filter below key off this, so a\n // self-contained `cd <nested clone> && …` is judged against the clone, not the outer repo.\n const effectiveCwd = effectiveBashCwd(command, cwd);\n\n // Git-repo-boundary governance: the command runs inside a DIFFERENT git repo than this\n // webpieces.config governs (e.g. a clone under repositories/). Out of scope → allow, hands-off.\n // Intentional, not a silent hole. (The hook always runs via $CLAUDE_PROJECT_DIR, so this is where\n // out-of-scope work is let through deliberately instead of the old accidental 127.)\n if (isForeignGitRepo(effectiveCwd, workspaceRoot)) {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'foreign git repo (out of scope)'));\n return null;\n }\n\n // Honour excludePaths.guards on the bash path too (not just Read/Edit): a command whose effective\n // cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how\n // runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which\n // matches no exclusion glob, so a plain command at the repo root is unaffected.\n const rules = filterByExcludedPaths(\n filterByMode(loadRules(loaded.rulesConfig, workspaceRoot), mode),\n path.relative(workspaceRoot, effectiveCwd),\n loaded.excludePaths,\n );\n if (rules.length === 0) return null;\n\n const outOfSync = checkConfigSync(rules, loaded.rulesConfig);\n if (outOfSync) return outOfSync;\n\n const subdirBlock = gitFromSubdirBlock(command, cwd, workspaceRoot);\n if (subdirBlock) return subdirBlock;\n\n // Keep the feature-branch-guard cache warm on EVERY command (not just Write/Edit): the AI runs\n // far more bash than edits, so refreshing here means the guard's next file-edit check reads a\n // fresh status. Detached + fire-and-forget — never blocks the command. Only when the guard is\n // loaded (guards/all mode) and enabled, so a project that opted out never triggers git fetches.\n maybeRefreshMainSync(rules, workspaceRoot);\n\n const ctx = buildBashContext(command, workspaceRoot);\n const groups = runBashRules(rules, ctx);\n if (groups.length === 0) {\n // Record the ALLOW only for git/gh commands — the operations the bash guards actually reason\n // about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log\n // focused (the whole point of the log is \"why did/didn't a guard fire?\"). Blocks are always\n // logged below.\n if (/\\b(?:git|gh)\\b/.test(command)) {\n logGuardDecision(workspaceRoot, new GuardDecision('-', 'Bash', command, branchForLog(workspaceRoot), 'ALLOW', 'no bash-guard block'));\n }\n return null;\n }\n\n const ruleNames = groups.map((g: RuleGroup): string => g.ruleName).join(',');\n logGuardDecision(workspaceRoot, new GuardDecision(ruleNames, 'Bash', command, branchForLog(workspaceRoot), 'BLOCK', 'bash-guard block'));\n const report = formatReport('<bash>', groups);\n return new BlockedResult(report);\n}\n\n// The set of rule names explicitly present in webpieces.config.json (every key except rulesDir).\nfunction configuredRuleNames(config: WebpiecesRulesConfig): ReadonlySet<string> {\n return new Set(Object.keys(config).filter((k: string) => k !== 'rulesDir'));\n}\n\nfunction checkConfigSync(rules: readonly Rule[], config: WebpiecesRulesConfig): BlockedResult | null {\n const configured = configuredRuleNames(config);\n const unconfiguredRules = rules.filter((r: Rule) => !configured.has(r.name));\n if (unconfiguredRules.length === 0) return null;\n\n const lines = [\n 'webpieces.config.json is out of sync — new built-in rules are present that have no entry in webpieces.config.json.',\n '',\n 'Tell the human: the following rules need to be configured. Ask for each one:',\n ' - Should this rule be ON, OFF, NEW_AND_MODIFIED_CODE, or NEW_AND_MODIFIED_FILES?',\n ' - What values do you want for the options listed below?',\n 'Then update webpieces.config.json and retry.',\n '',\n 'Do NOT proceed until webpieces.config.json has an entry for every rule below.',\n '',\n ];\n\n for (const rule of unconfiguredRules) {\n lines.push(`--- ${rule.name} ---`);\n lines.push(`Description: ${rule.description}`);\n const opts = rule.defaultOptions;\n const optKeys = Object.keys(opts);\n if (optKeys.length > 0) {\n lines.push(`Available options (suggested defaults shown):`);\n for (const key of optKeys) {\n lines.push(` ${key}: ${JSON.stringify(opts[key])}`);\n }\n } else {\n lines.push('Available options: none beyond mode');\n }\n lines.push(`Example entry for webpieces.config.json:`);\n lines.push(` \"${rule.name}\": { \"mode\": \"ON\" }`);\n lines.push('');\n }\n\n return new BlockedResult(lines.join('\\n'));\n}\n\n// N-legs pattern: each rule runs independently so one rule can never abort the others. A rule may\n// EITHER return Violation[] OR throw — both accumulate here into visible violations the AI sees:\n// - a thrown RuleFailError → an expected, well-formed violation (its line/snippet/fixHints kept);\n// - a thrown plain Error → a \"crashed\" violation (a bug, surfaced not swallowed).\nexport function runRuleCheck(rule: Rule, ctx: EditContext | FileContext | BashContext): readonly Violation[] {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return rule.check(ctx);\n } catch (err: unknown) {\n const error = toError(err);\n if (error instanceof RuleFailError) {\n return [violationFromRuleFail(error)];\n }\n return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\n}\n\n// A thrown RuleFailError carries its own AI-facing message + optional location and fix hints. Fold the\n// fix hints into the message because Violation has no fixHint field (RuleGroup's fixHint comes from the\n// rule definition, not a per-throw value).\nfunction violationFromRuleFail(error: RuleFailError): Violation {\n const hints = error.fixHints.length > 0 ? `\\n Fix: ${error.fixHints.join('\\n Fix: ')}` : '';\n return new Violation(error.line ?? 0, error.snippet ?? '', error.aiMessage + hints);\n}\n\nfunction ruleMatchesFile(rule: Rule, relativePath: string): boolean {\n for (const pattern of rule.files) {\n if (globMatches(pattern, relativePath)) return true;\n }\n return false;\n}\n\nfunction runBashRules(rules: readonly Rule[], bashContext: BashContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'bash') continue;\n if (!rule.shouldRun()) continue;\n const vs = runRuleCheck(rule, bashContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n\nfunction runEditRules(rules: readonly Rule[], editContexts: readonly EditContext[]): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'edit') continue;\n if (!rule.shouldRun()) continue;\n const allViolations: Violation[] = [];\n for (const ctx of editContexts) {\n if (!ruleMatchesFile(rule, ctx.relativePath)) continue;\n const vs = runRuleCheck(rule, ctx);\n for (const v of vs) {\n const copy = new Violation(v.line, v.snippet, v.message);\n copy.editIndex = ctx.editIndex;\n copy.editCount = ctx.editCount;\n allViolations.push(copy);\n }\n }\n if (allViolations.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, allViolations,\n ));\n }\n }\n return groups;\n}\n\nfunction runFileRules(rules: readonly Rule[], fileContext: FileContext): readonly RuleGroup[] {\n const groups: RuleGroup[] = [];\n for (const rule of rules) {\n if (rule.scope !== 'file') continue;\n if (!rule.shouldRun()) continue;\n if (!ruleMatchesFile(rule, fileContext.relativePath)) continue;\n const vs = runRuleCheck(rule, fileContext);\n if (vs.length > 0) {\n groups.push(new RuleGroup(\n rule.name, rule.description, rule.fixHint, [...vs],\n ));\n }\n }\n return groups;\n}\n"]}