@webpieces/ai-hook-rules 0.4.684 → 0.4.686
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/core/l2-doc.js +35 -21
- package/src/core/l2-doc.js.map +1 -1
- package/src/core/l2-rows.d.ts +8 -5
- package/src/core/l2-rows.js +26 -17
- package/src/core/l2-rows.js.map +1 -1
- package/src/core/rules/feature-branch-guard.d.ts +21 -1
- package/src/core/rules/feature-branch-guard.js +21 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/main-freshness.d.ts +35 -0
- package/src/core/rules/main-freshness.js +53 -0
- package/src/core/rules/main-freshness.js.map +1 -0
- package/src/core/rules/merged-branch-bash-guard.js +1 -1
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.js +2 -1
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.d.ts +5 -3
- package/src/core/rules/read-stale-guard.js +10 -24
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/recovery-allowlist.d.ts +32 -1
- package/src/core/rules/recovery-allowlist.js +79 -21
- package/src/core/rules/recovery-allowlist.js.map +1 -1
- package/src/core/rules/shell-segment-scan.d.ts +10 -1
- package/src/core/rules/shell-segment-scan.js +9 -1
- package/src/core/rules/shell-segment-scan.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.d.ts +73 -47
- package/src/core/rules/stale-main-bash-guard.js +109 -83
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +4 -3
- package/src/core/rules/stale-main-message.js +4 -3
- package/src/core/rules/stale-main-message.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":";;;;AAAA,mDAA6B;AAE7B,kDAAiE;AAoCjE,mDAAmD;AACnD,MAAa,cAAc;IACvB,IAAI,CAAc;IAClB,oGAAoG;IACpG,KAAK,CAAoB;IAEzB,YAAY,IAAiB,EAAE,KAAwB;QACnD,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;IACvB,CAAC;CACJ;AATD,wCASC;AAED,MAAa,gBAAgB;IACI;IAA7B,YAA6B,UAA0B,IAAI,6BAAc,EAAE;QAA9C,YAAO,GAAP,OAAO,CAAuC;IAAG,CAAC;IAE/E,QAAQ,CAAC,OAAuB;QAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;QACnE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,SAAS,CAAC;QAEzC,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACrC,IAAI,eAAe,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,SAAS,CAAC;QAEhD,2FAA2F;QAC3F,IAAI,IAAI,CAAC,eAAe,CAAC,KAAK,CAAC;YAAE,OAAO,IAAI,cAAc,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAE7E,IAAI,YAAY,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,cAAc,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QACxE,IAAI,OAAO,CAAC,SAAS,IAAI,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,cAAc,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;QAE/F,OAAO,IAAI,cAAc,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC;IAChD,CAAC;IAED;;;;;OAKG;IACH,cAAc,CAAC,WAAmB;QAC9B,OAAO,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC;IAC/D,CAAC;IAEO,aAAa,CAAC,KAAwB;QAC1C,IAAI,CAAC,GAAG,CAAC,CAAC;QACV,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,IAAI,mBAAmB,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YAAE,CAAC,EAAE,CAAC;QAClE,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1B,CAAC;IAED
|
|
1
|
+
{"version":3,"file":"shell-segment-scan.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/shell-segment-scan.ts"],"names":[],"mappings":"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","sourcesContent":["import * as path from 'path';\n\nimport { CommandScanner, CommandSegment } from '../command-scan';\n\n/**\n * What ROLE one segment of a shell command plays — the question every allowlist-shaped bash guard has\n * to answer before it can judge a compound command.\n *\n * WHY this exists: `merged-branch-bash-guard` allowlists the commands that get you OFF a merged\n * branch, and the redirect it prints tells the agent to run them. An agent bounds tool output by\n * reflex, so it runs `git fetch origin main 2>&1 | tail -5` — and the guard, evaluating `tail -5` as\n * an ordinary command, denied the very remedy it had just printed. Verified pairs from the field:\n *\n * pnpm wp-cleanup allowed\n * pnpm wp-cleanup 2>&1 | tail -40 BLOCKED\n * git fetch origin main allowed\n * git fetch origin main 2>&1; echo BLOCKED\n *\n * Nothing in `| tail -40` or `; echo done` can touch the repo, and `done`/`do`/`for x in a b` are not\n * commands at all. So a segment is one of three things:\n *\n * - STRUCTURE — pure shell syntax, invokes nothing (`for b in a b c`, `done`, `fi`).\n * - SHAPING — cannot change the repo: a pager/filter fed by a PIPE (`| tail`, `| head`, `| wc`),\n * or an always-inert command (`echo`, `cd`, `pwd`, `true`).\n * - COMMAND — a real invocation, with `words` giving the effective argv AFTER leading shell\n * keywords are stripped, so `do gh pr list` classifies as `gh pr list`.\n *\n * Two things keep SHAPING honest. A filter counts only when a PIPE fed it — bare `tail src/x.ts`\n * reads the working tree and stays a COMMAND. And a segment carrying an output REDIRECT (`> file`,\n * `>> file`) is always a COMMAND, because `echo x > src/y.ts` writes the repo. `2>&1` is not a\n * redirect to a file and is deliberately not caught.\n *\n * Deciding WHETHER a shaping segment is acceptable is still the guard's call: merged-branch-bash-guard\n * pairs this with ContentReadScan so `git status | cat src/foo.ts` (a filter with a workspace path)\n * stays blocked.\n */\nexport type SegmentRole = 'structure' | 'shaping' | 'command';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class SegmentVerdict {\n role: SegmentRole;\n /** The effective argv for a COMMAND, leading shell keywords stripped. Empty for the other roles. */\n words: readonly string[];\n\n constructor(role: SegmentRole, words: readonly string[]) {\n this.role = role;\n this.words = words;\n }\n}\n\nexport class ShellSegmentScan {\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {}\n\n classify(segment: CommandSegment): SegmentVerdict {\n const words = this.stripKeywords(this.scanner.words(segment.text));\n if (words.length === 0) return STRUCTURE;\n\n const head = path.basename(words[0]);\n if (STRUCTURE_HEADS.has(head)) return STRUCTURE;\n\n // A redirect can create or overwrite a file, so it is never inert — judge it as a command.\n if (this.redirectsToFile(words)) return new SegmentVerdict('command', words);\n\n if (ALWAYS_INERT.has(head)) return new SegmentVerdict('shaping', words);\n if (segment.pipedInto && OUTPUT_FILTERS.has(head)) return new SegmentVerdict('shaping', words);\n\n return new SegmentVerdict('command', words);\n }\n\n /**\n * The effective argv of a segment with leading shell keywords removed — what a guard should judge\n * instead of the raw words. `for b in $(…); do git status; done` splits into three segments and\n * the middle one is literally `do git status`; without this, `do` is the command name and every\n * loop body walks straight past a git allowlist.\n */\n effectiveWords(segmentText: string): readonly string[] {\n return this.stripKeywords(this.scanner.words(segmentText));\n }\n\n private stripKeywords(words: readonly string[]): readonly string[] {\n let i = 0;\n while (i < words.length && STRIPPABLE_KEYWORDS.has(words[i])) i++;\n return words.slice(i);\n }\n\n /**\n * `>`, `>>`, `>out.txt`, `2>log` — but NOT `2>&1`/`1>&2`, which merely rewire fds.\n *\n * PUBLIC because RecoveryAllowlist asks the same question of a segment it is about to allow on the\n * strength of the program name alone (`curl`, `gh`): those cannot touch the tree by themselves, but\n * `curl … > src/x.ts` can. One implementation, so the two callers cannot disagree about what\n * counts as a redirect — the `2>&1` carve-out above is exactly the kind of detail a second copy\n * gets wrong.\n */\n redirectsToFile(words: readonly string[]): boolean {\n return words.some((word: string): boolean => REDIRECT_TO_FILE.test(word));\n }\n}\n\nconst STRUCTURE = new SegmentVerdict('structure', []);\n\n// Keywords that PRECEDE a real command; strip them and judge what follows.\nconst STRIPPABLE_KEYWORDS: ReadonlySet<string> = new Set([\n 'do', 'then', 'else', 'elif', 'if', 'while', 'until', '!', '{', '}', '(', ')',\n]);\n\n// Segments that invoke nothing at all: loop/case HEADERS (their tail is a word list, and any `$(…)`\n// inside was already split into its own segment by CommandScanner) and the closing keywords.\nconst STRUCTURE_HEADS: ReadonlySet<string> = new Set([\n 'for', 'case', 'select', 'done', 'fi', 'esac', ';;', 'in',\n]);\n\n// Commands that cannot read repo content or change the repo, piped or not.\nconst ALWAYS_INERT: ReadonlySet<string> = new Set([\n 'echo', 'printf', 'true', 'false', ':', 'cd', 'pwd', 'date', 'whoami', 'which', 'sleep', 'test', '[',\n]);\n\n// Pagers/filters that read STDIN. Only when a pipe fed them — bare, they read the working tree.\nconst OUTPUT_FILTERS: ReadonlySet<string> = new Set([\n 'head', 'tail', 'wc', 'cat', 'less', 'more', 'nl', 'tac', 'rev', 'sort', 'uniq', 'cut', 'tr',\n 'column', 'fold', 'expand', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'jq', 'yq',\n]);\n\nconst REDIRECT_TO_FILE = /^\\d*>>?(?!&)/;\n"]}
|
|
@@ -43,41 +43,54 @@ import { FixHint } from '../fix-hint';
|
|
|
43
43
|
* ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
|
|
44
44
|
* `git checkout -- <file>`, and any other branch.
|
|
45
45
|
*
|
|
46
|
-
* ──
|
|
46
|
+
* ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────
|
|
47
47
|
*
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* The finding is not "you are on `main`" — it is **"what you would read here is out of date"**. So the
|
|
49
|
+
* ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only
|
|
50
|
+
* when local `main` is known to be BEHIND `origin/main`. Unknown → allow. Current → allow.
|
|
51
51
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
52
|
+
* WHY, when this guard spent a release judging the branch alone: because the branch alone denies
|
|
53
|
+
* everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly
|
|
54
|
+
* where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` — the
|
|
55
|
+
* command this repo tells it to run — and the next `curl`, `gh pr close` or test run is refused by a
|
|
56
|
+
* guard whose own name says STALE. The tool that got it there could not be the cure for being there,
|
|
57
|
+
* and the refusal had nothing to do with staleness, which is the confusion reported from the field.
|
|
58
58
|
*
|
|
59
|
-
* THE
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
59
|
+
* THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right
|
|
60
|
+
* about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place
|
|
61
|
+
* whatever `main`'s freshness — unreviewable, and unrevertable as a unit — so feature-branch-guard
|
|
62
|
+
* stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms
|
|
63
|
+
* nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.
|
|
64
|
+
* Same tree, different hazard, so one precondition was never right for both.
|
|
65
65
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
66
|
+
* THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks "does local `main`
|
|
67
|
+
* already contain the cached `origin/main`?", so the block lifts the instant a pull lands rather than
|
|
68
|
+
* waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses — one
|
|
69
|
+
* implementation, so the Read and Bash halves of one state can never disagree about whether the pull
|
|
70
|
+
* took.
|
|
71
71
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
72
|
+
* FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:
|
|
73
|
+
* no cache (the first call of every session), a cache for another branch, an empty `originMain`
|
|
74
|
+
* (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is
|
|
75
|
+
* fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no
|
|
76
|
+
* synchronous `git fetch` is ever run on the blocking path.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape
|
|
79
|
+
* merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read
|
|
80
|
+
* BLOCKLIST could not replace it — enumerating readers catches `cat` and `grep`, and structurally
|
|
81
|
+
* cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands
|
|
82
|
+
* whose stated purpose is something else and whose effect is to modify tracked files. What changed is
|
|
83
|
+
* WHEN that polarity applies, not the polarity.
|
|
84
|
+
*
|
|
85
|
+
* BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,
|
|
86
|
+
* installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
|
|
87
|
+
* ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the
|
|
88
|
+
* blocked state, everything that gets you OUT or tells you where you are:
|
|
89
|
+
* `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,
|
|
90
|
+
* `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
|
|
91
|
+
* tree), `curl`/`wget`, every `wp-*` bin, installs.
|
|
92
|
+
*
|
|
93
|
+
* There is no dirty-tree valve here and none in read-stale-guard either: the cure is
|
|
81
94
|
* `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
|
|
82
95
|
* in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
|
|
83
96
|
*/
|
|
@@ -87,12 +100,26 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
|
|
|
87
100
|
private readonly recovery;
|
|
88
101
|
private readonly switches;
|
|
89
102
|
private readonly recoveryList;
|
|
103
|
+
private readonly freshness;
|
|
90
104
|
readonly description: string;
|
|
91
105
|
readonly defaultOptions: {
|
|
92
106
|
hangTimeoutMinutes: number;
|
|
93
107
|
};
|
|
94
108
|
readonly fixHint: FixHint;
|
|
95
109
|
check(ctx: BashContext): readonly Violation[];
|
|
110
|
+
/**
|
|
111
|
+
* ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`.
|
|
112
|
+
*
|
|
113
|
+
* Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and
|
|
114
|
+
* that is on purpose — the Read and the Bash halves of one state must not disagree about whether
|
|
115
|
+
* `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one
|
|
116
|
+
* file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the
|
|
117
|
+
* row 4 skip list already applied above.
|
|
118
|
+
*
|
|
119
|
+
* Every exit that is not "established BEHIND" is an ALLOW, and the not-established ones are
|
|
120
|
+
* `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.
|
|
121
|
+
*/
|
|
122
|
+
private checkFreshness;
|
|
96
123
|
/**
|
|
97
124
|
* The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
|
|
98
125
|
* command, or null. The pull is looked for across the WHOLE command, not the matched segment,
|
|
@@ -101,28 +128,27 @@ export declare class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuar
|
|
|
101
128
|
*/
|
|
102
129
|
private bareCheckoutOfMain;
|
|
103
130
|
/**
|
|
104
|
-
* The
|
|
131
|
+
* The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what
|
|
132
|
+
* the guard's name promised all along.
|
|
105
133
|
*
|
|
106
|
-
* The
|
|
107
|
-
*
|
|
108
|
-
* CURRENT `main`, and is still on `main`. The
|
|
134
|
+
* The two versions before this one are both instructive. The oldest opened with how many commits
|
|
135
|
+
* behind `main` was, which invited the wrong cure: an agent that reads "behind" reaches for a pull,
|
|
136
|
+
* lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said
|
|
137
|
+
* `main` is not a place to work "whether or not it is current" — true of a WRITE, but this guard
|
|
138
|
+
* does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.
|
|
109
139
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
140
|
+
* So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you
|
|
141
|
+
* read here is out of date; Bash is default-deny in this state rather than a list of readers,
|
|
142
|
+
* because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,
|
|
143
|
+
* so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN
|
|
144
|
+
* stays legitimate — on a CURRENT `main` nothing here fires at all.
|
|
115
145
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* `main` is behind, the reads are out of date too, so "I am only reading" is not a reason
|
|
121
|
-
* to skip the cure. The cure FETCHES, so it makes the reads true as well as moving you off
|
|
122
|
-
* `main` — which is why judging on the branch alone, before freshness is known, is the
|
|
123
|
-
* correct call rather than a crude approximation.
|
|
146
|
+
* ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`
|
|
147
|
+
* carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard
|
|
148
|
+
* prints it, because a Read really can be cured by staying put. A Bash session cannot — the next
|
|
149
|
+
* command is as likely to write as to read.)
|
|
124
150
|
*/
|
|
125
|
-
private
|
|
151
|
+
private staleMainMessage;
|
|
126
152
|
private pairingMessage;
|
|
127
153
|
/**
|
|
128
154
|
* The guard could not ESTABLISH the state it judges on, so it judged nothing.
|
|
@@ -16,6 +16,7 @@ const command_scan_1 = require("../command-scan");
|
|
|
16
16
|
const tree_recovery_1 = require("./tree-recovery");
|
|
17
17
|
const branch_switch_scan_1 = require("./branch-switch-scan");
|
|
18
18
|
const recovery_allowlist_1 = require("./recovery-allowlist");
|
|
19
|
+
const main_freshness_1 = require("./main-freshness");
|
|
19
20
|
/**
|
|
20
21
|
* The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:
|
|
21
22
|
* a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that
|
|
@@ -57,41 +58,54 @@ const recovery_allowlist_1 = require("./recovery-allowlist");
|
|
|
57
58
|
* ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
|
|
58
59
|
* `git checkout -- <file>`, and any other branch.
|
|
59
60
|
*
|
|
60
|
-
* ──
|
|
61
|
+
* ── ROWS 6/7: the block fires only once `main` is KNOWN STALE ────────────────────────────────
|
|
61
62
|
*
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
64
|
-
*
|
|
63
|
+
* The finding is not "you are on `main`" — it is **"what you would read here is out of date"**. So the
|
|
64
|
+
* ladder below asks the main-sync cache, exactly as read-stale-guard's State A does, and BLOCKS only
|
|
65
|
+
* when local `main` is known to be BEHIND `origin/main`. Unknown → allow. Current → allow.
|
|
65
66
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* WHY, when this guard spent a release judging the branch alone: because the branch alone denies
|
|
68
|
+
* everything off a narrow allowlist on a PERFECTLY CURRENT `main`, and a current `main` is exactly
|
|
69
|
+
* where the prescribed cure leaves you. An agent lands a PR, runs `pnpm wp-checkout-clean-main` — the
|
|
70
|
+
* command this repo tells it to run — and the next `curl`, `gh pr close` or test run is refused by a
|
|
71
|
+
* guard whose own name says STALE. The tool that got it there could not be the cure for being there,
|
|
72
|
+
* and the refusal had nothing to do with staleness, which is the confusion reported from the field.
|
|
72
73
|
*
|
|
73
|
-
* THE
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
74
|
+
* THE ASYMMETRY WITH `E` IS DELIBERATE, and the docblocks that argued `B` should track `E` were right
|
|
75
|
+
* about the mechanism and wrong about the policy. A WRITE on `main` creates work in the wrong place
|
|
76
|
+
* whatever `main`'s freshness — unreviewable, and unrevertable as a unit — so feature-branch-guard
|
|
77
|
+
* stays unconditional and keeps its one `git rev-parse`. A READ or a BUILD on a CURRENT `main` harms
|
|
78
|
+
* nothing, and blocking it strands the agent at the exact moment the prescribed cure put it there.
|
|
79
|
+
* Same tree, different hazard, so one precondition was never right for both.
|
|
79
80
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* THE ANCESTRY TEST, NOT HASH EQUALITY. `MainFreshness.containsOriginMain` asks "does local `main`
|
|
82
|
+
* already contain the cached `origin/main`?", so the block lifts the instant a pull lands rather than
|
|
83
|
+
* waiting for the detached refresher to catch up. It is the SAME object read-stale-guard uses — one
|
|
84
|
+
* implementation, so the Read and Bash halves of one state can never disagree about whether the pull
|
|
85
|
+
* took.
|
|
85
86
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
87
|
+
* FAIL-OPEN ON EVERYTHING NOT ESTABLISHED, logged as `ALLOW_FAIL_OPEN` so abstentions stay countable:
|
|
88
|
+
* no cache (the first call of every session), a cache for another branch, an empty `originMain`
|
|
89
|
+
* (offline), no local `main` at all (fresh clone / worktree), branch undeterminable. The refresher is
|
|
90
|
+
* fired detached on every call to keep the cache warm for the NEXT one; it is never waited on, and no
|
|
91
|
+
* synchronous `git fetch` is ever run on the blocking path.
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
93
|
+
* THE POLARITY INSIDE THE BLOCKED STATE IS UNCHANGED: default-DENY plus row 4's skip list, the shape
|
|
94
|
+
* merged-branch-bash-guard uses for state B, via the same shared RecoveryAllowlist. A content-read
|
|
95
|
+
* BLOCKLIST could not replace it — enumerating readers catches `cat` and `grep`, and structurally
|
|
96
|
+
* cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a `>` redirect: commands
|
|
97
|
+
* whose stated purpose is something else and whose effect is to modify tracked files. What changed is
|
|
98
|
+
* WHEN that polarity applies, not the polarity.
|
|
99
|
+
*
|
|
100
|
+
* BLOCKED on a `main` known to be BEHIND: anything not on the skip list — builds, tests,
|
|
101
|
+
* installers, formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.
|
|
102
|
+
* ALLOWED every command on a `main` that is current or whose freshness is unknown — and, in the
|
|
103
|
+
* blocked state, everything that gets you OUT or tells you where you are:
|
|
104
|
+
* `git checkout -b <new> origin/main`, `git switch`, `git pull`/`fetch`,
|
|
105
|
+
* `git status|log|diff|show|branch`, `git stash`, `gh` (it talks to GitHub, not to this
|
|
106
|
+
* tree), `curl`/`wget`, every `wp-*` bin, installs.
|
|
107
|
+
*
|
|
108
|
+
* There is no dirty-tree valve here and none in read-stale-guard either: the cure is
|
|
95
109
|
* `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody
|
|
96
110
|
* in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)
|
|
97
111
|
*/
|
|
@@ -103,10 +117,12 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
103
117
|
// ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states
|
|
104
118
|
// cannot drift apart about what "gets you out" means. See recovery-allowlist.ts.
|
|
105
119
|
recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
|
|
120
|
+
// The ancestry test and the cache summary, shared with read-stale-guard — see main-freshness.ts.
|
|
121
|
+
freshness = new main_freshness_1.MainFreshness();
|
|
106
122
|
description = 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +
|
|
107
|
-
'the same command), and block Bash
|
|
108
|
-
'
|
|
109
|
-
'
|
|
123
|
+
'the same command), and — once local main is KNOWN to be behind origin/main — block Bash ' +
|
|
124
|
+
'there, allowlisting only the commands that get you off it. A main that is current, or whose ' +
|
|
125
|
+
'freshness is unknown, is left alone.';
|
|
110
126
|
defaultOptions = {
|
|
111
127
|
hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
|
|
112
128
|
};
|
|
@@ -122,7 +138,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
122
138
|
new rules_config_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
|
|
123
139
|
+ '\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),
|
|
124
140
|
new rules_config_1.Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),
|
|
125
|
-
new rules_config_1.Option('Still allowed on main
|
|
141
|
+
new rules_config_1.Option('Still allowed: every BASH command, on a main that is current or whose freshness is not known — this guard only closes once local main is known to be BEHIND origin/main. (Write/Edit on main is a different policy and is blocked by feature-branch-guard however current main is.) In that state you keep the Read tool while main is current (read-stale-guard closes it when main falls behind, because stale reads are worthless) plus everything that gets you OUT or tells 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, and reading webpieces.config.json.'),
|
|
126
142
|
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.'),
|
|
127
143
|
]);
|
|
128
144
|
check(ctx) {
|
|
@@ -145,31 +161,42 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
145
161
|
if (this.recoveryList.isFullyRecovery(ctx)) {
|
|
146
162
|
return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');
|
|
147
163
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
164
|
+
return this.checkFreshness(ctx, branch);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* ROWS 6/7 — block ONLY when local `main` is KNOWN to be behind `origin/main`.
|
|
168
|
+
*
|
|
169
|
+
* Read this beside read-stale-guard.checkStaleMain: it is the same ladder over the same cache, and
|
|
170
|
+
* that is on purpose — the Read and the Bash halves of one state must not disagree about whether
|
|
171
|
+
* `main` is stale. What differs is the VERDICT SHAPE once it is stale, because a Read names one
|
|
172
|
+
* file and a Bash command is opaque: the Read is judged per file, Bash is default-deny plus the
|
|
173
|
+
* row 4 skip list already applied above.
|
|
174
|
+
*
|
|
175
|
+
* Every exit that is not "established BEHIND" is an ALLOW, and the not-established ones are
|
|
176
|
+
* `ALLOW_FAIL_OPEN` so they stay countable. A guard that cannot see the state judges nothing.
|
|
177
|
+
*/
|
|
178
|
+
checkFreshness(ctx, branch) {
|
|
179
|
+
const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, 'main');
|
|
180
|
+
// The first call of every session, and every call while another worktree holds the refresh
|
|
181
|
+
// lock. The refresher fired above populates it for the NEXT call; nothing waits here.
|
|
182
|
+
if (status === null)
|
|
183
|
+
return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
|
|
184
|
+
const cache = this.freshness.summarize(status);
|
|
185
|
+
// BELT-AND-BRACES since the cache became branch-keyed: we asked for the 'main' entry BY KEY, so
|
|
186
|
+
// a mismatch means the map's key and the entry's own `branch` disagree — a shape bug. Kept so
|
|
187
|
+
// that degrades to an allow. Unreachable in normal operation.
|
|
188
|
+
if (status.branch !== 'main')
|
|
189
|
+
return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);
|
|
190
|
+
// Offline / origin unresolvable, or no local main to compare against.
|
|
191
|
+
if (status.originMain === '')
|
|
192
|
+
return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
|
|
193
|
+
// ANCESTRY, not equality — the line that makes a pull take effect immediately. See
|
|
194
|
+
// MainFreshness.containsOriginMain.
|
|
195
|
+
if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {
|
|
196
|
+
return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
|
|
197
|
+
}
|
|
198
|
+
// ESTABLISHED BEHIND. Now — and only now — the default-deny polarity applies.
|
|
199
|
+
return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
|
|
173
200
|
}
|
|
174
201
|
/**
|
|
175
202
|
* The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same
|
|
@@ -190,35 +217,34 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
|
|
|
190
217
|
return null;
|
|
191
218
|
}
|
|
192
219
|
/**
|
|
193
|
-
* The
|
|
220
|
+
* The stale-`main` deny. Deliberately SHORT, and now deliberately ABOUT STALENESS — which is what
|
|
221
|
+
* the guard's name promised all along.
|
|
194
222
|
*
|
|
195
|
-
* The
|
|
196
|
-
*
|
|
197
|
-
* CURRENT `main`, and is still on `main`. The
|
|
223
|
+
* The two versions before this one are both instructive. The oldest opened with how many commits
|
|
224
|
+
* behind `main` was, which invited the wrong cure: an agent that reads "behind" reaches for a pull,
|
|
225
|
+
* lands on a CURRENT `main`, and is still on `main`. The one after it swung the other way and said
|
|
226
|
+
* `main` is not a place to work "whether or not it is current" — true of a WRITE, but this guard
|
|
227
|
+
* does not see writes, and it made every refusal on a freshly-pulled `main` unanswerable.
|
|
198
228
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
229
|
+
* So the text says what is now actually true and nothing more: local `main` is BEHIND, so what you
|
|
230
|
+
* read here is out of date; Bash is default-deny in this state rather than a list of readers,
|
|
231
|
+
* because a command's stated purpose never says whether it also WRITES; and the branch cure fetches,
|
|
232
|
+
* so it makes the reads true as well as moving the work somewhere reviewable. Reading `main` to PLAN
|
|
233
|
+
* stays legitimate — on a CURRENT `main` nothing here fires at all.
|
|
204
234
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* `main` is behind, the reads are out of date too, so "I am only reading" is not a reason
|
|
210
|
-
* to skip the cure. The cure FETCHES, so it makes the reads true as well as moving you off
|
|
211
|
-
* `main` — which is why judging on the branch alone, before freshness is known, is the
|
|
212
|
-
* correct call rather than a crude approximation.
|
|
235
|
+
* ONE cure, and it is the dirty-safe one, the same call feature-branch-guard made: `git checkout -b`
|
|
236
|
+
* carries uncommitted work onto the new branch. (Row 6 also lists the pull, and read-stale-guard
|
|
237
|
+
* prints it, because a Read really can be cured by staying put. A Bash session cannot — the next
|
|
238
|
+
* command is as likely to write as to read.)
|
|
213
239
|
*/
|
|
214
|
-
|
|
215
|
-
return 'Blocked:
|
|
216
|
-
+
|
|
217
|
-
+ '
|
|
218
|
-
+ '
|
|
219
|
-
+ '
|
|
220
|
-
+ '
|
|
221
|
-
+ '
|
|
240
|
+
staleMainMessage(workspaceRoot) {
|
|
241
|
+
return 'Blocked: local `main` is BEHIND origin/main, so what you would read here is out of '
|
|
242
|
+
+ 'date and a plan built on it is built on code upstream has moved past. A CURRENT main is '
|
|
243
|
+
+ 'not blocked — reading main to PLAN is fine, and this fires only once being behind is '
|
|
244
|
+
+ 'established. Bash is default-deny in this state rather than a list of readers, because a '
|
|
245
|
+
+ "command's stated purpose never says whether it also WRITES. The feature branch is the "
|
|
246
|
+
+ 'unit of work in any case: reviewable, revertable — and the cure fetches, so it makes '
|
|
247
|
+
+ 'your reads true as well as moving you off main.\n'
|
|
222
248
|
+ `Start a branch (uncommitted work comes with you):\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;
|
|
223
249
|
}
|
|
224
250
|
pairingMessage(ctx) {
|