@webpieces/ai-hook-rules 0.4.736 → 0.4.738
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 +26 -5
- package/package.json +2 -2
- package/src/bin/codex-trust.js.map +1 -1
- package/src/bin/hook-registration.d.ts +49 -23
- package/src/bin/hook-registration.js +80 -6
- package/src/bin/hook-registration.js.map +1 -1
- package/src/bin/neighbour-hooks.d.ts +42 -0
- package/src/bin/neighbour-hooks.js +177 -0
- package/src/bin/neighbour-hooks.js.map +1 -0
- package/src/bin/settings-shape.d.ts +37 -0
- package/src/bin/settings-shape.js +17 -0
- package/src/bin/settings-shape.js.map +1 -0
- package/src/bin/setup.d.ts +2 -1
- package/src/bin/setup.js.map +1 -1
- package/src/bin/shim-deny-reason.js +19 -1
- package/src/bin/shim-deny-reason.js.map +1 -1
- package/src/bin/upgrade-shim.js +14 -0
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/excluded-paths.d.ts +23 -0
- package/src/core/excluded-paths.js +54 -0
- package/src/core/excluded-paths.js.map +1 -0
- package/src/core/l0-matrix.js +3 -2
- package/src/core/l0-matrix.js.map +1 -1
- package/src/core/l0-tooling-doc.js +1 -1
- package/src/core/l0-tooling-doc.js.map +1 -1
- package/src/core/l1-doc.js +20 -3
- package/src/core/l1-doc.js.map +1 -1
- package/src/core/l1-rows.js +1 -0
- package/src/core/l1-rows.js.map +1 -1
- package/src/core/l2-rows.js +9 -0
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +36 -0
- package/src/core/rules/feature-branch-guard.js +95 -17
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/judged-tree.d.ts +66 -0
- package/src/core/rules/judged-tree.js +97 -0
- package/src/core/rules/judged-tree.js.map +1 -0
- package/src/core/rules/read-stale-guard.d.ts +8 -0
- package/src/core/rules/read-stale-guard.js +47 -16
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/runner.d.ts +0 -2
- package/src/core/runner.js +13 -23
- package/src/core/runner.js.map +1 -1
- package/src/core/target-tree.d.ts +82 -0
- package/src/core/target-tree.js +145 -0
- package/src/core/target-tree.js.map +1 -0
|
@@ -15,7 +15,9 @@ const main_sync_timeout_1 = require("../main-sync-timeout");
|
|
|
15
15
|
const decision_log_1 = require("../decision-log");
|
|
16
16
|
const l2_matrix_doc_1 = require("../l2-matrix-doc");
|
|
17
17
|
const l0_fault_codes_1 = require("../l0-fault-codes");
|
|
18
|
+
const target_tree_1 = require("../target-tree");
|
|
18
19
|
const merged_branch_message_1 = require("./merged-branch-message");
|
|
20
|
+
const judged_tree_1 = require("./judged-tree");
|
|
19
21
|
const stale_main_message_1 = require("./stale-main-message");
|
|
20
22
|
const main_freshness_1 = require("./main-freshness");
|
|
21
23
|
const tree_recovery_1 = require("./tree-recovery");
|
|
@@ -96,27 +98,47 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
96
98
|
new rules_config_1.Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),
|
|
97
99
|
new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
|
|
98
100
|
]);
|
|
101
|
+
/**
|
|
102
|
+
* WHICH TREE is judged — the one that owns the FILE BEING READ, resolved by git through
|
|
103
|
+
* TargetTreeResolver, never by `ctx.workspaceRoot`. Same fix and same reasoning as
|
|
104
|
+
* feature-branch-guard's (issue #851): `ctx.workspaceRoot` is the walk-up from the SESSION's cwd, so
|
|
105
|
+
* a main-session Read of a file inside an agent worktree was judged on the PRIMARY clone's branch —
|
|
106
|
+
* and a `main` that the primary happened to be sitting on would close reads of another tree's
|
|
107
|
+
* feature branch.
|
|
108
|
+
*/
|
|
99
109
|
check(ctx) {
|
|
100
110
|
// Outside the workspace root — no jurisdiction.
|
|
101
111
|
if (ctx.relativePath.startsWith('..'))
|
|
102
112
|
return [];
|
|
103
|
-
const
|
|
113
|
+
const target = new target_tree_1.TargetTreeResolver().resolve(ctx.filePath, ctx.workspaceRoot);
|
|
114
|
+
// A nested clone under `repositories/**` is out of scope, exactly as it is on the bash path.
|
|
115
|
+
if (target.kind === 'foreign')
|
|
116
|
+
return this.failOpen(ctx, null, 'target-tree-foreign');
|
|
117
|
+
const judgedRoot = target.root;
|
|
118
|
+
const branch = this.currentBranch(judgedRoot);
|
|
104
119
|
if (branch === null)
|
|
105
120
|
return this.failOpen(ctx, branch, 'branch-undeterminable');
|
|
121
|
+
// Mid-rebase in the target tree: no branch name, so no key into the branch-keyed cache and
|
|
122
|
+
// nothing to judge. Matrix row 14 — abstain, and say WHY rather than logging a cache miss.
|
|
123
|
+
if (branch === 'HEAD')
|
|
124
|
+
return this.failOpen(ctx, branch, 'detached-head');
|
|
106
125
|
// Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for
|
|
107
|
-
// BOTH states — the merged-branch signal comes out of that same cache.
|
|
108
|
-
|
|
126
|
+
// BOTH states — the merged-branch signal comes out of that same cache. Rooted at the JUDGED
|
|
127
|
+
// tree so the entry refreshed is the one this guard reads.
|
|
128
|
+
(0, main_sync_refresh_1.triggerMainSyncRefresh)(judgedRoot, (0, main_sync_timeout_1.hangTimeoutOf)(this.config));
|
|
109
129
|
// Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so
|
|
110
130
|
// the agent can always read-then-edit the file that turns this guard off.
|
|
111
131
|
if (this.isConfigFile(ctx.relativePath))
|
|
112
132
|
return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');
|
|
133
|
+
const judged = new judged_tree_1.JudgedTree(target, branch, target.governedRoot);
|
|
113
134
|
return branch === 'main'
|
|
114
|
-
? this.checkStaleMain(ctx,
|
|
115
|
-
: this.checkMergedBranch(ctx,
|
|
135
|
+
? this.checkStaleMain(ctx, judged)
|
|
136
|
+
: this.checkMergedBranch(ctx, judged);
|
|
116
137
|
}
|
|
117
138
|
// State A — on main, possibly behind origin/main.
|
|
118
|
-
checkStaleMain(ctx,
|
|
119
|
-
const
|
|
139
|
+
checkStaleMain(ctx, judged) {
|
|
140
|
+
const branch = judged.branch;
|
|
141
|
+
const status = (0, rules_config_1.readMainSyncStatus)(judged.root, 'main');
|
|
120
142
|
if (status === null)
|
|
121
143
|
return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
|
|
122
144
|
const cache = this.cacheSummary(status);
|
|
@@ -129,7 +151,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
129
151
|
if (status.originMain === '')
|
|
130
152
|
return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
|
|
131
153
|
// Escape valve 2 — ancestry, NOT equality. See the class comment.
|
|
132
|
-
if (this.freshness.containsOriginMain(
|
|
154
|
+
if (this.freshness.containsOriginMain(judged.root, status.originMain)) {
|
|
133
155
|
return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
|
|
134
156
|
}
|
|
135
157
|
// NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull
|
|
@@ -140,7 +162,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
140
162
|
// (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.
|
|
141
163
|
// Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch
|
|
142
164
|
// — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.
|
|
143
|
-
return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(
|
|
165
|
+
return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(judged), cache);
|
|
144
166
|
}
|
|
145
167
|
/**
|
|
146
168
|
* State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every
|
|
@@ -157,8 +179,9 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
157
179
|
* from the documented design, not a decision — this docblock described the strict behaviour for
|
|
158
180
|
* releases while the code failed open.
|
|
159
181
|
*/
|
|
160
|
-
checkMergedBranch(ctx,
|
|
161
|
-
const
|
|
182
|
+
checkMergedBranch(ctx, judged) {
|
|
183
|
+
const branch = judged.branch;
|
|
184
|
+
const status = (0, rules_config_1.readMainSyncStatus)(judged.root, branch);
|
|
162
185
|
if (status === null)
|
|
163
186
|
return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
|
|
164
187
|
const cache = this.cacheSummary(status);
|
|
@@ -183,15 +206,19 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
183
206
|
// with you, so a dirty tree traps nobody. The valve was code drift from the documented design;
|
|
184
207
|
// read-stale-guard's own class comment said so while the code did the opposite.
|
|
185
208
|
const pr = status.mergedPr !== '' ? status.mergedPr : '?';
|
|
186
|
-
return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(
|
|
209
|
+
return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(judged, status.mergedPr), cache);
|
|
187
210
|
}
|
|
188
211
|
// The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree
|
|
189
212
|
// is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is
|
|
190
213
|
// told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a
|
|
191
214
|
// worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.
|
|
192
|
-
mergedMessage(
|
|
215
|
+
mergedMessage(judged, mergedPr) {
|
|
193
216
|
const recovery = new tree_recovery_1.TreeRecovery();
|
|
194
|
-
|
|
217
|
+
const body = new merged_branch_message_1.MergedBranchMessage(judged.root).forReads(judged.branch, mergedPr, recovery.kindOf(judged.root), judged.root);
|
|
218
|
+
// NO redirect note here, deliberately — see feature-branch-guard.alreadyMergedMessage for the
|
|
219
|
+
// argument. MergedBranchMessage already aims its commands at the judged root through `atRoot`
|
|
220
|
+
// and prints the WORKTREE flavour of the cure when the judged tree is one.
|
|
221
|
+
return [judged.header(), body].join('\n');
|
|
195
222
|
}
|
|
196
223
|
isConfigFile(relativePath) {
|
|
197
224
|
return relativePath === 'webpieces.config.json';
|
|
@@ -216,8 +243,12 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
|
|
|
216
243
|
// StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two
|
|
217
244
|
// halves of the State-A block could never prescribe different cures; that guard now blocks on the
|
|
218
245
|
// BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.
|
|
219
|
-
staleMainMessage(
|
|
220
|
-
return
|
|
246
|
+
staleMainMessage(judged) {
|
|
247
|
+
return [
|
|
248
|
+
judged.header(),
|
|
249
|
+
new stale_main_message_1.StaleMainMessage(judged.root).forReads(this.behindCount(judged.root)),
|
|
250
|
+
judged.redirectNote([judged.pnpmCure('wp-sync-main')]),
|
|
251
|
+
].join('\n');
|
|
221
252
|
}
|
|
222
253
|
cacheSummary(status) {
|
|
223
254
|
return this.freshness.summarize(status);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.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 { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { StaleMainMessage } from './stale-main-message';\nimport { MainFreshness } from './main-freshness';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to\n * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,\n * an in-place pull, is not a fast-forward on a dirty tree — true, but that is a fact about the\n * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>\n * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with\n * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty\n * tree, so the block no longer has to be suppressed to keep the printed cure runnable. State B's valve\n * never had an argument at all — its cure was always the branch form.\n *\n * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.\n * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `pnpm wp-sync-main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (Every `wp-*` bin is on the L2 skip list, and the pull it wraps is explicitly permitted on main by\n * redirect-how-to-merge-main, which returns null when the branch IS main — the guards are\n * complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard\n * blocks — `main`, KNOWN BEHIND `origin/main` by the ancestry test below — it default-denies Bash and\n * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay\n * simple and Read-only.\n *\n * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data\n * is far worse than one that misses; every unknown resolves to \"allow\". Note the dual, which is what\n * the deleted dirty valve violated: never fail open on data you DID establish. A dirty tree is not an\n * unknown — it is a known state with a known cure. The three deliberate escape valves:\n *\n * 1. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 2. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 3. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'read-stale-guard', BRANCH_STATE_GUARD_KEY); }\n\n // The ancestry test and the cache summary, shared with stale-main-bash-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → pnpm wp-sync-main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),\n new Option('If a checkout -b refuses because origin/main changed the same files you edited: git stash (never blocked), redo the checkout, then git stash pop.'),\n new Option(\"If pnpm wp-sync-main dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then run it again.\"),\n new Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n return branch === 'main'\n ? this.checkStaleMain(ctx, branch)\n : this.checkMergedBranch(ctx, branch);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull\n // is not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:\n // row 6's cure cell has always offered `git checkout -b <new> origin/main` as an alternative,\n // and THAT works dirty — it carries uncommitted changes onto the new branch and lands you on\n // current code, which is the whole point. The message now leads with it when the tree is dirty\n // (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.\n // Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch\n // — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * NO DIRTY-TREE VALVE. `git checkout -b <new> origin/main` carries uncommitted changes onto the\n * fresh branch, so the work comes with you and there is nothing to rescue by reading. When it does\n * NOT (an overlapping change landed in main, so git refuses the switch), `git stash` is on the L2\n * skip list and is never blocked: stash → branch → pop. The valve that used to sit here was drift\n * from the documented design, not a decision — this docblock described the strict behaviour for\n * releases while the code failed open.\n */\n private checkMergedBranch(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation. (A branch the refresh has\n // not seen yet is the `status === null` case above — still fail-open.)\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n // NO DIRTY VALVE — and this one never had an argument behind it at all. Row 8's cure is\n // `git fetch origin main && git checkout -b <new> origin/main`, which carries uncommitted work\n // with you, so a dirty tree traps nobody. The valve was code drift from the documented design;\n // read-stale-guard's own class comment said so while the code did the opposite.\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n const recovery = new TreeRecovery();\n return new MergedBranchMessage(workspaceRoot).forReads(\n branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two\n // halves of the State-A block could never prescribe different cures; that guard now blocks on the\n // BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.\n private staleMainMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return this.freshness.summarize(status);\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\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 // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.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 { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, MainSyncStatus, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { TargetTreeResolver } from '../target-tree';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { JudgedTree } from './judged-tree';\nimport { StaleMainMessage } from './stale-main-message';\nimport { MainFreshness } from './main-freshness';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THERE IS NO DIRTY-TREE ASYMMETRY, and there is no dirty-tree valve in either state. Both used to\n * fail open on uncommitted work; both now block. The argument for the state-A valve was that its cure,\n * an in-place pull, is not a fast-forward on a dirty tree — true, but that is a fact about the\n * MESSAGE, which printed only the pull. Row 6 has always carried a second cure, `git checkout -b <new>\n * origin/main`, and that one CARRIES uncommitted changes onto the new branch, so the work comes with\n * you and nothing is trapped. StaleMainMessage now prints both, labelled with which survives a dirty\n * tree, so the block no longer has to be suppressed to keep the printed cure runnable. State B's valve\n * never had an argument at all — its cure was always the branch form.\n *\n * Residual, in both states: if `origin/main` changed the same files you edited, git refuses the switch.\n * `git stash` is on the L2 skip list and is never blocked, so the path out is stash → branch → pop.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `pnpm wp-sync-main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (Every `wp-*` bin is on the L2 skip list, and the pull it wraps is explicitly permitted on main by\n * redirect-how-to-merge-main, which returns null when the branch IS main — the guards are\n * complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's): in the SAME state this guard\n * blocks — `main`, KNOWN BEHIND `origin/main` by the ancestry test below — it default-denies Bash and\n * allowlists only the commands that get you out, so the cure is never blocked and this guard can stay\n * simple and Read-only.\n *\n * Everything here is FAIL-OPEN on data we could not ESTABLISH. A guard that blocks reads on bad data\n * is far worse than one that misses; every unknown resolves to \"allow\". Note the dual, which is what\n * the deleted dirty valve violated: never fail open on data you DID establish. A dirty tree is not an\n * unknown — it is a known state with a known cure. The three deliberate escape valves:\n *\n * 1. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 2. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 3. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'read-stale-guard', BRANCH_STATE_GUARD_KEY); }\n\n // The ancestry test and the cache summary, shared with stale-main-bash-guard — see main-freshness.ts.\n private readonly freshness = new MainFreshness();\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → pnpm wp-sync-main (CLEAN TREE ONLY), or git checkout -b <new-branch> origin/main which works with UNCOMMITTED CHANGES and brings them along. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main, which likewise carries your edits. Then retry the read.', true),\n new Option('If a checkout -b refuses because origin/main changed the same files you edited: git stash (never blocked), redo the checkout, then git stash pop.'),\n new Option(\"If pnpm wp-sync-main dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then run it again.\"),\n new Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n /**\n * WHICH TREE is judged — the one that owns the FILE BEING READ, resolved by git through\n * TargetTreeResolver, never by `ctx.workspaceRoot`. Same fix and same reasoning as\n * feature-branch-guard's (issue #851): `ctx.workspaceRoot` is the walk-up from the SESSION's cwd, so\n * a main-session Read of a file inside an agent worktree was judged on the PRIMARY clone's branch —\n * and a `main` that the primary happened to be sitting on would close reads of another tree's\n * feature branch.\n */\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const target = new TargetTreeResolver().resolve(ctx.filePath, ctx.workspaceRoot);\n // A nested clone under `repositories/**` is out of scope, exactly as it is on the bash path.\n if (target.kind === 'foreign') return this.failOpen(ctx, null, 'target-tree-foreign');\n\n const judgedRoot = target.root;\n const branch = this.currentBranch(judgedRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n // Mid-rebase in the target tree: no branch name, so no key into the branch-keyed cache and\n // nothing to judge. Matrix row 14 — abstain, and say WHY rather than logging a cache miss.\n if (branch === 'HEAD') return this.failOpen(ctx, branch, 'detached-head');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache. Rooted at the JUDGED\n // tree so the entry refreshed is the one this guard reads.\n triggerMainSyncRefresh(judgedRoot, hangTimeoutOf(this.config));\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n const judged = new JudgedTree(target, branch, target.governedRoot);\n return branch === 'main'\n ? this.checkStaleMain(ctx, judged)\n : this.checkMergedBranch(ctx, judged);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, judged: JudgedTree): readonly Violation[] {\n const branch = judged.branch;\n const status = readMainSyncStatus(judged.root, 'main');\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry by key, so\n // a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so\n // that degrades to an allow. Unreachable in normal operation.\n if (status.branch !== 'main') return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.failOpen(ctx, branch, 'origin-main-unknown', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.freshness.containsOriginMain(judged.root, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull\n // is not a clean fast-forward on a dirty tree. That argument was about the MESSAGE, not the row:\n // row 6's cure cell has always offered `git checkout -b <new> origin/main` as an alternative,\n // and THAT works dirty — it carries uncommitted changes onto the new branch and lands you on\n // current code, which is the whole point. The message now leads with it when the tree is dirty\n // (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.\n // Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch\n // — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(judged), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * NO DIRTY-TREE VALVE. `git checkout -b <new> origin/main` carries uncommitted changes onto the\n * fresh branch, so the work comes with you and there is nothing to rescue by reading. When it does\n * NOT (an overlapping change landed in main, so git refuses the switch), `git stash` is on the L2\n * skip list and is never blocked: stash → branch → pop. The valve that used to sit here was drift\n * from the documented design, not a decision — this docblock described the strict behaviour for\n * releases while the code failed open.\n */\n private checkMergedBranch(ctx: FileContext, judged: JudgedTree): readonly Violation[] {\n const branch = judged.branch;\n const status = readMainSyncStatus(judged.root, branch);\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: the entry was looked up BY `branch`, so\n // a mismatch is a shape bug rather than the old \"cache is for another branch\" state. Kept so\n // such a bug degrades to an allow. Unreachable in normal operation. (A branch the refresh has\n // not seen yet is the `status === null` case above — still fail-open.)\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n if (!status.branchAlreadyMerged) {\n return status.forgeReachable\n ? this.allow(ctx, branch, 'clean-feature-branch', cache)\n : this.failOpen(ctx, branch, 'no-forge', cache);\n }\n // NO DIRTY VALVE — and this one never had an argument behind it at all. Row 8's cure is\n // `git fetch origin main && git checkout -b <new> origin/main`, which carries uncommitted work\n // with you, so a dirty tree traps nobody. The valve was code drift from the documented design;\n // read-stale-guard's own class comment said so while the code did the opposite.\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(judged, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(judged: JudgedTree, mergedPr: string): string {\n const recovery = new TreeRecovery();\n const body = new MergedBranchMessage(judged.root).forReads(\n judged.branch, mergedPr, recovery.kindOf(judged.root), judged.root,\n );\n // NO redirect note here, deliberately — see feature-branch-guard.alreadyMergedMessage for the\n // argument. MergedBranchMessage already aims its commands at the judged root through `atRoot`\n // and prints the WORKTREE flavour of the cure when the judged tree is one.\n return [judged.header(), body].join('\\n');\n }\n\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two\n // halves of the State-A block could never prescribe different cures; that guard now blocks on the\n // BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.\n private staleMainMessage(judged: JudgedTree): string {\n return [\n judged.header(),\n new StaleMainMessage(judged.root).forReads(this.behindCount(judged.root)),\n judged.redirectNote([judged.pnpmCure('wp-sync-main')]),\n ].join('\\n');\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return this.freshness.summarize(status);\n }\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\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 // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
|
package/src/core/runner.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { ExcludePaths } from '@webpieces/rules-config';
|
|
2
1
|
import { AiType } from './agent-event';
|
|
3
2
|
import { ToolKind, NormalizedToolInput, BlockedResult, HookMode, Rule, Violation, EditContext, FileContext, BashContext } from './types';
|
|
4
|
-
export declare function filterByExcludedPaths(rules: readonly Rule[], relativePath: string, ex: ExcludePaths): readonly Rule[];
|
|
5
3
|
export declare function effectiveBashCwd(command: string, cwd: string): string;
|
|
6
4
|
export declare function isGitOrGhCommand(command: string): boolean;
|
|
7
5
|
export declare function run(toolKind: ToolKind, input: NormalizedToolInput, cwd: string, mode?: HookMode): BlockedResult | null;
|
package/src/core/runner.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.filterByExcludedPaths = filterByExcludedPaths;
|
|
4
3
|
exports.effectiveBashCwd = effectiveBashCwd;
|
|
5
4
|
exports.isGitOrGhCommand = isGitOrGhCommand;
|
|
6
5
|
exports.run = run;
|
|
@@ -14,6 +13,8 @@ const build_context_1 = require("./build-context");
|
|
|
14
13
|
const delete_scoped_rules_1 = require("./delete-scoped-rules");
|
|
15
14
|
const version_sync_1 = require("./version-sync");
|
|
16
15
|
const effective_tree_1 = require("./effective-tree");
|
|
16
|
+
const target_tree_1 = require("./target-tree");
|
|
17
|
+
const excluded_paths_1 = require("./excluded-paths");
|
|
17
18
|
const excluded_path_escape_1 = require("./excluded-path-escape");
|
|
18
19
|
const force_to_root_1 = require("./force-to-root");
|
|
19
20
|
const load_rules_1 = require("./load-rules");
|
|
@@ -45,19 +46,6 @@ function filterByMode(rules, mode) {
|
|
|
45
46
|
return rules.filter((r) => (0, rules_config_1.isHookGuard)(r.configKey));
|
|
46
47
|
return rules.filter((r) => !(0, rules_config_1.isHookGuard)(r.configKey));
|
|
47
48
|
}
|
|
48
|
-
// Drop every rule excluded for this path (webpieces.config.json → excludePaths). ONE glob list: a path
|
|
49
|
-
// listed there is hands-off for code-style rules and file-scoped guards alike, because webpieces either
|
|
50
|
-
// governs a path or it does not. Per-rule carve-outs live in the rule's own `excludePaths`.
|
|
51
|
-
// This is L1's FILTER (not a table row) — see guards/L1-location.md.
|
|
52
|
-
function filterByExcludedPaths(rules, relativePath, ex) {
|
|
53
|
-
// webpieces' OWN gitignored state dir is never governed, config or no config. Ahead of the list on
|
|
54
|
-
// purpose — see isWebpiecesStateDir for why it is code and not a seeded glob.
|
|
55
|
-
if ((0, rules_config_1.isWebpiecesStateDir)(relativePath))
|
|
56
|
-
return [];
|
|
57
|
-
if (ex.paths.some((p) => (0, load_rules_1.globMatches)(p, relativePath)))
|
|
58
|
-
return [];
|
|
59
|
-
return rules;
|
|
60
|
-
}
|
|
61
49
|
// The cwd a command actually runs from, after its own leading `cd`/`pushd` run. Thin delegate kept
|
|
62
50
|
// for the callers (and specs) that only need the directory; the full tree classification — primary
|
|
63
51
|
// clone vs linked worktree vs nested clone vs outside any repo — is EffectiveTreeResolver.resolve().
|
|
@@ -121,8 +109,9 @@ function runInternal(toolKind, input, cwd, mode) {
|
|
|
121
109
|
// Suppress enforcement for files under this category's excludePaths (e.g. vendored repos under
|
|
122
110
|
// repositories/**). Exclusion is all-or-nothing per category, so an excluded file drops the whole
|
|
123
111
|
// rule set and is fully hands-off — no violations AND no config-sync nag on those files.
|
|
124
|
-
const
|
|
125
|
-
const
|
|
112
|
+
const governed = new target_tree_1.TargetTreeResolver().governedPath(input.filePath, workspaceRoot);
|
|
113
|
+
const relativePath = governed.relativePath;
|
|
114
|
+
const rules = new delete_scoped_rules_1.DeleteScopedRules().narrow(toolKind, (0, excluded_paths_1.filterByExcludedPaths)(modeRules, governed, loaded.excludePaths));
|
|
126
115
|
if (rules.length === 0)
|
|
127
116
|
return null;
|
|
128
117
|
// Config-sync applies only to built-in/custom rules; match-rules have their own validated section
|
|
@@ -182,9 +171,10 @@ function runRead(filePath, cwd, mode = 'all') {
|
|
|
182
171
|
// clone is out of scope. (No command to parse here, so the shell cwd IS the effective cwd.)
|
|
183
172
|
if (new effective_tree_1.EffectiveTreeResolver().resolve('', cwd, workspaceRoot).kind === 'foreign')
|
|
184
173
|
return null;
|
|
185
|
-
const
|
|
174
|
+
const governed = new target_tree_1.TargetTreeResolver().governedPath(filePath, workspaceRoot);
|
|
175
|
+
const relativePath = governed.relativePath;
|
|
186
176
|
const all = (0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot, guardHintsOf(loaded));
|
|
187
|
-
const rules = filterByExcludedPaths(all.filter((r) => READ_SCOPED_GUARDS.has(r.name)),
|
|
177
|
+
const rules = (0, excluded_paths_1.filterByExcludedPaths)(all.filter((r) => READ_SCOPED_GUARDS.has(r.name)), governed, loaded.excludePaths);
|
|
188
178
|
if (rules.length === 0)
|
|
189
179
|
return null;
|
|
190
180
|
const ctx = new types_1.FileContext('Read', filePath, relativePath, workspaceRoot, 0, 0, 0, 0);
|
|
@@ -380,10 +370,10 @@ function misplacedCdBlock(command, tree) {
|
|
|
380
370
|
* They still honour excludePaths, and they do not run in `rules` mode (code-style-only hook).
|
|
381
371
|
*/
|
|
382
372
|
// webpieces-disable no-function-outside-class -- sibling of the module-scope runner helpers; the whole file is functions and a lone class here would break its shape
|
|
383
|
-
function keylessBashRules(loaded, mode,
|
|
373
|
+
function keylessBashRules(loaded, mode, governed) {
|
|
384
374
|
if (mode === 'rules')
|
|
385
375
|
return [];
|
|
386
|
-
return filterByExcludedPaths((0, load_rules_1.loadKeylessBashRules)(loaded.prGate.buildCommand),
|
|
376
|
+
return (0, excluded_paths_1.filterByExcludedPaths)((0, load_rules_1.loadKeylessBashRules)(loaded.prGate.buildCommand), governed, loaded.excludePaths);
|
|
387
377
|
}
|
|
388
378
|
// webpieces-disable no-function-outside-class -- sibling of run()/runBash() in this module; the whole runner is module-scope functions and a lone class for this one entry point would break the file's shape
|
|
389
379
|
function runBashInternal(command, cwd, mode, aiType) {
|
|
@@ -415,9 +405,9 @@ function runBashInternal(command, cwd, mode, aiType) {
|
|
|
415
405
|
// cwd sits under an excluded tree (e.g. repositories/**) drops the whole guard set — matching how
|
|
416
406
|
// runInternal/runRead treat file paths. The relative path is '' when there is no `cd` (root), which
|
|
417
407
|
// matches no exclusion glob, so a plain command at the repo root is unaffected.
|
|
418
|
-
const
|
|
419
|
-
const rules = filterByExcludedPaths(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot, guardHintsOf(loaded)), mode),
|
|
420
|
-
const keyless = keylessBashRules(loaded, mode,
|
|
408
|
+
const governedCwd = (0, excluded_paths_1.bashGovernedPath)(tree, workspaceRoot);
|
|
409
|
+
const rules = (0, excluded_paths_1.filterByExcludedPaths)(filterByMode((0, load_rules_1.loadRules)(loaded.rulesConfig, workspaceRoot, guardHintsOf(loaded)), mode), governedCwd, loaded.excludePaths);
|
|
410
|
+
const keyless = keylessBashRules(loaded, mode, governedCwd);
|
|
421
411
|
if (rules.length === 0 && keyless.length === 0)
|
|
422
412
|
return null;
|
|
423
413
|
const outOfSync = checkConfigSync(rules, loaded.rulesConfig); // fault Y — L0 list wins, as under C
|