@webpieces/ai-hook-rules 0.4.509 → 0.4.510
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/bin/shim.d.ts +2 -0
- package/src/bin/shim.js +46 -19
- package/src/bin/shim.js.map +1 -1
- package/src/core/build-context.d.ts +2 -1
- package/src/core/build-context.js +5 -2
- package/src/core/build-context.js.map +1 -1
- package/src/core/effective-tree.d.ts +75 -0
- package/src/core/effective-tree.js +136 -0
- package/src/core/effective-tree.js.map +1 -0
- package/src/core/rules/content-read-scan.d.ts +29 -4
- package/src/core/rules/content-read-scan.js +54 -9
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/feature-branch-guard.js +1 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.js +8 -2
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.d.ts +8 -0
- package/src/core/rules/merged-branch-message.js +17 -1
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.js +2 -2
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.js +2 -2
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +12 -0
- package/src/core/rules/stale-main-message.js +17 -1
- package/src/core/rules/stale-main-message.js.map +1 -1
- package/src/core/rules/tree-recovery.d.ts +16 -0
- package/src/core/rules/tree-recovery.js +28 -7
- package/src/core/rules/tree-recovery.js.map +1 -1
- package/src/core/runner.js +56 -77
- package/src/core/runner.js.map +1 -1
- package/src/core/types.d.ts +12 -1
- package/src/core/types.js +14 -1
- package/src/core/types.js.map +1 -1
- package/templates/ai-hook.sh +5 -5
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EffectiveTreeResolver = exports.EffectiveTree = void 0;
|
|
4
|
+
exports.atRoot = atRoot;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
|
+
const command_scan_1 = require("./command-scan");
|
|
10
|
+
const shell_segment_scan_1 = require("./rules/shell-segment-scan");
|
|
11
|
+
/** Data-only (per CLAUDE.md, classes for data). */
|
|
12
|
+
class EffectiveTree {
|
|
13
|
+
/** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
|
|
14
|
+
shellCwd;
|
|
15
|
+
/** The directory the command really runs in, after its own leading `cd`/`pushd` run. */
|
|
16
|
+
effectiveCwd;
|
|
17
|
+
/** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */
|
|
18
|
+
root;
|
|
19
|
+
/** The root that owns webpieces.config.json — where config and excludePaths come from. */
|
|
20
|
+
governedRoot;
|
|
21
|
+
kind;
|
|
22
|
+
/** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
|
|
23
|
+
redirected;
|
|
24
|
+
constructor(shellCwd, effectiveCwd, root, governedRoot, kind) {
|
|
25
|
+
this.shellCwd = shellCwd;
|
|
26
|
+
this.effectiveCwd = effectiveCwd;
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.governedRoot = governedRoot;
|
|
29
|
+
this.kind = kind;
|
|
30
|
+
this.redirected = path.resolve(root) !== path.resolve(shellCwd);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.EffectiveTree = EffectiveTree;
|
|
34
|
+
class EffectiveTreeResolver {
|
|
35
|
+
scanner;
|
|
36
|
+
worktrees = new rules_config_1.WorktreeService();
|
|
37
|
+
shell;
|
|
38
|
+
constructor(scanner = new command_scan_1.CommandScanner()) {
|
|
39
|
+
this.scanner = scanner;
|
|
40
|
+
this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
|
|
41
|
+
}
|
|
42
|
+
resolve(command, shellCwd, governedRoot) {
|
|
43
|
+
const effectiveCwd = this.effectiveCwd(command, shellCwd);
|
|
44
|
+
const kindAndRoot = this.classify(effectiveCwd, governedRoot);
|
|
45
|
+
return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
|
|
49
|
+
*
|
|
50
|
+
* ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,
|
|
51
|
+
* so a later `cd` must not retroactively pull it out of scope — otherwise a trailing
|
|
52
|
+
* `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
|
|
53
|
+
* guards. `cd a && cd b && git …` resolves left to right, matching the shell.
|
|
54
|
+
*
|
|
55
|
+
* Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the
|
|
56
|
+
* guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`,
|
|
57
|
+
* so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.
|
|
58
|
+
*/
|
|
59
|
+
effectiveCwd(command, shellCwd) {
|
|
60
|
+
let effective = shellCwd;
|
|
61
|
+
for (const segment of this.scanner.commandSegments(command)) {
|
|
62
|
+
const words = this.shell.effectiveWords(segment);
|
|
63
|
+
if (words[0] !== 'cd' && words[0] !== 'pushd')
|
|
64
|
+
break;
|
|
65
|
+
if (words[1] !== undefined)
|
|
66
|
+
effective = path.resolve(effective, words[1]);
|
|
67
|
+
}
|
|
68
|
+
return effective;
|
|
69
|
+
}
|
|
70
|
+
classify(effectiveCwd, governedRoot) {
|
|
71
|
+
// Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and
|
|
72
|
+
// the nested-clone check keeps its exact previous behaviour.
|
|
73
|
+
if (this.isInside(effectiveCwd, governedRoot)) {
|
|
74
|
+
const gitRoot = gitToplevel(effectiveCwd);
|
|
75
|
+
if (gitRoot === null)
|
|
76
|
+
return new TreeClassification('primary', governedRoot);
|
|
77
|
+
if (path.resolve(gitRoot) === path.resolve(governedRoot))
|
|
78
|
+
return new TreeClassification('primary', governedRoot);
|
|
79
|
+
return new TreeClassification('foreign', gitRoot);
|
|
80
|
+
}
|
|
81
|
+
// Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a
|
|
82
|
+
// worktree nested under another resolves to the innermost one.
|
|
83
|
+
const owner = this.owningWorktree(effectiveCwd, governedRoot);
|
|
84
|
+
if (owner !== null) {
|
|
85
|
+
return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);
|
|
86
|
+
}
|
|
87
|
+
const gitRoot = gitToplevel(effectiveCwd);
|
|
88
|
+
if (gitRoot === null)
|
|
89
|
+
return new TreeClassification('outside', governedRoot);
|
|
90
|
+
if (path.resolve(gitRoot) === path.resolve(governedRoot))
|
|
91
|
+
return new TreeClassification('primary', governedRoot);
|
|
92
|
+
return new TreeClassification('foreign', gitRoot);
|
|
93
|
+
}
|
|
94
|
+
owningWorktree(dir, governedRoot) {
|
|
95
|
+
let best = null;
|
|
96
|
+
for (const tree of this.worktrees.listWorktrees(governedRoot)) {
|
|
97
|
+
if (!this.isInside(dir, tree.path))
|
|
98
|
+
continue;
|
|
99
|
+
if (best === null || tree.path.length > best.path.length)
|
|
100
|
+
best = tree;
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
|
104
|
+
/** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
|
|
105
|
+
isInside(dir, root) {
|
|
106
|
+
const relative = path.relative(path.resolve(root), path.resolve(dir));
|
|
107
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.EffectiveTreeResolver = EffectiveTreeResolver;
|
|
111
|
+
/** Data-only carrier for the two values classify() decides together. */
|
|
112
|
+
class TreeClassification {
|
|
113
|
+
kind;
|
|
114
|
+
root;
|
|
115
|
+
constructor(kind, root) {
|
|
116
|
+
this.kind = kind;
|
|
117
|
+
this.root = root;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare
|
|
121
|
+
* remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */
|
|
122
|
+
// webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony
|
|
123
|
+
function atRoot(root, command) {
|
|
124
|
+
return `cd ${root} && ${command}`;
|
|
125
|
+
}
|
|
126
|
+
// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`
|
|
127
|
+
// IS the expected "not a repo" answer (spawnSync does not throw on a non-zero exit), so no try/catch.
|
|
128
|
+
// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers
|
|
129
|
+
function gitToplevel(dir) {
|
|
130
|
+
const r = (0, child_process_1.spawnSync)('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
131
|
+
if (r.status !== 0)
|
|
132
|
+
return null;
|
|
133
|
+
const root = (r.stdout ?? '').trim();
|
|
134
|
+
return root !== '' ? root : null;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=effective-tree.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":";;;AA0JA,wBAEC;;AA5JD,mDAA6B;AAC7B,iDAA0C;AAE1C,0DAAoE;AAEpE,iDAAgD;AAChD,mEAA8D;AAmC9D,mDAAmD;AACnD,MAAa,aAAa;IACtB,kGAAkG;IACzF,QAAQ,CAAS;IAC1B,wFAAwF;IAC/E,YAAY,CAAS;IAC9B,qGAAqG;IAC5F,IAAI,CAAS;IACtB,0FAA0F;IACjF,YAAY,CAAS;IACrB,IAAI,CAAW;IACxB,uGAAuG;IAC9F,UAAU,CAAU;IAE7B,YAAY,QAAgB,EAAE,YAAoB,EAAE,IAAY,EAAE,YAAoB,EAAE,IAAc;QAClG,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpE,CAAC;CACJ;AArBD,sCAqBC;AAED,MAAa,qBAAqB;IAID;IAHZ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAClC,KAAK,CAAmB;IAEzC,YAA6B,UAA0B,IAAI,6BAAc,EAAE;QAA9C,YAAO,GAAP,OAAO,CAAuC;QACvE,IAAI,CAAC,KAAK,GAAG,IAAI,qCAAgB,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAED,OAAO,CAAC,OAAe,EAAE,QAAgB,EAAE,YAAoB;QAC3D,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;QAC9D,OAAO,IAAI,aAAa,CAAC,QAAQ,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,OAAe,EAAE,QAAgB;QAC1C,IAAI,SAAS,GAAG,QAAQ,CAAC;QACzB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;YACjD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;gBAAE,MAAM;YACrD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;gBAAE,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9E,CAAC;QACD,OAAO,SAAS,CAAC;IACrB,CAAC;IAEO,QAAQ,CAAC,YAAoB,EAAE,YAAoB;QACvD,+FAA+F;QAC/F,6DAA6D;QAC7D,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YAC5C,MAAM,OAAO,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;YAC1C,IAAI,OAAO,KAAK,IAAI;gBAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;YAC7E,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC;gBAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;YACjH,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;QACtD,CAAC;QAED,gGAAgG;QAChG,+DAA+D;QAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;QAC9D,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACjB,OAAO,IAAI,kBAAkB,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACrF,CAAC;QAED,MAAM,OAAO,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;QAC1C,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QAC7E,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC;YAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QACjH,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IACtD,CAAC;IAEO,cAAc,CAAC,GAAW,EAAE,YAAoB;QACpD,IAAI,IAAI,GAAoB,IAAI,CAAC;QACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,YAAY,CAAC,EAAE,CAAC;YAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC;gBAAE,SAAS;YAC7C,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM;gBAAE,IAAI,GAAG,IAAI,CAAC;QAC1E,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,oGAAoG;IAC5F,QAAQ,CAAC,GAAW,EAAE,IAAY;QACtC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACtE,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;IACzF,CAAC;CACJ;AAzED,sDAyEC;AAED,wEAAwE;AACxE,MAAM,kBAAkB;IACX,IAAI,CAAW;IACf,IAAI,CAAS;IAEtB,YAAY,IAAc,EAAE,IAAY;QACpC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ;AAED;qGACqG;AACrG,4JAA4J;AAC5J,SAAgB,MAAM,CAAC,IAAY,EAAE,OAAe;IAChD,OAAO,MAAM,IAAI,OAAO,OAAO,EAAE,CAAC;AACtC,CAAC;AAED,wGAAwG;AACxG,sGAAsG;AACtG,4HAA4H;AAC5H,SAAS,WAAW,CAAC,GAAW;IAC5B,MAAM,CAAC,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,EAAE,iBAAiB,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IAC9F,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACrC,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACrC,CAAC","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: an agent's Bash tool does NOT persist `cd` between calls (verified —\n * a standalone `cd <worktree>` followed by `pwd` in the next call reports the primary clone again).\n * So an agent working in a linked worktree writes self-contained `cd <worktree> && …` commands, and\n * the shell cwd the PreToolUse hook is handed is ALWAYS the primary clone. Every guard that reasons\n * from that cwd judges the wrong tree on every single call. Three field sightings in one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare\n * remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */\n// webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony\nexport function atRoot(root: string, command: string): string {\n return `cd ${root} && ${command}`;\n}\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
|
|
@@ -20,7 +20,28 @@ export declare class ContentReadScan {
|
|
|
20
20
|
private readonly scanner;
|
|
21
21
|
private readonly workspaceRoot;
|
|
22
22
|
private readonly shell;
|
|
23
|
-
|
|
23
|
+
private readonly baseDir;
|
|
24
|
+
/**
|
|
25
|
+
* `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which
|
|
26
|
+
* is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.
|
|
27
|
+
* RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat
|
|
28
|
+
* notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,
|
|
29
|
+
* while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.
|
|
30
|
+
* Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).
|
|
31
|
+
*/
|
|
32
|
+
constructor(scanner: CommandScanner, workspaceRoot: string, effectiveCwd?: string);
|
|
33
|
+
/**
|
|
34
|
+
* True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —
|
|
35
|
+
* `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.
|
|
36
|
+
*
|
|
37
|
+
* merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an
|
|
38
|
+
* `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing
|
|
39
|
+
* about a read that never touches the tree is affected by which branch the tree is on. The
|
|
40
|
+
* "content reader" restriction is what keeps this from becoming a general escape hatch: a build,
|
|
41
|
+
* a server or a git write is not a content reader and never qualifies, however its paths look.
|
|
42
|
+
*/
|
|
43
|
+
readsOnlyOutsideContent(segment: CommandSegment): boolean;
|
|
44
|
+
private isContentReader;
|
|
24
45
|
/**
|
|
25
46
|
* The command word that reads stale workspace content, or null when this segment does not.
|
|
26
47
|
* The returned string is only a log/diagnostic label.
|
|
@@ -38,14 +59,18 @@ export declare class ContentReadScan {
|
|
|
38
59
|
private gitContentRead;
|
|
39
60
|
private pathOperands;
|
|
40
61
|
/**
|
|
41
|
-
* Is this operand a path inside the workspace?
|
|
42
|
-
*
|
|
43
|
-
*
|
|
62
|
+
* Is this operand a path inside the workspace? A RELATIVE operand is resolved against the
|
|
63
|
+
* directory the command actually runs in (`baseDir`), so it counts only when that directory is
|
|
64
|
+
* itself in the tree — the old code assumed every relative path meant "inside the repo", which is
|
|
65
|
+
* how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An
|
|
66
|
+
* absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,
|
|
67
|
+
* `~/notes.md` and `/tmp/x` are not this repo's problem.
|
|
44
68
|
*
|
|
45
69
|
* Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and
|
|
46
70
|
* a stat per operand on the blocking hook path is exactly the cost these guards avoid.
|
|
47
71
|
*/
|
|
48
72
|
private isWorkspacePath;
|
|
73
|
+
private isInWorkspace;
|
|
49
74
|
private isEscapeHatchPath;
|
|
50
75
|
private baseName;
|
|
51
76
|
}
|
|
@@ -25,10 +25,42 @@ class ContentReadScan {
|
|
|
25
25
|
scanner;
|
|
26
26
|
workspaceRoot;
|
|
27
27
|
shell;
|
|
28
|
-
|
|
28
|
+
baseDir;
|
|
29
|
+
/**
|
|
30
|
+
* `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which
|
|
31
|
+
* is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.
|
|
32
|
+
* RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat
|
|
33
|
+
* notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,
|
|
34
|
+
* while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.
|
|
35
|
+
* Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).
|
|
36
|
+
*/
|
|
37
|
+
constructor(scanner, workspaceRoot, effectiveCwd) {
|
|
29
38
|
this.scanner = scanner;
|
|
30
39
|
this.workspaceRoot = workspaceRoot;
|
|
31
40
|
this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
|
|
41
|
+
this.baseDir = effectiveCwd ?? workspaceRoot;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —
|
|
45
|
+
* `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.
|
|
46
|
+
*
|
|
47
|
+
* merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an
|
|
48
|
+
* `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing
|
|
49
|
+
* about a read that never touches the tree is affected by which branch the tree is on. The
|
|
50
|
+
* "content reader" restriction is what keeps this from becoming a general escape hatch: a build,
|
|
51
|
+
* a server or a git write is not a content reader and never qualifies, however its paths look.
|
|
52
|
+
*/
|
|
53
|
+
readsOnlyOutsideContent(segment) {
|
|
54
|
+
return this.isContentReader(segment) && this.readsStaleContent(segment) === null;
|
|
55
|
+
}
|
|
56
|
+
// Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?
|
|
57
|
+
isContentReader(segment) {
|
|
58
|
+
const words = this.shell.effectiveWords(segment.text);
|
|
59
|
+
if (words.length === 0)
|
|
60
|
+
return false;
|
|
61
|
+
if (this.scanner.gitSubcommandOf(words) !== null)
|
|
62
|
+
return false;
|
|
63
|
+
return CONTENT_READERS.has(this.baseName(words[0]));
|
|
32
64
|
}
|
|
33
65
|
/**
|
|
34
66
|
* The command word that reads stale workspace content, or null when this segment does not.
|
|
@@ -51,8 +83,10 @@ class ContentReadScan {
|
|
|
51
83
|
const operands = this.pathOperands(command, words.slice(1));
|
|
52
84
|
if (operands.length === 0) {
|
|
53
85
|
// No path given: either it reads stdin (fine — and doubly fine when piped into), or it
|
|
54
|
-
// walks the cwd
|
|
55
|
-
|
|
86
|
+
// walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`
|
|
87
|
+
// walks /tmp, which this repo's staleness has nothing to do with.
|
|
88
|
+
const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);
|
|
89
|
+
return walksTree ? command : null;
|
|
56
90
|
}
|
|
57
91
|
return operands.some((operand) => this.isWorkspacePath(operand)) ? command : null;
|
|
58
92
|
}
|
|
@@ -86,9 +120,12 @@ class ContentReadScan {
|
|
|
86
120
|
return positional;
|
|
87
121
|
}
|
|
88
122
|
/**
|
|
89
|
-
* Is this operand a path inside the workspace?
|
|
90
|
-
*
|
|
91
|
-
*
|
|
123
|
+
* Is this operand a path inside the workspace? A RELATIVE operand is resolved against the
|
|
124
|
+
* directory the command actually runs in (`baseDir`), so it counts only when that directory is
|
|
125
|
+
* itself in the tree — the old code assumed every relative path meant "inside the repo", which is
|
|
126
|
+
* how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An
|
|
127
|
+
* absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,
|
|
128
|
+
* `~/notes.md` and `/tmp/x` are not this repo's problem.
|
|
92
129
|
*
|
|
93
130
|
* Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and
|
|
94
131
|
* a stat per operand on the blocking hook path is exactly the cost these guards avoid.
|
|
@@ -96,13 +133,21 @@ class ContentReadScan {
|
|
|
96
133
|
isWorkspacePath(operand) {
|
|
97
134
|
if (operand.startsWith('~'))
|
|
98
135
|
return false;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
136
|
+
// The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`
|
|
137
|
+
// stays readable from any directory — never wedge the file that turns the guard off.
|
|
138
|
+
if (this.isEscapeHatchPath(operand))
|
|
139
|
+
return false;
|
|
140
|
+
const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);
|
|
141
|
+
const relative = path.relative(this.workspaceRoot, absolute);
|
|
102
142
|
if (relative.startsWith('..'))
|
|
103
143
|
return false;
|
|
104
144
|
return !this.isEscapeHatchPath(relative);
|
|
105
145
|
}
|
|
146
|
+
// The cwd-walk question: is the directory this command runs in inside the tree being judged?
|
|
147
|
+
isInWorkspace(dir) {
|
|
148
|
+
const relative = path.relative(this.workspaceRoot, path.resolve(dir));
|
|
149
|
+
return !relative.startsWith('..');
|
|
150
|
+
}
|
|
106
151
|
// Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the
|
|
107
152
|
// file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation
|
|
108
153
|
// data this guard writes itself, not source that upstream has moved past.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd, which IS the stale tree (`ls`, a bare `rg pattern`).\n return !segment.pipedInto && CWD_WALKERS.has(command) ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? Relative paths are (the agent's cwd is the repo\n * or a subdir of it); an absolute path counts only when it is actually under workspaceRoot, so\n * `/etc/hosts`, `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n if (!path.isAbsolute(operand)) return !this.isEscapeHatchPath(operand);\n const relative = path.relative(this.workspaceRoot, operand);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
1
|
+
{"version":3,"file":"content-read-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/content-read-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\nimport { ShellSegmentScan } from './shell-segment-scan';\n\n/**\n * Decides the one question stale-main-bash-guard asks of a command: does this segment put stale\n * WORKSPACE FILE CONTENT into the agent's context?\n *\n * The distinction that matters is content vs metadata, not shell vs tool. `git log`, `git diff`,\n * `git status`, builds, tests and the cure itself are all fine on a stale main — none of them hands\n * you the text of a file that upstream has moved past. `cat src/x.ts` does, and so does\n * `grep -r foo services/`, `ls .github/workflows/` (the incident's actual wrong answer: a listing\n * missing a workflow that existed upstream) and `git show HEAD:file`.\n *\n * Three things keep this from over-blocking:\n * 1. A piped consumer reads stdin, not the tree — `git log | grep fix` is metadata, so it passes.\n * 2. A reader with no path operand that does not default to the cwd reads stdin — `cat` alone,\n * `grep pattern` alone.\n * 3. Only paths INSIDE the workspace count. `cat /etc/hosts`, `cat ~/.zshrc`, `cat /tmp/out.log`\n * are nothing to do with this repo's staleness.\n */\nexport class ContentReadScan {\n private readonly shell: ShellSegmentScan;\n private readonly baseDir: string;\n\n /**\n * `effectiveCwd` is the directory the command really runs in — after its own leading `cd`, which\n * is how an agent reaches a linked worktree, since `cd` does not persist between tool calls.\n * RELATIVE operands are resolved against it, not against workspaceRoot: `cd /tmp/scratch && cat\n * notes.md` reads `/tmp/scratch/notes.md`, which is nothing to do with this repo's staleness,\n * while `cd /tmp/scratch && cat /repo/src/x.ts` still names repo content and is still caught.\n * Defaults to workspaceRoot, which is exactly the old behaviour (relative = inside the repo).\n */\n constructor(\n private readonly scanner: CommandScanner,\n private readonly workspaceRoot: string,\n effectiveCwd?: string,\n ) {\n this.shell = new ShellSegmentScan(scanner);\n this.baseDir = effectiveCwd ?? workspaceRoot;\n }\n\n /**\n * True when this segment's ONLY job is reading content, and nothing it reads is in the workspace —\n * `ls -la ~/.claude/projects/`, `cat /tmp/out.log`, `grep -r x /other/repo`.\n *\n * merged-branch-bash-guard needs this: it default-denies bash on a merged branch, and denied an\n * `ls` of a directory outside every git repo on the grounds that the branch was merged. Nothing\n * about a read that never touches the tree is affected by which branch the tree is on. The\n * \"content reader\" restriction is what keeps this from becoming a general escape hatch: a build,\n * a server or a git write is not a content reader and never qualifies, however its paths look.\n */\n readsOnlyOutsideContent(segment: CommandSegment): boolean {\n return this.isContentReader(segment) && this.readsStaleContent(segment) === null;\n }\n\n // Is this segment one of the CONTENT_READERS at all (as opposed to a build, a server, a git write)?\n private isContentReader(segment: CommandSegment): boolean {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return false;\n if (this.scanner.gitSubcommandOf(words) !== null) return false;\n return CONTENT_READERS.has(this.baseName(words[0]));\n }\n\n /**\n * The command word that reads stale workspace content, or null when this segment does not.\n * The returned string is only a log/diagnostic label.\n *\n * Judged on the segment's EFFECTIVE words: `for f in a b; do cat $f; done` splits into segments\n * whose middle one is literally `do cat $f`, and taking `do` as the command name let every loop\n * body read the stale tree unseen.\n */\n readsStaleContent(segment: CommandSegment): string | null {\n const words = this.shell.effectiveWords(segment.text);\n if (words.length === 0) return null;\n\n const gitSub = this.scanner.gitSubcommandOf(words);\n if (gitSub !== null) return this.gitContentRead(gitSub, words);\n\n const command = this.baseName(words[0]);\n if (!CONTENT_READERS.has(command)) return null;\n\n const operands = this.pathOperands(command, words.slice(1));\n if (operands.length === 0) {\n // No path given: either it reads stdin (fine — and doubly fine when piped into), or it\n // walks the cwd. That only reads the stale tree when the cwd IS in it — `cd /tmp && ls`\n // walks /tmp, which this repo's staleness has nothing to do with.\n const walksTree = !segment.pipedInto && CWD_WALKERS.has(command) && this.isInWorkspace(this.baseDir);\n return walksTree ? command : null;\n }\n return operands.some((operand: string): boolean => this.isWorkspacePath(operand)) ? command : null;\n }\n\n /**\n * git's own content readers. `git grep` searches tracked CONTENT and `git show <rev>:<path>`\n * prints a file — both stale when the rev is local. Against an `origin/…` rev they read the\n * CURRENT upstream tree, which is exactly what we want the agent doing, so those pass.\n */\n private gitContentRead(gitSub: string, words: readonly string[]): string | null {\n if (gitSub !== 'grep' && gitSub !== 'show') return null;\n const args = words.slice(words.indexOf(gitSub) + 1);\n if (args.some((arg: string): boolean => arg.startsWith('origin/'))) return null;\n // `git show` without a `<rev>:<path>` operand is a commit view — metadata, not file content.\n if (gitSub === 'show' && !args.some((arg: string): boolean => /^[^-].*:./.test(arg))) return null;\n return `git ${gitSub}`;\n }\n\n // The operands of a reader that are PATHS: flags dropped, and the leading pattern/script dropped\n // for the commands that take one (`grep RE file`, `sed -e prog file`, `awk prog file`).\n private pathOperands(command: string, args: readonly string[]): readonly string[] {\n const positional: string[] = [];\n for (const arg of args) {\n if (arg.startsWith('-')) continue; // a flag, or its attached value\n positional.push(arg);\n }\n if (PATTERN_FIRST.has(command) && positional.length > 0) return positional.slice(1);\n return positional;\n }\n\n /**\n * Is this operand a path inside the workspace? A RELATIVE operand is resolved against the\n * directory the command actually runs in (`baseDir`), so it counts only when that directory is\n * itself in the tree — the old code assumed every relative path meant \"inside the repo\", which is\n * how a command run in a `/private/tmp` scratchpad got judged as reading a stale repo. An\n * absolute path counts only when it is genuinely under workspaceRoot, so `/etc/hosts`,\n * `~/notes.md` and `/tmp/x` are not this repo's problem.\n *\n * Deliberately NOT filesystem-checked: whether the path exists says nothing about staleness, and\n * a stat per operand on the blocking hook path is exactly the cost these guards avoid.\n */\n private isWorkspacePath(operand: string): boolean {\n if (operand.startsWith('~')) return false;\n // The escape hatches are checked on the operand AS TYPED as well, so `cat webpieces.config.json`\n // stays readable from any directory — never wedge the file that turns the guard off.\n if (this.isEscapeHatchPath(operand)) return false;\n const absolute = path.isAbsolute(operand) ? operand : path.resolve(this.baseDir, operand);\n const relative = path.relative(this.workspaceRoot, absolute);\n if (relative.startsWith('..')) return false;\n return !this.isEscapeHatchPath(relative);\n }\n\n // The cwd-walk question: is the directory this command runs in inside the tree being judged?\n private isInWorkspace(dir: string): boolean {\n const relative = path.relative(this.workspaceRoot, path.resolve(dir));\n return !relative.startsWith('..');\n }\n\n // Always-readable paths: webpieces.config.json is the mode-OFF escape hatch (never block the\n // file that turns the guard off), and `.webpieces/` is the guards' own logs/caches — orientation\n // data this guard writes itself, not source that upstream has moved past.\n private isEscapeHatchPath(relative: string): boolean {\n const normalized = relative.replace(/^\\.\\//, '');\n return normalized === 'webpieces.config.json' || normalized.startsWith('.webpieces/');\n }\n\n // `/usr/bin/cat` and `./scripts/cat` both invoke a program named cat; match on the base name.\n private baseName(word: string): string {\n return path.basename(word);\n }\n}\n\n// Commands whose whole job is surfacing file CONTENT or file LISTINGS. Builds, test runners, package\n// managers and git metadata are deliberately absent — they are not how stale bytes enter context.\nconst CONTENT_READERS: ReadonlySet<string> = new Set([\n 'cat', 'bat', 'head', 'tail', 'less', 'more', 'nl', 'strings', 'xxd', 'od',\n 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq',\n 'ls', 'find', 'tree', 'wc', 'diff',\n]);\n\n// Readers that, given no path, walk the CURRENT DIRECTORY rather than reading stdin — so on a stale\n// main they read the stale tree even with no operand at all.\nconst CWD_WALKERS: ReadonlySet<string> = new Set(['ls', 'find', 'tree', 'rg', 'ag', 'ack']);\n\n// Readers whose FIRST positional argument is a pattern/program, not a path.\nconst PATTERN_FIRST: ReadonlySet<string> = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'jq', 'yq']);\n"]}
|
|
@@ -126,7 +126,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
|
|
|
126
126
|
// The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new
|
|
127
127
|
// worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.
|
|
128
128
|
alreadyMergedMessage(workspaceRoot, branch, mergedPr) {
|
|
129
|
-
return new merged_branch_message_1.MergedBranchMessage().forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
129
|
+
return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forEdits(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
130
130
|
}
|
|
131
131
|
conflictMessage(conflictFiles, openPr) {
|
|
132
132
|
const files = conflictFiles.length > 0
|
|
@@ -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 MainSyncStatus,\n SyncFlowGuidance,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\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 = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\n ],\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)', 'cache=none');\n\n const cache = this.cacheSummary(status);\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)', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(ctx.workspaceRoot, branch, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\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), cache);\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, status.openPr), cache);\n }\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\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 + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\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', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\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 // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n private alreadyMergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage().forEdits(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n private conflictMessage(conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n const header = [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n ];\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\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 MainSyncStatus,\n SyncFlowGuidance,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\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 = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\n ],\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)', 'cache=none');\n\n const cache = this.cacheSummary(status);\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)', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(ctx.workspaceRoot, branch, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\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), cache);\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, status.openPr), cache);\n }\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\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 + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\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', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\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 // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n private alreadyMergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forEdits(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n private conflictMessage(conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n const header = [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n ];\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\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"]}
|
|
@@ -100,7 +100,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
100
100
|
const segments = this.scanner.segmentsWithPipes(ctx.command);
|
|
101
101
|
if (segments.length === 0)
|
|
102
102
|
return false;
|
|
103
|
-
const content = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot);
|
|
103
|
+
const content = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);
|
|
104
104
|
return segments.every((segment) => this.isRecoverySegment(segment, content));
|
|
105
105
|
}
|
|
106
106
|
isRecoverySegment(segment, content) {
|
|
@@ -112,6 +112,12 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
112
112
|
// is protecting against. ContentReadScan already draws exactly that line.
|
|
113
113
|
if (verdict.role === 'shaping')
|
|
114
114
|
return content.readsStaleContent(segment) === null;
|
|
115
|
+
// A read that names NOTHING in this tree cannot be affected by which branch this tree is on.
|
|
116
|
+
// `ls -la ~/.claude/projects/ | grep -i foo` was blocked as "this branch is merged" — the
|
|
117
|
+
// command touches no repo at all. Only CONTENT READERS qualify, so a build, a server or a git
|
|
118
|
+
// write never slips through on the strength of its paths.
|
|
119
|
+
if (content.readsOnlyOutsideContent(segment))
|
|
120
|
+
return true;
|
|
115
121
|
const gitSub = this.scanner.gitSubcommandOf(verdict.words);
|
|
116
122
|
if (gitSub !== null)
|
|
117
123
|
return ALLOWED_GIT_SUBCOMMANDS.has(gitSub);
|
|
@@ -147,7 +153,7 @@ class MergedBranchBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
147
153
|
return words.slice(1).some((word) => /^wp-[a-z-]+$/.test(word) || PACKAGE_INSTALL_VERBS.has(word));
|
|
148
154
|
}
|
|
149
155
|
mergedMessage(workspaceRoot, branch, mergedPr) {
|
|
150
|
-
return new merged_branch_message_1.MergedBranchMessage().forBash(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
156
|
+
return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forBash(branch, mergedPr, new tree_recovery_1.TreeRecovery().kindOf(workspaceRoot), workspaceRoot);
|
|
151
157
|
}
|
|
152
158
|
// One-line summary of the async-written cache that drove this decision (mirrors the file guards),
|
|
153
159
|
// so a wrong allow/block is traceable to the exact main-sync-status.json read.
|