@webpieces/ai-hook-rules 0.3.200 → 0.3.202
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/README.md +16 -46
- package/package.json +2 -2
- package/src/adapters/hook-core.js +7 -1
- package/src/adapters/hook-core.js.map +1 -1
- package/src/core/decision-log.d.ts +17 -0
- package/src/core/decision-log.js +105 -0
- package/src/core/decision-log.js.map +1 -0
- package/src/core/main-sync-log.d.ts +15 -0
- package/src/core/main-sync-log.js +90 -0
- package/src/core/main-sync-log.js.map +1 -0
- package/src/core/main-sync-refresh.d.ts +5 -0
- package/src/core/main-sync-refresh.js +20 -1
- package/src/core/main-sync-refresh.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +3 -0
- package/src/core/rules/feature-branch-guard.js +24 -8
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merge-in-progress-guard.js +5 -6
- package/src/core/rules/merge-in-progress-guard.js.map +1 -1
- package/src/core/runner.js +12 -1
- package/src/core/runner.js.map +1 -1
- package/src/core/sync-main.js +13 -4
- package/src/core/sync-main.js.map +1 -1
package/README.md
CHANGED
|
@@ -31,57 +31,27 @@ openclaw plugins enable @webpieces/ai-hook-rules
|
|
|
31
31
|
# Drop webpieces.config.json into any project you want checked
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## The two hooks (Claude Code)
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
**delegates to each repo's own `./node_modules/.bin/wp-ai-hook`** — so you install the
|
|
39
|
-
global hook one time and every webpieces project you have installed gets enforced
|
|
40
|
-
automatically, no per-project Claude Code wiring.
|
|
36
|
+
`wp-setup-ai-hooks` wires two independent `PreToolUse` hooks into the chosen
|
|
37
|
+
`settings.json`, each invoked via the project's `./node_modules/.bin/`:
|
|
41
38
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
./node_modules/.bin/wp-setup-global-ai-hooks
|
|
47
|
-
# Restart your Claude Code session
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
The command is interactive:
|
|
51
|
-
|
|
52
|
-
- **Not yet wired** → prompts `Install global webpieces hook…? [Y/n]`. On `Y` it copies the
|
|
53
|
-
bundled `global-hook.js` → `~/.webpieces/global-hook.js` and appends the `PreToolUse`
|
|
54
|
-
entry to `~/.claude/settings.json`.
|
|
55
|
-
- **Already wired** → prompts `Global hook is already installed. Uninstall? [y/N]`. To
|
|
56
|
-
**refresh to the latest** version, run it twice: once answering `y` (uninstall), then
|
|
57
|
-
again answering `Y` (re-install the current copy).
|
|
58
|
-
|
|
59
|
-
### How delegation works
|
|
39
|
+
- `wp-ai-rules-hook` — matcher `Write|Edit|MultiEdit`. Runs the code-style rules.
|
|
40
|
+
- `wp-ai-guards-hook` — matcher `Write|Edit|MultiEdit|Bash`. Runs the git/PR/branch guards
|
|
41
|
+
(`hookGuards` section): bash git/PR guards on `Bash`, and file guards like
|
|
42
|
+
`feature-branch-guard` on `Write|Edit|MultiEdit`.
|
|
60
43
|
|
|
61
|
-
|
|
44
|
+
For each hook the setup command prompts for a target: project `.claude/settings.json`,
|
|
45
|
+
personal `.claude/settings.local.json`, the global `~/.claude/settings.json` (this-repo-only),
|
|
46
|
+
or **none** (= uninstall). Installing and uninstalling are the same operation — pick a
|
|
47
|
+
location, or pick "none" to remove the hook from every target.
|
|
62
48
|
|
|
63
|
-
|
|
64
|
-
2. Writing `<cwd>/.webpieces/skiphooks` is always allowed.
|
|
65
|
-
3. If `<cwd>/node_modules/.bin/wp-ai-hook` exists → delegate the decision to it.
|
|
66
|
-
4. Otherwise → block and tell the AI to install the hook (or write a skiphooks).
|
|
67
|
-
|
|
68
|
-
Because resolution is cwd-based (not the edited file's path), launch Claude Code from the
|
|
69
|
-
directory whose `node_modules` contains the install. In a monorepo where only a subdir is a
|
|
70
|
-
webpieces project, launch from that subdir to get enforcement there.
|
|
71
|
-
|
|
72
|
-
### Temporarily skipping hooks
|
|
73
|
-
|
|
74
|
-
Drop a `skiphooks` file at the launch dir to bypass enforcement (e.g. for an umbrella repo
|
|
75
|
-
where only some subdirs are webpieces projects):
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
# <cwd>/.webpieces/skiphooks
|
|
79
|
-
{"expires": null, "reason": "why hooks are skipped here"}
|
|
80
|
-
```
|
|
49
|
+
### Disabling enforcement
|
|
81
50
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
51
|
+
There is no runtime escape-hatch file. To stop enforcement, **uninstall the hook**
|
|
52
|
+
(re-run `wp-setup-ai-hooks` and choose "none" for it). Per-rule opt-outs stay in
|
|
53
|
+
`webpieces.config.json` (`mode: "OFF"`, `ignoreModifiedUntilEpoch`, `ignoreRuleWhileOnBranch`)
|
|
54
|
+
and per-line opt-outs use `// webpieces-disable <rule> -- reason`.
|
|
85
55
|
|
|
86
56
|
## Starter rules
|
|
87
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.202",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"directory": "packages/tooling/ai-hook-rules"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@webpieces/rules-config": "0.3.
|
|
38
|
+
"@webpieces/rules-config": "0.3.202"
|
|
39
39
|
},
|
|
40
40
|
"publishConfig": {
|
|
41
41
|
"access": "public"
|
|
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
|
|
|
5
5
|
const path = tslib_1.__importStar(require("path"));
|
|
6
6
|
const runner_1 = require("../core/runner");
|
|
7
7
|
const rejection_log_1 = require("../core/rejection-log");
|
|
8
|
+
const decision_log_1 = require("../core/decision-log");
|
|
8
9
|
const load_config_1 = require("../core/load-config");
|
|
9
10
|
const types_1 = require("../core/types");
|
|
10
11
|
const to_error_1 = require("../core/to-error");
|
|
@@ -83,8 +84,13 @@ function handleFileTool(payload, cwd, mode) {
|
|
|
83
84
|
process.exit(0);
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
|
-
// Always allow edits to webpieces.config.json — it's the fix target when the config is broken
|
|
87
|
+
// Always allow edits to webpieces.config.json — it's the fix target when the config is broken.
|
|
88
|
+
// This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the
|
|
89
|
+
// audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).
|
|
87
90
|
if (path.basename(input.filePath) === load_config_1.CONFIG_FILENAME) {
|
|
91
|
+
if (mode !== 'rules') {
|
|
92
|
+
(0, decision_log_1.logGuardDecision)(cwd, new decision_log_1.GuardDecision('feature-branch-guard', toolKind, input.filePath, (0, decision_log_1.branchForLog)(cwd), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'));
|
|
93
|
+
}
|
|
88
94
|
process.exit(0);
|
|
89
95
|
return;
|
|
90
96
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash } from '../core/runner';\nimport { logRejection } from '../core/rejection-log';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, HookMode } from '../core/types';\nimport { toError } from '../core/to-error';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit. Matcher is Write|Edit|MultiEdit|Bash.\n// - 'all' → both categories, for the combined back-compat `wp-ai-hook` bin.\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { process.exit(0); return; }\n const result = runBash(command, cwd, mode);\n if (!result) { process.exit(0); return; }\n process.stderr.write(result.report);\n process.exit(2);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { process.exit(0); return; }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { process.exit(0); return; }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken\n if (path.basename(input.filePath) === CONFIG_FILENAME) { process.exit(0); return; }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { process.exit(0); return; }\n\n logRejection(toolKind, input, result, cwd);\n process.stderr.write(result.report);\n process.exit(2);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (exit 0). Fails CLOSED (exit 2) on any\n * unexpected crash so a broken hook never silently lets an edit through.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { process.exit(0); return; }\n\n const cwd = process.cwd();\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { process.exit(0); return; }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n if (err instanceof InformAiError) {\n process.stderr.write(error.message + '\\n');\n } else {\n process.stderr.write(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}\\n`);\n }\n process.exit(2);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"hook-core.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/hook-core.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { run, runBash } from '../core/runner';\nimport { logRejection } from '../core/rejection-log';\nimport { logGuardDecision, GuardDecision, branchForLog } from '../core/decision-log';\nimport { CONFIG_FILENAME } from '../core/load-config';\nimport { NormalizedToolInput, NormalizedEdit, ToolKind, InformAiError, HookMode } from '../core/types';\nimport { toError } from '../core/to-error';\n\n// Which category of rules this hook invocation runs. The hook is split into two independently\n// installable PreToolUse hooks; each runs ONE category (the runner filters by it), and both can\n// receive file AND bash payloads:\n// - 'rules' → code-style rules (file/edit scope). Bash payloads pass through (no code rules apply).\n// - 'guards' → hookGuards section: bash git/PR guards on Bash AND file guards (feature-branch-guard)\n// on Write/Edit. Matcher is Write|Edit|MultiEdit|Bash.\n// - 'all' → both categories, for the combined back-compat `wp-ai-hook` bin.\nexport type { HookMode };\n\nconst HANDLED_FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);\n\ninterface ClaudeCodePayload {\n tool_name: string;\n tool_input: ClaudeCodeToolInput;\n}\n\ninterface ClaudeCodeToolInput {\n file_path?: string;\n content?: string;\n old_string?: string;\n new_string?: string;\n edits?: ClaudeCodeEditEntry[];\n command?: string;\n}\n\ninterface ClaudeCodeEditEntry {\n old_string?: string;\n new_string?: string;\n}\n\nfunction readStdin(): Promise<string> {\n return new Promise((resolve: (value: string) => void) => {\n let data = '';\n process.stdin.setEncoding('utf8');\n process.stdin.on('data', (chunk: string) => { data += chunk; });\n process.stdin.on('end', () => resolve(data));\n process.stdin.on('error', () => resolve(''));\n if (process.stdin.isTTY) resolve('');\n });\n}\n\nfunction safeParse(raw: string): ClaudeCodePayload | null {\n if (!raw || raw.trim() === '') return null;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return JSON.parse(raw) as ClaudeCodePayload;\n } catch (err: unknown) {\n const error = toError(err);\n throw new InformAiError(`Malformed hook input from Claude Code stdin: ${error.message}`, { cause: error });\n }\n}\n\nfunction normalizeToolKind(toolName: string): ToolKind | null {\n if (HANDLED_FILE_TOOLS.has(toolName)) return toolName as ToolKind;\n return null;\n}\n\nfunction normalizeToolInput(toolKind: ToolKind, toolInput: ClaudeCodeToolInput): NormalizedToolInput | null {\n const filePath = toolInput.file_path;\n if (!filePath) return null;\n\n if (toolKind === 'Write') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit('', toolInput.content || ''),\n ]);\n }\n if (toolKind === 'Edit') {\n return new NormalizedToolInput(filePath, [\n new NormalizedEdit(toolInput.old_string || '', toolInput.new_string || ''),\n ]);\n }\n if (toolKind === 'MultiEdit') {\n const raw = Array.isArray(toolInput.edits) ? toolInput.edits : [];\n const edits = raw.map((e: ClaudeCodeEditEntry) => new NormalizedEdit(e.old_string || '', e.new_string || ''));\n return new NormalizedToolInput(filePath, edits);\n }\n return null;\n}\n\nfunction handleBash(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const command = payload.tool_input.command;\n if (!command || command.trim() === '') { process.exit(0); return; }\n const result = runBash(command, cwd, mode);\n if (!result) { process.exit(0); return; }\n process.stderr.write(result.report);\n process.exit(2);\n}\n\nfunction handleFileTool(payload: ClaudeCodePayload, cwd: string, mode: HookMode): void {\n const toolKind = normalizeToolKind(payload.tool_name);\n if (!toolKind) { process.exit(0); return; }\n\n const input = normalizeToolInput(toolKind, payload.tool_input);\n if (!input) { process.exit(0); return; }\n\n // Always allow edits to webpieces.config.json — it's the fix target when the config is broken.\n // This exits BEFORE run(), so feature-branch-guard never sees a config edit; record that so the\n // audit trail explains why a config edit on a bad branch was not blocked (see decision-log.ts).\n if (path.basename(input.filePath) === CONFIG_FILENAME) {\n if (mode !== 'rules') {\n logGuardDecision(\n cwd,\n new GuardDecision('feature-branch-guard', toolKind, input.filePath, branchForLog(cwd), 'ALLOW', 'config-bypass (feature-branch-guard skipped)'),\n );\n }\n process.exit(0);\n return;\n }\n\n const result = run(toolKind, input, cwd, mode);\n if (!result) { process.exit(0); return; }\n\n logRejection(toolKind, input, result, cwd);\n process.stderr.write(result.report);\n process.exit(2);\n}\n\n/**\n * Shared entry point for all three Claude Code PreToolUse adapters. `mode` selects which tool kinds\n * to validate; payloads outside the mode's scope pass through (exit 0). Fails CLOSED (exit 2) on any\n * unexpected crash so a broken hook never silently lets an edit through.\n */\nexport async function runMain(mode: HookMode): Promise<void> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const raw = await readStdin();\n const payload = safeParse(raw);\n if (!payload) { process.exit(0); return; }\n\n const cwd = process.cwd();\n\n if (payload.tool_name === 'Bash') {\n // No code-style rule is bash-scoped, so the rules hook ignores Bash.\n if (mode === 'rules') { process.exit(0); return; }\n handleBash(payload, cwd, mode);\n return;\n }\n\n // File payloads run in 'rules' (code-style), 'guards' (file-scoped guards like\n // feature-branch-guard), and 'all'. The runner filters to the right category.\n handleFileTool(payload, cwd, mode);\n } catch (err: unknown) {\n const error = toError(err);\n if (err instanceof InformAiError) {\n process.stderr.write(error.message + '\\n');\n } else {\n process.stderr.write(`[ai-hooks] hook crashed unexpectedly — failing closed: ${error.message}\\n`);\n }\n process.exit(2);\n }\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Verdict = 'ALLOW' | 'BLOCK';
|
|
2
|
+
export declare class GuardDecision {
|
|
3
|
+
rule: string;
|
|
4
|
+
tool: string;
|
|
5
|
+
target: string;
|
|
6
|
+
branch: string;
|
|
7
|
+
verdict: Verdict;
|
|
8
|
+
reason: string;
|
|
9
|
+
constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Append one tab-separated line per decision to `.webpieces/hooks/guard-decisions.log`. `root` is
|
|
13
|
+
* the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or process.cwd() at
|
|
14
|
+
* the pre-load config-bypass site). Swallows all errors — logging must never block or fail a hook.
|
|
15
|
+
*/
|
|
16
|
+
export declare function logGuardDecision(root: string, decision: GuardDecision): void;
|
|
17
|
+
export declare function branchForLog(root: string): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GuardDecision = void 0;
|
|
4
|
+
exports.logGuardDecision = logGuardDecision;
|
|
5
|
+
exports.branchForLog = branchForLog;
|
|
6
|
+
const tslib_1 = require("tslib");
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
9
|
+
const path = tslib_1.__importStar(require("path"));
|
|
10
|
+
const to_error_1 = require("./to-error");
|
|
11
|
+
// Sibling of rejection-log.ts. The rejection log records only BLOCKS (file edits). This records
|
|
12
|
+
// EVERY guard decision — allow, block, config-bypass, and the various fail-open cases — so when a
|
|
13
|
+
// guard silently lets an edit through (e.g. feature-branch-guard failing open on a missing/stale
|
|
14
|
+
// sync cache, or a webpieces.config.json edit skipping the guard entirely) there is an audit trail
|
|
15
|
+
// answering "why didn't it fire?". Writes to the same `.webpieces/hooks` dir as the rejection log.
|
|
16
|
+
const HOOKS_DIR = '.webpieces/hooks';
|
|
17
|
+
const LOG_FILE = 'guard-decisions.log';
|
|
18
|
+
const LOG_FILE_PREV = 'guard-decisions.1.log';
|
|
19
|
+
const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)
|
|
20
|
+
const MAX_TARGET_LEN = 160;
|
|
21
|
+
// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).
|
|
22
|
+
class GuardDecision {
|
|
23
|
+
rule;
|
|
24
|
+
tool;
|
|
25
|
+
target; // file path (file guards) or the bash command (bash guards)
|
|
26
|
+
branch;
|
|
27
|
+
verdict;
|
|
28
|
+
reason;
|
|
29
|
+
constructor(rule, tool, target, branch, verdict, reason) {
|
|
30
|
+
this.rule = rule;
|
|
31
|
+
this.tool = tool;
|
|
32
|
+
this.target = target;
|
|
33
|
+
this.branch = branch;
|
|
34
|
+
this.verdict = verdict;
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
exports.GuardDecision = GuardDecision;
|
|
39
|
+
/**
|
|
40
|
+
* Append one tab-separated line per decision to `.webpieces/hooks/guard-decisions.log`. `root` is
|
|
41
|
+
* the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or process.cwd() at
|
|
42
|
+
* the pre-load config-bypass site). Swallows all errors — logging must never block or fail a hook.
|
|
43
|
+
*/
|
|
44
|
+
function logGuardDecision(root, decision) {
|
|
45
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
46
|
+
try {
|
|
47
|
+
const timestamp = new Date().toISOString();
|
|
48
|
+
const hooksDir = path.join(root, HOOKS_DIR);
|
|
49
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
50
|
+
const logPath = path.join(hooksDir, LOG_FILE);
|
|
51
|
+
rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));
|
|
52
|
+
const line = [
|
|
53
|
+
`[${timestamp}]`,
|
|
54
|
+
decision.verdict,
|
|
55
|
+
decision.tool,
|
|
56
|
+
oneLine(decision.target),
|
|
57
|
+
decision.branch,
|
|
58
|
+
decision.rule,
|
|
59
|
+
oneLine(decision.reason),
|
|
60
|
+
].join('\t') + '\n';
|
|
61
|
+
fs.appendFileSync(logPath, line);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const error = (0, to_error_1.toError)(err);
|
|
65
|
+
void error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git
|
|
69
|
+
// repo) — this is for display only, never for a control decision.
|
|
70
|
+
function branchForLog(root) {
|
|
71
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
72
|
+
try {
|
|
73
|
+
return (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
|
|
74
|
+
cwd: root,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
77
|
+
}).trim() || 'unknown';
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const error = (0, to_error_1.toError)(err);
|
|
81
|
+
void error;
|
|
82
|
+
return 'unknown';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Collapse newlines/tabs and cap length so one decision is always one log line.
|
|
86
|
+
function oneLine(value) {
|
|
87
|
+
const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
|
|
88
|
+
return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';
|
|
89
|
+
}
|
|
90
|
+
function rotateLogFile(logPath, prevPath) {
|
|
91
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(logPath);
|
|
94
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
95
|
+
if (fs.existsSync(prevPath))
|
|
96
|
+
fs.unlinkSync(prevPath);
|
|
97
|
+
fs.renameSync(logPath, prevPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const error = (0, to_error_1.toError)(err);
|
|
102
|
+
void error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=decision-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"decision-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/decision-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from './to-error';\n\n// Sibling of rejection-log.ts. The rejection log records only BLOCKS (file edits). This records\n// EVERY guard decision — allow, block, config-bypass, and the various fail-open cases — so when a\n// guard silently lets an edit through (e.g. feature-branch-guard failing open on a missing/stale\n// sync cache, or a webpieces.config.json edit skipping the guard entirely) there is an audit trail\n// answering \"why didn't it fire?\". Writes to the same `.webpieces/hooks` dir as the rejection log.\nconst HOOKS_DIR = '.webpieces/hooks';\nconst LOG_FILE = 'guard-decisions.log';\nconst LOG_FILE_PREV = 'guard-decisions.1.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors rejection-log)\nconst MAX_TARGET_LEN = 160;\n\nexport type Verdict = 'ALLOW' | 'BLOCK';\n\n// Data-only record of one guard decision (per CLAUDE.md: classes for data, not object literals).\nexport class GuardDecision {\n rule: string;\n tool: string;\n target: string; // file path (file guards) or the bash command (bash guards)\n branch: string;\n verdict: Verdict;\n reason: string;\n\n constructor(rule: string, tool: string, target: string, branch: string, verdict: Verdict, reason: string) {\n this.rule = rule;\n this.tool = tool;\n this.target = target;\n this.branch = branch;\n this.verdict = verdict;\n this.reason = reason;\n }\n}\n\n/**\n * Append one tab-separated line per decision to `.webpieces/hooks/guard-decisions.log`. `root` is\n * the repo/workspace root that holds `.webpieces` (callers pass workspaceRoot, or process.cwd() at\n * the pre-load config-bypass site). Swallows all errors — logging must never block or fail a hook.\n */\nexport function logGuardDecision(root: string, decision: GuardDecision): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n const hooksDir = path.join(root, HOOKS_DIR);\n fs.mkdirSync(hooksDir, { recursive: true });\n\n const logPath = path.join(hooksDir, LOG_FILE);\n rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));\n\n const line = [\n `[${timestamp}]`,\n decision.verdict,\n decision.tool,\n oneLine(decision.target),\n decision.branch,\n decision.rule,\n oneLine(decision.reason),\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n// Best-effort current branch for the log line. Returns 'unknown' on any failure (e.g. not a git\n// repo) — this is for display only, never for a control decision.\nexport function branchForLog(root: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: root,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim() || 'unknown';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return 'unknown';\n }\n}\n\n// Collapse newlines/tabs and cap length so one decision is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_TARGET_LEN ? flat : flat.slice(0, MAX_TARGET_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SyncPhase = 'SPAWN_ATTEMPT' | 'START' | 'SKIP_INPROGRESS' | 'FINISH' | 'ERROR';
|
|
2
|
+
export declare class SyncLogEvent {
|
|
3
|
+
phase: SyncPhase;
|
|
4
|
+
pid: number;
|
|
5
|
+
branchArg: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Append one tab-separated line per refresher event to `.webpieces/hooks/main-sync.log`. `root` is
|
|
11
|
+
* the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
|
|
12
|
+
* the refresher (or the hook that spawns it).
|
|
13
|
+
*/
|
|
14
|
+
export declare function logSyncEvent(root: string, event: SyncLogEvent): void;
|
|
15
|
+
export declare function syncStderrLogPath(root: string): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SyncLogEvent = void 0;
|
|
4
|
+
exports.logSyncEvent = logSyncEvent;
|
|
5
|
+
exports.syncStderrLogPath = syncStderrLogPath;
|
|
6
|
+
const tslib_1 = require("tslib");
|
|
7
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
8
|
+
const path = tslib_1.__importStar(require("path"));
|
|
9
|
+
const to_error_1 = require("./to-error");
|
|
10
|
+
// Observability for the detached background refresher (sync-main.ts). The refresher runs AFTER the
|
|
11
|
+
// spawning hook has exited, with stdio discarded, so when it fails to update main-sync-status.json
|
|
12
|
+
// there is normally no trace at all. This log captures its lifecycle — SPAWN_ATTEMPT (parent side),
|
|
13
|
+
// then START / SKIP_INPROGRESS / FINISH / ERROR (child side) — so we can tell whether the detached
|
|
14
|
+
// child never launched, was killed mid-run (START with no FINISH), or threw. Modeled on
|
|
15
|
+
// decision-log.ts: same .webpieces/hooks dir, 512KB size rotation, toError-compliant catches.
|
|
16
|
+
const HOOKS_DIR = '.webpieces/hooks';
|
|
17
|
+
const LOG_FILE = 'main-sync.log';
|
|
18
|
+
const LOG_FILE_PREV = 'main-sync.1.log';
|
|
19
|
+
const STDERR_FILE = 'main-sync.stderr.log';
|
|
20
|
+
const MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors decision-log)
|
|
21
|
+
const MAX_DETAIL_LEN = 300;
|
|
22
|
+
// Data-only record of one refresher lifecycle event (per CLAUDE.md: classes for data).
|
|
23
|
+
class SyncLogEvent {
|
|
24
|
+
phase;
|
|
25
|
+
pid;
|
|
26
|
+
branchArg;
|
|
27
|
+
detail;
|
|
28
|
+
constructor(phase, pid, branchArg, detail) {
|
|
29
|
+
this.phase = phase;
|
|
30
|
+
this.pid = pid;
|
|
31
|
+
this.branchArg = branchArg;
|
|
32
|
+
this.detail = detail;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
exports.SyncLogEvent = SyncLogEvent;
|
|
36
|
+
/**
|
|
37
|
+
* Append one tab-separated line per refresher event to `.webpieces/hooks/main-sync.log`. `root` is
|
|
38
|
+
* the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail
|
|
39
|
+
* the refresher (or the hook that spawns it).
|
|
40
|
+
*/
|
|
41
|
+
function logSyncEvent(root, event) {
|
|
42
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
43
|
+
try {
|
|
44
|
+
const timestamp = new Date().toISOString();
|
|
45
|
+
const hooksDir = path.join(root, HOOKS_DIR);
|
|
46
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
47
|
+
const logPath = path.join(hooksDir, LOG_FILE);
|
|
48
|
+
rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));
|
|
49
|
+
const line = [
|
|
50
|
+
`[${timestamp}]`,
|
|
51
|
+
event.phase,
|
|
52
|
+
`pid=${String(event.pid)}`,
|
|
53
|
+
event.branchArg,
|
|
54
|
+
oneLine(event.detail),
|
|
55
|
+
].join('\t') + '\n';
|
|
56
|
+
fs.appendFileSync(logPath, line);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const error = (0, to_error_1.toError)(err);
|
|
60
|
+
void error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Absolute path the detached child's stdout/stderr are redirected to (opened with fs.openSync(p,'a')
|
|
64
|
+
// by the spawner), so even a crash BEFORE our own logging runs — e.g. a module-load failure — is
|
|
65
|
+
// captured instead of vanishing into /dev/null. Callers must ensure the hooks dir exists first
|
|
66
|
+
// (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).
|
|
67
|
+
function syncStderrLogPath(root) {
|
|
68
|
+
return path.join(root, HOOKS_DIR, STDERR_FILE);
|
|
69
|
+
}
|
|
70
|
+
// Collapse newlines/tabs and cap length so one event is always one log line.
|
|
71
|
+
function oneLine(value) {
|
|
72
|
+
const flat = value.replace(/[\t\r\n]+/g, ' ').trim();
|
|
73
|
+
return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';
|
|
74
|
+
}
|
|
75
|
+
function rotateLogFile(logPath, prevPath) {
|
|
76
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
77
|
+
try {
|
|
78
|
+
const stat = fs.statSync(logPath);
|
|
79
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
80
|
+
if (fs.existsSync(prevPath))
|
|
81
|
+
fs.unlinkSync(prevPath);
|
|
82
|
+
fs.renameSync(logPath, prevPath);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
const error = (0, to_error_1.toError)(err);
|
|
87
|
+
void error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=main-sync-log.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-sync-log.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-log.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\n\nimport { toError } from './to-error';\n\n// Observability for the detached background refresher (sync-main.ts). The refresher runs AFTER the\n// spawning hook has exited, with stdio discarded, so when it fails to update main-sync-status.json\n// there is normally no trace at all. This log captures its lifecycle — SPAWN_ATTEMPT (parent side),\n// then START / SKIP_INPROGRESS / FINISH / ERROR (child side) — so we can tell whether the detached\n// child never launched, was killed mid-run (START with no FINISH), or threw. Modeled on\n// decision-log.ts: same .webpieces/hooks dir, 512KB size rotation, toError-compliant catches.\nconst HOOKS_DIR = '.webpieces/hooks';\nconst LOG_FILE = 'main-sync.log';\nconst LOG_FILE_PREV = 'main-sync.1.log';\nconst STDERR_FILE = 'main-sync.stderr.log';\nconst MAX_LOG_BYTES = 512 * 1024; // 512 KB — rotate when exceeded (mirrors decision-log)\nconst MAX_DETAIL_LEN = 300;\n\nexport type SyncPhase = 'SPAWN_ATTEMPT' | 'START' | 'SKIP_INPROGRESS' | 'FINISH' | 'ERROR';\n\n// Data-only record of one refresher lifecycle event (per CLAUDE.md: classes for data).\nexport class SyncLogEvent {\n phase: SyncPhase;\n pid: number;\n branchArg: string;\n detail: string;\n\n constructor(phase: SyncPhase, pid: number, branchArg: string, detail: string) {\n this.phase = phase;\n this.pid = pid;\n this.branchArg = branchArg;\n this.detail = detail;\n }\n}\n\n/**\n * Append one tab-separated line per refresher event to `.webpieces/hooks/main-sync.log`. `root` is\n * the workspace root holding `.webpieces`. Swallows all errors — logging must never block or fail\n * the refresher (or the hook that spawns it).\n */\nexport function logSyncEvent(root: string, event: SyncLogEvent): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const timestamp = new Date().toISOString();\n const hooksDir = path.join(root, HOOKS_DIR);\n fs.mkdirSync(hooksDir, { recursive: true });\n\n const logPath = path.join(hooksDir, LOG_FILE);\n rotateLogFile(logPath, path.join(hooksDir, LOG_FILE_PREV));\n\n const line = [\n `[${timestamp}]`,\n event.phase,\n `pid=${String(event.pid)}`,\n event.branchArg,\n oneLine(event.detail),\n ].join('\\t') + '\\n';\n fs.appendFileSync(logPath, line);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n\n// Absolute path the detached child's stdout/stderr are redirected to (opened with fs.openSync(p,'a')\n// by the spawner), so even a crash BEFORE our own logging runs — e.g. a module-load failure — is\n// captured instead of vanishing into /dev/null. Callers must ensure the hooks dir exists first\n// (logSyncEvent's mkdir, called for SPAWN_ATTEMPT, does that).\nexport function syncStderrLogPath(root: string): string {\n return path.join(root, HOOKS_DIR, STDERR_FILE);\n}\n\n// Collapse newlines/tabs and cap length so one event is always one log line.\nfunction oneLine(value: string): string {\n const flat = value.replace(/[\\t\\r\\n]+/g, ' ').trim();\n return flat.length <= MAX_DETAIL_LEN ? flat : flat.slice(0, MAX_DETAIL_LEN) + '…';\n}\n\nfunction rotateLogFile(logPath: string, prevPath: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const stat = fs.statSync(logPath);\n if (stat.size > MAX_LOG_BYTES) {\n if (fs.existsSync(prevPath)) fs.unlinkSync(prevPath);\n fs.renameSync(logPath, prevPath);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n}\n"]}
|
|
@@ -3,5 +3,10 @@
|
|
|
3
3
|
* not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
|
|
4
4
|
* merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on
|
|
5
5
|
* the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
|
|
6
|
+
*
|
|
7
|
+
* Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
|
|
8
|
+
* `.webpieces/hooks/main-sync.log`. The child's stdout/stderr are redirected to a sibling file (not
|
|
9
|
+
* /dev/null) so a crash before the child's own logging is still captured. If main-sync.log shows
|
|
10
|
+
* SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
|
|
6
11
|
*/
|
|
7
12
|
export declare function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes?: number): void;
|
|
@@ -3,24 +3,43 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.triggerMainSyncRefresh = triggerMainSyncRefresh;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
7
|
const path = tslib_1.__importStar(require("path"));
|
|
7
8
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
9
|
const to_error_1 = require("./to-error");
|
|
10
|
+
const main_sync_log_1 = require("./main-sync-log");
|
|
9
11
|
/**
|
|
10
12
|
* Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,
|
|
11
13
|
* not a bin). The child outlives this hook process (`detached` + `unref`), does the slow
|
|
12
14
|
* merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on
|
|
13
15
|
* the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.
|
|
16
|
+
*
|
|
17
|
+
* Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to
|
|
18
|
+
* `.webpieces/hooks/main-sync.log`. The child's stdout/stderr are redirected to a sibling file (not
|
|
19
|
+
* /dev/null) so a crash before the child's own logging is still captured. If main-sync.log shows
|
|
20
|
+
* SPAWN_ATTEMPT but never START, the detached child was killed before it ran.
|
|
14
21
|
*/
|
|
15
22
|
function triggerMainSyncRefresh(workspaceRoot, hangTimeoutMinutes = rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES) {
|
|
16
23
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
17
24
|
try {
|
|
18
25
|
const refresher = path.join(__dirname, 'sync-main.js');
|
|
26
|
+
// SPAWN_ATTEMPT first — this also creates .webpieces/hooks so the stderr fd below can open.
|
|
27
|
+
(0, main_sync_log_1.logSyncEvent)(workspaceRoot, new main_sync_log_1.SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));
|
|
28
|
+
// Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash
|
|
29
|
+
// before the child's own logging — e.g. a module-load failure — is still captured.
|
|
30
|
+
const errFd = fs.openSync((0, main_sync_log_1.syncStderrLogPath)(workspaceRoot), 'a');
|
|
19
31
|
const child = (0, child_process_1.spawn)(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {
|
|
20
32
|
detached: true,
|
|
21
|
-
stdio: 'ignore',
|
|
33
|
+
stdio: ['ignore', errFd, errFd],
|
|
34
|
+
});
|
|
35
|
+
// spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit
|
|
36
|
+
// before this handler runs, but on POSIX a successful exec has already happened by now.
|
|
37
|
+
child.once('error', (err) => {
|
|
38
|
+
(0, main_sync_log_1.logSyncEvent)(workspaceRoot, new main_sync_log_1.SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));
|
|
22
39
|
});
|
|
23
40
|
child.unref();
|
|
41
|
+
// The child has its own dup'd copy of the fd after spawn; close the parent's copy.
|
|
42
|
+
fs.closeSync(errFd);
|
|
24
43
|
}
|
|
25
44
|
catch (err) {
|
|
26
45
|
const error = (0, to_error_1.toError)(err);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"main-sync-refresh.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/main-sync-refresh.ts"],"names":[],"mappings":";;AAoBA,wDA2BC;;AA/CD,iDAAsC;AACtC,+CAAyB;AACzB,mDAA6B;AAE7B,0DAAuE;AAEvE,yCAAqC;AACrC,mDAAgF;AAEhF;;;;;;;;;;GAUG;AACH,SAAgB,sBAAsB,CAAC,aAAqB,EAAE,qBAA6B,2CAA4B;IACnH,8DAA8D;IAC9D,IAAI,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;QACvD,4FAA4F;QAC5F,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,eAAe,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,aAAa,SAAS,EAAE,CAAC,CAAC,CAAC;QAE3G,6FAA6F;QAC7F,mFAAmF;QACnF,MAAM,KAAK,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAA,iCAAiB,EAAC,aAAa,CAAC,EAAE,GAAG,CAAC,CAAC;QACjE,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,CAAC,kBAAkB,CAAC,CAAC,EAAE;YAC1F,QAAQ,EAAE,IAAI;YACd,KAAK,EAAE,CAAC,QAAQ,EAAE,KAAK,EAAE,KAAK,CAAC;SAClC,CAAC,CAAC;QACH,8FAA8F;QAC9F,wFAAwF;QACxF,KAAK,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,GAAU,EAAQ,EAAE;YACrC,IAAA,4BAAY,EAAC,aAAa,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,iBAAiB,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACjH,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,KAAK,EAAE,CAAC;QACd,mFAAmF;QACnF,EAAE,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC;IACxB,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,KAAK,KAAK,CAAC;QACX,0EAA0E;IAC9E,CAAC;AACL,CAAC","sourcesContent":["import { spawn } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport { DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent, syncStderrLogPath } from './main-sync-log';\n\n/**\n * Fire-and-forget spawn of the detached refresher (sync-main.js in this same dir — spawned by path,\n * not a bin). The child outlives this hook process (`detached` + `unref`), does the slow\n * merged-PR/fetch/merge-base/overlap work, and writes the cache the feature-branch-guard reads on\n * the NEXT call. This is the first detached spawn in the codebase — every existing hook is synchronous.\n *\n * Observability: we log SPAWN_ATTEMPT here and the child logs START/FINISH/ERROR, all to\n * `.webpieces/hooks/main-sync.log`. The child's stdout/stderr are redirected to a sibling file (not\n * /dev/null) so a crash before the child's own logging is still captured. If main-sync.log shows\n * SPAWN_ATTEMPT but never START, the detached child was killed before it ran.\n */\nexport function triggerMainSyncRefresh(workspaceRoot: string, hangTimeoutMinutes: number = DEFAULT_HANG_TIMEOUT_MINUTES): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const refresher = path.join(__dirname, 'sync-main.js');\n // SPAWN_ATTEMPT first — this also creates .webpieces/hooks so the stderr fd below can open.\n logSyncEvent(workspaceRoot, new SyncLogEvent('SPAWN_ATTEMPT', process.pid, '-', `refresher=${refresher}`));\n\n // Redirect the detached child's stdout+stderr to a file (not /dev/null) so an uncaught crash\n // before the child's own logging — e.g. a module-load failure — is still captured.\n const errFd = fs.openSync(syncStderrLogPath(workspaceRoot), 'a');\n const child = spawn(process.execPath, [refresher, workspaceRoot, String(hangTimeoutMinutes)], {\n detached: true,\n stdio: ['ignore', errFd, errFd],\n });\n // spawn errors (e.g. ENOENT) arrive asynchronously; record one if it fires. The hook may exit\n // before this handler runs, but on POSIX a successful exec has already happened by now.\n child.once('error', (err: Error): void => {\n logSyncEvent(workspaceRoot, new SyncLogEvent('ERROR', child.pid ?? -1, '-', `spawn failed: ${err.message}`));\n });\n child.unref();\n // The child has its own dup'd copy of the fd after spawn; close the parent's copy.\n fs.closeSync(errFd);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Spawning the background refresh must never block or fail the tool call.\n }\n}\n"]}
|
|
@@ -24,6 +24,9 @@ export declare class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGu
|
|
|
24
24
|
};
|
|
25
25
|
readonly fixHint: string[];
|
|
26
26
|
check(ctx: FileContext): readonly Violation[];
|
|
27
|
+
private allow;
|
|
28
|
+
private block;
|
|
29
|
+
private logDecision;
|
|
27
30
|
private currentBranch;
|
|
28
31
|
private onMainMessage;
|
|
29
32
|
private alreadyMergedMessage;
|
|
@@ -7,6 +7,7 @@ const types_1 = require("../types");
|
|
|
7
7
|
const rule_base_1 = require("../rule-base");
|
|
8
8
|
const to_error_1 = require("../to-error");
|
|
9
9
|
const main_sync_refresh_1 = require("../main-sync-refresh");
|
|
10
|
+
const decision_log_1 = require("../decision-log");
|
|
10
11
|
/**
|
|
11
12
|
* Comprehensive "are you on a proper feature branch?" guard — the single rule that blocks edits when
|
|
12
13
|
* the branch isn't a healthy place to work. Four states, in priority order:
|
|
@@ -35,15 +36,16 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
35
36
|
'Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.',
|
|
36
37
|
];
|
|
37
38
|
check(ctx) {
|
|
38
|
-
// Only files inside the workspace root.
|
|
39
|
+
// Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.
|
|
39
40
|
if (ctx.relativePath.startsWith('..'))
|
|
40
41
|
return [];
|
|
41
42
|
const branch = this.currentBranch(ctx.workspaceRoot);
|
|
43
|
+
// Can't determine branch (e.g. not a git repo) → don't block. Fail-open.
|
|
42
44
|
if (branch === null)
|
|
43
|
-
return
|
|
45
|
+
return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');
|
|
44
46
|
// State 1: on main — synchronous, no cache needed.
|
|
45
47
|
if (branch === 'main') {
|
|
46
|
-
return
|
|
48
|
+
return this.block(ctx, branch, 'on-main', this.onMainMessage());
|
|
47
49
|
}
|
|
48
50
|
// Keep the cache warm for the next call. Detached; never blocks this edit.
|
|
49
51
|
(0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES);
|
|
@@ -51,26 +53,40 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
51
53
|
// No cache yet (first edit of the session) → allow; the refresh we just spawned populates it
|
|
52
54
|
// for the next call. Fail-open: never block on missing data.
|
|
53
55
|
if (status === null)
|
|
54
|
-
return
|
|
56
|
+
return this.allow(ctx, branch, 'no-sync-cache (fail-open)');
|
|
55
57
|
// Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just
|
|
56
58
|
// switched branches and the refresh for this one hasn't landed yet). Never block on another
|
|
57
59
|
// branch's signals — fail open; the refresh we just spawned rewrites it for this branch.
|
|
58
60
|
if (status.branch !== branch)
|
|
59
|
-
return
|
|
61
|
+
return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)');
|
|
60
62
|
// State 2: this feature branch was already merged into main.
|
|
61
63
|
if (status.branchAlreadyMerged) {
|
|
62
|
-
|
|
64
|
+
const pr = status.mergedPr !== '' ? status.mergedPr : '?';
|
|
65
|
+
return this.block(ctx, branch, `already-merged PR#${pr}`, this.alreadyMergedMessage(branch, status.mergedPr));
|
|
63
66
|
}
|
|
64
67
|
// State 3: no fork point — main was merged into the branch.
|
|
65
68
|
if (!status.hasForkPoint) {
|
|
66
|
-
return
|
|
69
|
+
return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(branch));
|
|
67
70
|
}
|
|
68
71
|
// State 4: origin/main moved and touches files you changed.
|
|
69
72
|
if (status.conflict) {
|
|
70
|
-
return
|
|
73
|
+
return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles));
|
|
71
74
|
}
|
|
75
|
+
return this.allow(ctx, branch, 'clean-feature-branch');
|
|
76
|
+
}
|
|
77
|
+
// Log + return for the allow path. Centralizes the decision-log call so every exit of check()
|
|
78
|
+
// is recorded with its reason (this is the audit trail for "why didn't the guard fire?").
|
|
79
|
+
allow(ctx, branch, reason) {
|
|
80
|
+
this.logDecision(ctx, branch, 'ALLOW', reason);
|
|
72
81
|
return [];
|
|
73
82
|
}
|
|
83
|
+
block(ctx, branch, reason, message) {
|
|
84
|
+
this.logDecision(ctx, branch, 'BLOCK', reason);
|
|
85
|
+
return [new types_1.Violation(1, ctx.relativePath, message)];
|
|
86
|
+
}
|
|
87
|
+
logDecision(ctx, branch, verdict, reason) {
|
|
88
|
+
(0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason));
|
|
89
|
+
}
|
|
74
90
|
currentBranch(workspaceRoot) {
|
|
75
91
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
76
92
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = [\n 'You must be on a clean, up-to-date feature branch to edit code.',\n 'On main → create a feature branch. Already merged → branch off fresh main.',\n 'main moved/conflicts → `pnpm wp-start-upsert-pr` (merge), `/wp-merge` (resolve), `pnpm wp-finish-upsert-pr`.',\n 'Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.',\n ];\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return []; // can't determine branch (e.g. not a git repo) → don't block\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return [new V(1, ctx.relativePath, this.onMainMessage())];\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return [];\n\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return [];\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n return [new V(1, ctx.relativePath, this.alreadyMergedMessage(branch, status.mergedPr))];\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return [new V(1, ctx.relativePath, this.noForkPointMessage(branch))];\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return [new V(1, ctx.relativePath, this.conflictMessage(status.conflictFiles))];\n }\n return [];\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n private alreadyMergedMessage(branch: string, mergedPr: string): string {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n return [\n `This feature branch \"${branch}\" was already merged into main${pr}.`,\n 'Your work is in main — do NOT keep editing this stale branch (you will reconflict with main).',\n 'Start fresh:',\n ' 1. git checkout main',\n ' 2. git pull',\n ' 3. git checkout -b <new-feature-branch>',\n ].join('\\n');\n }\n\n private conflictMessage(conflictFiles: readonly string[]): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n return [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n 'Merge main in before editing further:',\n ' 1. pnpm wp-start-upsert-pr ← merges main, writes 3-point conflict context',\n ' 2. /wp-merge ← resolve each conflicted file',\n ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',\n ].join('\\n');\n }\n\n private noForkPointMessage(branch: string): string {\n return [\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(branch),\n ].join('\\n');\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = [\n 'You must be on a clean, up-to-date feature branch to edit code.',\n 'On main → create a feature branch. Already merged → branch off fresh main.',\n 'main moved/conflicts → `pnpm wp-start-upsert-pr` (merge), `/wp-merge` (resolve), `pnpm wp-finish-upsert-pr`.',\n 'Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.',\n ];\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine branch (e.g. not a git repo) → don't block. Fail-open.\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return this.block(ctx, branch, 'on-main', this.onMainMessage());\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)');\n\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)');\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(ctx, branch, `already-merged PR#${pr}`, this.alreadyMergedMessage(branch, status.mergedPr));\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(branch));\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles));\n }\n return this.allow(ctx, branch, 'clean-feature-branch');\n }\n\n // Log + return for the allow path. Centralizes the decision-log call so every exit of check()\n // is recorded with its reason (this is the audit trail for \"why didn't the guard fire?\").\n private allow(ctx: FileContext, branch: string | null, reason: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n private alreadyMergedMessage(branch: string, mergedPr: string): string {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n return [\n `This feature branch \"${branch}\" was already merged into main${pr}.`,\n 'Your work is in main — do NOT keep editing this stale branch (you will reconflict with main).',\n 'Start fresh:',\n ' 1. git checkout main',\n ' 2. git pull',\n ' 3. git checkout -b <new-feature-branch>',\n ].join('\\n');\n }\n\n private conflictMessage(conflictFiles: readonly string[]): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n return [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n 'Merge main in before editing further:',\n ' 1. pnpm wp-start-upsert-pr ← merges main, writes 3-point conflict context',\n ' 2. /wp-merge ← resolve each conflicted file',\n ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',\n ].join('\\n');\n }\n\n private noForkPointMessage(branch: string): string {\n return [\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(branch),\n ].join('\\n');\n }\n}\n"]}
|
|
@@ -20,13 +20,12 @@ function fixHintFor(mergeCompleteCommand) {
|
|
|
20
20
|
// Returns the path of the first UNVALIDATED merge marker found, or null. We detect validation
|
|
21
21
|
// by a raw substring (no JSON.parse) so a malformed marker can never crash the guard.
|
|
22
22
|
function findUnvalidatedMerge(workspaceRoot) {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Per-feature merge dirs live under `.webpieces/merge-info/<feature>/`; scan that home's subdirs.
|
|
24
|
+
const mergeInfoDir = path.join(workspaceRoot, rules_config_1.WEBPIECES_TMP_DIR, rules_config_1.MERGE_INFO_DIR);
|
|
25
|
+
if (!fs.existsSync(mergeInfoDir))
|
|
25
26
|
return null;
|
|
26
|
-
for (const entry of fs.readdirSync(
|
|
27
|
-
|
|
28
|
-
continue;
|
|
29
|
-
const marker = path.join(tmpDir, entry, rules_config_1.MERGE_IN_PROGRESS_FILE);
|
|
27
|
+
for (const entry of fs.readdirSync(mergeInfoDir)) {
|
|
28
|
+
const marker = path.join(mergeInfoDir, entry, rules_config_1.MERGE_IN_PROGRESS_FILE);
|
|
30
29
|
if (!fs.existsSync(marker))
|
|
31
30
|
continue;
|
|
32
31
|
const raw = fs.readFileSync(marker, 'utf8');
|
|
@@ -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,
|
|
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;AAE5C,MAAM,8BAA8B,GAAG,0BAA0B,CAAC;AAElE,SAAS,UAAU,CAAC,oBAA4B;IAC5C,OAAO;QACH,uDAAuD;QACvD,gEAAgE;QAChE,KAAK,oBAAoB,EAAE;QAC3B,8FAA8F;QAC9F,yEAAyE;KAC5E,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,KAAwB,OAAO,UAAU,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC;IAElF,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;gBACI,yEAAyE;gBACzE,WAAW,MAAM,EAAE;gBACnB,0CAA0C,IAAI,CAAC,oBAAoB,EAAE;aACxE,CAAC,IAAI,CAAC,IAAI,CAAC,CACf,CAAC,CAAC;IACP,CAAC;CACJ;AAzBD,4DAyBC","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';\n\nconst DEFAULT_MERGE_COMPLETE_COMMAND = 'pnpm wp-finish-upsert-pr';\n\nfunction fixHintFor(mergeCompleteCommand: string): readonly string[] {\n return [\n 'A 3-point merge is in progress and not yet validated.',\n 'Resolve the remaining conflicts in the working tree, then run:',\n ` ${mergeCompleteCommand}`,\n 'That scans for leftover conflict markers and runs the build; only when green does it commit,',\n 'unblock commit/push/PR, render the dashboard, and create/update the PR.',\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(): readonly string[] { 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 [\n 'A merge is in progress and not yet validated — this command is blocked.',\n `Marker: ${marker}`,\n `Finish resolving conflicts, then run: ${this.mergeCompleteCommand}`,\n ].join('\\n'),\n )];\n }\n}\n"]}
|
package/src/core/runner.js
CHANGED
|
@@ -8,6 +8,7 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
8
8
|
const build_context_1 = require("./build-context");
|
|
9
9
|
const load_rules_1 = require("./load-rules");
|
|
10
10
|
const main_sync_refresh_1 = require("./main-sync-refresh");
|
|
11
|
+
const decision_log_1 = require("./decision-log");
|
|
11
12
|
const to_error_1 = require("./to-error");
|
|
12
13
|
const report_1 = require("./report");
|
|
13
14
|
const types_1 = require("./types");
|
|
@@ -82,8 +83,18 @@ function runBashInternal(command, cwd, mode) {
|
|
|
82
83
|
maybeRefreshMainSync(rules, workspaceRoot);
|
|
83
84
|
const ctx = (0, build_context_1.buildBashContext)(command, workspaceRoot);
|
|
84
85
|
const groups = runBashRules(rules, ctx);
|
|
85
|
-
if (groups.length === 0)
|
|
86
|
+
if (groups.length === 0) {
|
|
87
|
+
// Record the ALLOW only for git/gh commands — the operations the bash guards actually reason
|
|
88
|
+
// about (branch create, commit, push, merge, PR). Skipping ls/cat/grep keeps the audit log
|
|
89
|
+
// focused (the whole point of the log is "why did/didn't a guard fire?"). Blocks are always
|
|
90
|
+
// logged below.
|
|
91
|
+
if (/\b(?:git|gh)\b/.test(command)) {
|
|
92
|
+
(0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision('-', 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'ALLOW', 'no bash-guard block'));
|
|
93
|
+
}
|
|
86
94
|
return null;
|
|
95
|
+
}
|
|
96
|
+
const ruleNames = groups.map((g) => g.ruleName).join(',');
|
|
97
|
+
(0, decision_log_1.logGuardDecision)(workspaceRoot, new decision_log_1.GuardDecision(ruleNames, 'Bash', command, (0, decision_log_1.branchForLog)(workspaceRoot), 'BLOCK', 'bash-guard block'));
|
|
87
98
|
const report = (0, report_1.formatReport)('<bash>', groups);
|
|
88
99
|
return new types_1.BlockedResult(report);
|
|
89
100
|
}
|
package/src/core/runner.js.map
CHANGED
|
@@ -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';\n\nimport { loadAndValidate, WebpiecesRulesConfig, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { loadRules, globMatches } from './load-rules';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup,\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// combined back-compat bin). isHookGuard is the shared classifier in @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// 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 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 const contexts = buildContexts(toolKind, input, workspaceRoot);\n const relativePath = path.relative(workspaceRoot, input.filePath);\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\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): 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 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 // 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) return null;\n\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, 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; crash → visible violation so AI sees it, not silent []\nfunction 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 return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\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';\n\nimport { loadAndValidate, WebpiecesRulesConfig, isHookGuard, DEFAULT_HANG_TIMEOUT_MINUTES } from '@webpieces/rules-config';\n\nimport { buildContexts, buildBashContext } from './build-context';\nimport { loadRules, globMatches } from './load-rules';\nimport { triggerMainSyncRefresh } from './main-sync-refresh';\nimport { logGuardDecision, GuardDecision, branchForLog } from './decision-log';\nimport { toError } from './to-error';\nimport { formatReport } from './report';\nimport {\n ToolKind, NormalizedToolInput, BlockedResult, HookMode,\n Rule, Violation, RuleGroup,\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// combined back-compat bin). isHookGuard is the shared classifier in @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// 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 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 const contexts = buildContexts(toolKind, input, workspaceRoot);\n const relativePath = path.relative(workspaceRoot, input.filePath);\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\nfunction runBashInternal(command: string, cwd: string, mode: HookMode): 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 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 // 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, 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; crash → visible violation so AI sees it, not silent []\nfunction 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 return [new Violation(0, '', `Rule '${rule.name}' crashed: ${error.message}`)];\n }\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"]}
|
package/src/core/sync-main.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.main = main;
|
|
4
4
|
const rules_config_1 = require("@webpieces/rules-config");
|
|
5
5
|
const to_error_1 = require("./to-error");
|
|
6
|
+
const main_sync_log_1 = require("./main-sync-log");
|
|
6
7
|
/**
|
|
7
8
|
* The detached, fire-and-forget refresher spawned (by file path, not a bin) from
|
|
8
9
|
* main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +
|
|
@@ -19,16 +20,24 @@ const to_error_1 = require("./to-error");
|
|
|
19
20
|
function main() {
|
|
20
21
|
const repoRoot = process.argv[2] ?? process.cwd();
|
|
21
22
|
const hangTimeoutMinutes = Number(process.argv[3]) || rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES;
|
|
23
|
+
const startedMs = Date.now();
|
|
24
|
+
// First action: prove the detached child actually started. If main-sync.log has no START line
|
|
25
|
+
// for a spawn, the child never launched (or died before this point).
|
|
26
|
+
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));
|
|
22
27
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
23
28
|
try {
|
|
24
|
-
if ((0, rules_config_1.isRefreshInProgress)(repoRoot, hangTimeoutMinutes))
|
|
29
|
+
if ((0, rules_config_1.isRefreshInProgress)(repoRoot, hangTimeoutMinutes)) {
|
|
30
|
+
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));
|
|
25
31
|
return;
|
|
32
|
+
}
|
|
26
33
|
const lock = (0, rules_config_1.inProcessLock)();
|
|
27
34
|
(0, rules_config_1.writeMainSyncLock)(repoRoot, lock);
|
|
28
35
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
29
36
|
try {
|
|
30
37
|
const status = (0, rules_config_1.computeMainSyncStatus)(repoRoot);
|
|
31
38
|
(0, rules_config_1.writeMainSyncStatus)(repoRoot, status);
|
|
39
|
+
// FINISH after a successful write — START-without-FINISH means we were killed mid-run.
|
|
40
|
+
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ms=${String(Date.now() - startedMs)}`));
|
|
32
41
|
}
|
|
33
42
|
finally {
|
|
34
43
|
// Always flip the lock off so a compute failure can't wedge the guard until the
|
|
@@ -38,9 +47,9 @@ function main() {
|
|
|
38
47
|
}
|
|
39
48
|
catch (err) {
|
|
40
49
|
const error = (0, to_error_1.toError)(err);
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
50
|
+
// Detached: swallow so a transient git/fs error never leaves poison state (the next hook call
|
|
51
|
+
// spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.
|
|
52
|
+
(0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));
|
|
44
53
|
}
|
|
45
54
|
}
|
|
46
55
|
if (require.main === module) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":";;AA0BA,oBAsCC;AAhED,0DAQiC;AAEjC,yCAAqC;AACrC,mDAA6D;AAE7D;;;;;;;;;;;;GAYG;AACH,SAAgB,IAAI;IAChB,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC;IAClD,MAAM,kBAAkB,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,2CAA4B,CAAC;IACnF,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAE7B,8FAA8F;IAC9F,qEAAqE;IACrE,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAE/G,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAI,IAAA,kCAAmB,EAAC,QAAQ,EAAE,kBAAkB,CAAC,EAAE,CAAC;YACpD,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,iBAAiB,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,gCAAgC,CAAC,CAAC,CAAC;YAChH,OAAO;QACX,CAAC;QAED,MAAM,IAAI,GAAG,IAAA,4BAAa,GAAE,CAAC;QAC7B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QAClC,8DAA8D;QAC9D,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAA,oCAAqB,EAAC,QAAQ,CAAC,CAAC;YAC/C,IAAA,kCAAmB,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;YACtC,uFAAuF;YACvF,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CACnC,QAAQ,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EACpC,UAAU,MAAM,CAAC,MAAM,CAAC,mBAAmB,CAAC,aAAa,MAAM,CAAC,QAAQ,cAAc,MAAM,CAAC,MAAM,CAAC,YAAY,CAAC,aAAa,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC,EAAE,CAC/L,CAAC,CAAC;QACP,CAAC;gBAAS,CAAC;YACP,gFAAgF;YAChF,8BAA8B;YAC9B,IAAA,gCAAiB,EAAC,QAAQ,EAAE,IAAA,2BAAY,EAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,8FAA8F;QAC9F,qFAAqF;QACrF,IAAA,4BAAY,EAAC,QAAQ,EAAE,IAAI,4BAAY,CAAC,OAAO,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC,OAAO,MAAM,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;IACnH,CAAC;AACL,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,IAAI,EAAE,CAAC;AACX,CAAC","sourcesContent":["import {\n DEFAULT_HANG_TIMEOUT_MINUTES,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If main-sync.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, status.branch,\n `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
|