create-agent-rig 0.4.0 → 0.5.0
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/CHANGELOG.md +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision router — which gate does this change deserve, and what does that
|
|
3
|
+
* cost.
|
|
4
|
+
*
|
|
5
|
+
* `pr-ship` is the merge-time gate and it always runs the expensive path: the
|
|
6
|
+
* full suite, then `code-reviewer` on every diff. That is right for a change
|
|
7
|
+
* that contains code and wrong for one that does not, and today there is no
|
|
8
|
+
* cheaper lane at all — a one-line typo fix in a README buys the same fan-out as
|
|
9
|
+
* a rewrite of the storage layer. This module is the dispatcher that decides
|
|
10
|
+
* *whether the expensive path is warranted*; it does not replace the gate and it
|
|
11
|
+
* never runs a reviewer itself.
|
|
12
|
+
*
|
|
13
|
+
* The ladder is `LANES`, in ascending order of cost, and `RISK_FLAGS` are
|
|
14
|
+
* evaluated **ahead of all three** — a flag escalates straight to `model`, so no
|
|
15
|
+
* cheap gate ever gets the chance to claim a change that carries one.
|
|
16
|
+
*
|
|
17
|
+
* 🔴 **The two directions of error are not symmetric, and every decision below
|
|
18
|
+
* is made in the safe one.** Routing an expensive change into a cheap lane loses
|
|
19
|
+
* the review — silently, and exactly on the diff that needed it. Routing a cheap
|
|
20
|
+
* change into the expensive lane costs tokens. So: an unclassifiable path, a
|
|
21
|
+
* rulebook document, a dependency manifest, a file under a declared elevated
|
|
22
|
+
* path, and an absent file list all resolve to the **expensive** answer. The
|
|
23
|
+
* cheap lanes are narrow on purpose and are meant to stay that way.
|
|
24
|
+
*
|
|
25
|
+
* ⚠ Read "under a declared elevated path" exactly, because it has one carve-out
|
|
26
|
+
* and the carve-out is inherited rather than chosen here: `elevatedPathsIn`
|
|
27
|
+
* treats **`.md`/`.mdx` files and test paths** that provision nothing as inert,
|
|
28
|
+
* so `infra/README.md` does not escalate while `infra/stack.ts` does. 🔴 Both
|
|
29
|
+
* halves are the SWEEP's definitions and neither matches this file's: its inert
|
|
30
|
+
* test paths are `test|tests|__tests__` directories and `.test.`/`.spec.`
|
|
31
|
+
* JS-flavoured names, while `isTestPath` here is wider; and its inert
|
|
32
|
+
* extensions are `.md`/`.mdx` while `PROSE_EXTENSIONS` here is `.md`/`.txt` —
|
|
33
|
+
* **neither set contains the other**, and the difference on `.mdx` is
|
|
34
|
+
* deliberate (MDX executes, see `PROSE_EXTENSIONS`). Aligning either way would
|
|
35
|
+
* do harm: widening the sweep stops `requirements.txt` in an elevated directory
|
|
36
|
+
* from escalating, and copying the sweep's `.mdx` back into this file's prose
|
|
37
|
+
* set reinstates a defect a 🔴 below spends a paragraph closing. They stay
|
|
38
|
+
* apart on purpose, and the difference is written here rather than discovered.
|
|
39
|
+
* A rulebook file
|
|
40
|
+
* is not inert wherever it sits. That is the sweep's definition and this file
|
|
41
|
+
* delegates to it on purpose — two answers to "is this path elevated" would
|
|
42
|
+
* disagree. **Neither cheap lane relies on that carve-out for a derived file:**
|
|
43
|
+
* both test the declared prefixes raw, so an inert-looking derived file under
|
|
44
|
+
* one cannot compose its way in. (Round four scoped this sentence to the
|
|
45
|
+
* no-reviewer lane; the mechanism gated both from the start, and the narrower
|
|
46
|
+
* claim understated the guard.)
|
|
47
|
+
*
|
|
48
|
+
* 🔴 **The lane is a value on stdout; the exit code says only that the router
|
|
49
|
+
* ran.** `0` never means "cheap" and non-zero never means "expensive" — a caller
|
|
50
|
+
* that chains this on `&&` reads *escalate to the model* as *go ahead*, which is
|
|
51
|
+
* the one misreading that turns a gate into a rubber stamp. Read the JSON.
|
|
52
|
+
*
|
|
53
|
+
* **What it deliberately does not do:** decide whether a review PASSED (that is
|
|
54
|
+
* the gate's job), run any reviewer, or write anything except journal records.
|
|
55
|
+
*
|
|
56
|
+
* ⚠ **The limits, stated rather than implied — all six of them.**
|
|
57
|
+
*
|
|
58
|
+
* 1. **It sees paths, never content.** A diff that guts a function inside
|
|
59
|
+
* `docs/` is invisible to it, and so is a secret pasted into a `.md`. It
|
|
60
|
+
* classifies by name because that is what a dispatcher can do in
|
|
61
|
+
* milliseconds before any expensive work starts; the layers behind it (the
|
|
62
|
+
* suite, the reviewers, CI) are what read content. A project whose risky code
|
|
63
|
+
* does not announce itself in its paths should widen `elevated-paths` rather
|
|
64
|
+
* than expect this file to guess.
|
|
65
|
+
* 2. **`derived` is a naming convention, not a proof.** A hand-authored
|
|
66
|
+
* `src/x.generated.ts` satisfies it. So **both cheap lanes** require a status
|
|
67
|
+
* saying the file was drift — `modified` or `removed`; everything else,
|
|
68
|
+
* including an entry with **no status at all** (the `--files` string form),
|
|
69
|
+
* is refused them — as is a derived file **under a declared elevated path**,
|
|
70
|
+
* whatever its status. A `modified` derived file elsewhere is still taken on
|
|
71
|
+
* trust, and that trust rests on the project having a check that regenerates
|
|
72
|
+
* it.
|
|
73
|
+
* 3. **The journal is written only when `RIG_RUN_DIR` is declared**, and only
|
|
74
|
+
* from the CLI — `route()` used as a library writes nothing. So an absent
|
|
75
|
+
* `decisions.jsonl` is the ordinary state of an undeclared run, and a reader
|
|
76
|
+
* auditing one must check the run declared a directory before reading
|
|
77
|
+
* absence as a gate that stopped firing.
|
|
78
|
+
* 4. **Exit 1 is not a lane, and STDOUT is the thing to read.** Exit 1 means
|
|
79
|
+
* nothing was routed — an unreadable diff, an absent file list, a project
|
|
80
|
+
* declaring no elevated path, an unrecognised flag, a `--base`/`--head` that
|
|
81
|
+
* is not a revision, or a run directory that is not there. The caller treats
|
|
82
|
+
* it as `model`; it is never a reason to skip the gate.
|
|
83
|
+
*
|
|
84
|
+
* ⚠ **Do not key on the `run journal:` prefix**, because both journal
|
|
85
|
+
* failures wear it and they end differently: a trace that can no longer
|
|
86
|
+
* accept records ends the TRACE, not the routing — the lane still prints and
|
|
87
|
+
* the exit stays 0 — while a run directory that was never there exits 1 with
|
|
88
|
+
* nothing on stdout. One rule covers both and every other case: **if a lane
|
|
89
|
+
* printed, read it; if stdout is empty, treat the change as `model`.**
|
|
90
|
+
* ⚠ And the diff it reads is the **committed** one, `<base>...<head>`: an
|
|
91
|
+
* uncommitted edit is not routed, so commit before routing.
|
|
92
|
+
* 5. **Case folding is deliberately asymmetric, and the residual is the FLAG,
|
|
93
|
+
* not the lane.** A rulebook file is recognised whatever the case of its
|
|
94
|
+
* name, and this file's own cheap-lane tests fold too — both directions that
|
|
95
|
+
* can only ESCALATE. The gate sweep's `elevatedPathsIn` does not fold, on
|
|
96
|
+
* purpose (`normalizePath` explains why). So against a declared `scripts/`,
|
|
97
|
+
* a file under `Scripts/` **routes to the same lane** as the correctly-cased
|
|
98
|
+
* path — `caseOnlyElevated` in `route` sees to that, and it inherits the
|
|
99
|
+
* sweep's inertness so `Scripts/README.md` stays as cheap as
|
|
100
|
+
* `scripts/README.md` — but `risks` comes back empty, so the trace does not
|
|
101
|
+
* say *why* it was expensive.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 This limit has now been wrong in **both** directions, which is why it
|
|
104
|
+
* is spelled out rather than summarised. It first claimed the residual was a
|
|
105
|
+
* stray `.txt` while a *derived* file under the mismatched directory reached
|
|
106
|
+
* the lane with no reviewer at all; the fix for that then claimed the lane
|
|
107
|
+
* was safe while a `.txt` still lost `code-reviewer`. Both are closed now,
|
|
108
|
+
* in code rather than in prose.
|
|
109
|
+
*
|
|
110
|
+
* ⚠ One genuine false negative survives, and ASCII is not where it lives:
|
|
111
|
+
* Unicode lowercasing is not prefix-preserving at a Greek final sigma, so a
|
|
112
|
+
* declared prefix ending mid-segment on `Σ` can fail to fold-match. It needs
|
|
113
|
+
* a declaration without a trailing slash, a path inert to the sweep, a
|
|
114
|
+
* derived-looking name and a trusted status all at once.
|
|
115
|
+
* 6. **`reviewers` is a floor, not a ceiling** — and this was measured on the
|
|
116
|
+
* router's own first run, not predicted. It returned `code-reviewer` and
|
|
117
|
+
* `prose-reviewer` for a diff that parses untrusted argv and git output,
|
|
118
|
+
* which `pr-ship`'s own trigger list calls a `security-scanner` case. Paths
|
|
119
|
+
* cannot see what code does. The gate's triggers apply on every lane and may
|
|
120
|
+
* only add.
|
|
121
|
+
*
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
import { execFileSync } from 'node:child_process';
|
|
125
|
+
import { realpathSync } from 'node:fs';
|
|
126
|
+
import { dirname, join } from 'node:path';
|
|
127
|
+
import { fileURLToPath } from 'node:url';
|
|
128
|
+
|
|
129
|
+
import {
|
|
130
|
+
elevatedPathsIn,
|
|
131
|
+
isDecisionRecord,
|
|
132
|
+
normalizePath,
|
|
133
|
+
readDeclaredPaths,
|
|
134
|
+
} from './detect-missed-gate.mjs';
|
|
135
|
+
import { withoutGitLocation } from './git-env.mjs';
|
|
136
|
+
|
|
137
|
+
/** The three lanes, cheapest first. The order is the dispatch order. */
|
|
138
|
+
export const LANES = Object.freeze(['deterministic', 'fast-path', 'model']);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every gate that produces a verdict, in evaluation order.
|
|
142
|
+
*
|
|
143
|
+
* 🔴 `risk-flags` is first and that placement is the contract. Evaluated after
|
|
144
|
+
* the cheap lanes it could escalate nothing — the change would already be
|
|
145
|
+
* routed, and the flag would be a comment rather than a control.
|
|
146
|
+
*/
|
|
147
|
+
export const GATES = Object.freeze(['risk-flags', 'deterministic', 'fast-path', 'model']);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The closed vocabulary a gate line may carry.
|
|
151
|
+
*
|
|
152
|
+
* `decline` and `skipped` are different facts and collapsing them is how a trace
|
|
153
|
+
* starts lying: a gate that was *evaluated and said no* tells you the router
|
|
154
|
+
* considered the cheap answer, and a gate that was *never reached* tells you an
|
|
155
|
+
* escalation happened above it. A reader who cannot tell those apart cannot tell
|
|
156
|
+
* a working router from one whose cheap lanes silently stopped matching.
|
|
157
|
+
*/
|
|
158
|
+
export const VERDICTS = Object.freeze(['clear', 'escalate', 'route', 'decline', 'skipped']);
|
|
159
|
+
|
|
160
|
+
/** What escalates a change ahead of the ladder. */
|
|
161
|
+
export const RISK_FLAGS = Object.freeze(['elevated-path', 'security-surface', 'test-removed']);
|
|
162
|
+
|
|
163
|
+
const DERIVED = 'derived';
|
|
164
|
+
const PROSE = 'prose';
|
|
165
|
+
const CODE = 'code';
|
|
166
|
+
const UNKNOWN = 'unknown';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Coerce a file entry to its path, accepting both shapes the callers have.
|
|
170
|
+
*
|
|
171
|
+
* `--files` yields plain strings; `git diff --name-status` yields a status too,
|
|
172
|
+
* and `test-removed` cannot be decided without it. Rather than force every
|
|
173
|
+
* caller into the richer shape, both travel in one list.
|
|
174
|
+
*/
|
|
175
|
+
const pathOf = (file) => {
|
|
176
|
+
if (typeof file === 'string') return file;
|
|
177
|
+
const path = file?.path;
|
|
178
|
+
return typeof path === 'string' ? path : '';
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const statusOf = (file) => {
|
|
182
|
+
const status = file?.status;
|
|
183
|
+
return typeof status === 'string' ? status : null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const segmentsOf = (path) => normalizePath(path).split('/').filter(Boolean);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The documents that instruct agents — where the prose IS the implementation.
|
|
190
|
+
*
|
|
191
|
+
* 🔴 Matched case-INSENSITIVELY, and the reason is a two-commit attack that was
|
|
192
|
+
* reproduced end to end: on a case-insensitive checkout (macOS default)
|
|
193
|
+
* `git mv CLAUDE.md claude.md` is accepted and recorded. The rename itself is
|
|
194
|
+
* caught, because a rename keeps its source path — but from the next commit on,
|
|
195
|
+
* the rulebook is in the prose lane forever. `readDeclaredPaths` still read that
|
|
196
|
+
* same file for its `elevated-paths` block, so the router was parsing it as the
|
|
197
|
+
* rulebook and refusing to classify it as one.
|
|
198
|
+
*
|
|
199
|
+
* ⚠ Case folding in this file is a whitelist, not a single site: this check,
|
|
200
|
+
* `isTestPath`'s directory scan, and `route`'s own prefix tests all fold —
|
|
201
|
+
* every one of them a direction that can only ESCALATE. What must NOT fold is
|
|
202
|
+
* `normalizePath`, and therefore `elevatedPathsIn`: that one decides the gate
|
|
203
|
+
* sweep's escalation, where folding creates false positives.
|
|
204
|
+
* `detect-missed-gate.mjs` states why.
|
|
205
|
+
*/
|
|
206
|
+
const RULEBOOK_BASENAMES = new Set([
|
|
207
|
+
'claude.md',
|
|
208
|
+
'agents.md',
|
|
209
|
+
'gemini.md',
|
|
210
|
+
'conventions.md',
|
|
211
|
+
'copilot-instructions.md',
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const isRulebookPath = (path) => {
|
|
215
|
+
const segments = segmentsOf(path);
|
|
216
|
+
if (segments.length === 0) return false;
|
|
217
|
+
if (RULEBOOK_BASENAMES.has(segments[segments.length - 1].toLowerCase())) return true;
|
|
218
|
+
for (const segment of segments) if (segment.toLowerCase() === '.claude') return true;
|
|
219
|
+
for (const segment of segments) {
|
|
220
|
+
const lowered = segment.toLowerCase();
|
|
221
|
+
if (lowered === '.agents' || lowered === '.codex') return true;
|
|
222
|
+
}
|
|
223
|
+
// A decision record carries rulebook rationale, so it is code here for the
|
|
224
|
+
// same reason a rule file is: the prose IS the implementation. Shared with
|
|
225
|
+
// the gate sweep rather than re-spelled — see `isDecisionRecord`.
|
|
226
|
+
//
|
|
227
|
+
// Fed the REJOINED, folded segments, not the raw argument: the predicate is
|
|
228
|
+
// case-sensitive by design for the sweep, and this file's case-folding
|
|
229
|
+
// whitelist above covers every check in it. A `git mv` to `docs/Decisions/`
|
|
230
|
+
// on a case-insensitive checkout would otherwise drop a record onto the
|
|
231
|
+
// prose lane — the exact move that whitelist exists to close. Rejoining also
|
|
232
|
+
// keeps this branch on the same normalised path as the two tests above it.
|
|
233
|
+
return isDecisionRecord(segments.join('/').toLowerCase());
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// 🔴 Deliberately EMPTY, and it held `.rig-manifest.json` for one review round.
|
|
237
|
+
// Nothing in a generated project regenerates or verifies that file — it is
|
|
238
|
+
// written once at scaffold time and read by `upgrade` to decide which files are
|
|
239
|
+
// locally modified. So the premise the cheap lane rests on ("a check already
|
|
240
|
+
// catches its drift") is false for it, on the one file that governs what
|
|
241
|
+
// `upgrade` may overwrite. A project that really does generate a fixed-name
|
|
242
|
+
// artifact adds it here, next to the check that regenerates it.
|
|
243
|
+
const DERIVED_BASENAMES = new Set();
|
|
244
|
+
// 🔴 `.mdx` is NOT here, and it was for four review rounds. MDX compiles to an
|
|
245
|
+
// ES module: it supports `import`/`export` and evaluates every `{…}`
|
|
246
|
+
// expression, so `app/page.mdx` is a route that executes. A single-file diff
|
|
247
|
+
// adding `import { execSync } …` to one routed to `fast-path`, and the router
|
|
248
|
+
// printed "the change carries no code" over it. This is not limit 1 — the path
|
|
249
|
+
// itself declares an executable format, and the router was reading it wrong.
|
|
250
|
+
// `DOC_EXTENSIONS` still sends it to `prose-reviewer` on top of the code review.
|
|
251
|
+
const PROSE_EXTENSIONS = new Set(['md', 'txt']);
|
|
252
|
+
const DOC_EXTENSIONS = new Set(['md', 'mdx', 'txt']);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Does this path LOOK derived — and it is only ever a look.
|
|
256
|
+
*
|
|
257
|
+
* ⚠ Nothing here verifies that the file is actually generator output; the
|
|
258
|
+
* classification is a naming convention, and a hand-authored file can satisfy
|
|
259
|
+
* it. That matters more than usual because `deterministic` is the lane that runs
|
|
260
|
+
* **no reviewer at all**, so the check on it is `route`'s status guard rather
|
|
261
|
+
* than this predicate: a derived-looking file that was ADDED cannot be drift
|
|
262
|
+
* against a generator, because there is no prior output for it to have drifted
|
|
263
|
+
* from.
|
|
264
|
+
*/
|
|
265
|
+
const looksDerived = (segments, basename) => {
|
|
266
|
+
if (DERIVED_BASENAMES.has(basename)) return true;
|
|
267
|
+
for (const segment of segments) if (segment === 'dist') return true;
|
|
268
|
+
// 🔴 A test SNAPSHOT is deliberately not here, and it was, for one review
|
|
269
|
+
// round. A snapshot is not output whose drift a generator check catches — it
|
|
270
|
+
// IS the behaviour claim, rewritten by the test run that then passes by
|
|
271
|
+
// construction. Routing one to the lane that launches no reviewer is
|
|
272
|
+
// "weaken a test to get to green" with a dispatcher doing the weakening.
|
|
273
|
+
return /\.generated\.[^.]+$/.test(basename);
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The statuses under which a derived file may reach the **no-reviewer** lane.
|
|
278
|
+
*
|
|
279
|
+
* Deliberately narrow, and the narrowness is the whole guard. `deterministic`
|
|
280
|
+
* rests on one claim — this file is generator output, so a check already catches
|
|
281
|
+
* its drift — and that claim needs a prior output to have drifted from. An
|
|
282
|
+
* `added` or `copied` file has none. A `renamed` one is new content at that
|
|
283
|
+
* path. And a status-less entry (the `--files` string form) has not been
|
|
284
|
+
* measured at all, which is not the same as measuring `modified`.
|
|
285
|
+
*/
|
|
286
|
+
const DERIVED_TRUSTED_STATUSES = new Set(['modified', 'removed']);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* What kind of file this is — the only judgement the cheap lanes are allowed to
|
|
290
|
+
* rest on.
|
|
291
|
+
*
|
|
292
|
+
* 🔴 A rulebook document classifies as `code`, and that is the single most
|
|
293
|
+
* expensive misroute this file exists to prevent: a change rewriting the
|
|
294
|
+
* autonomy tiers is a `.md`, and a router that read extensions would hand the
|
|
295
|
+
* Never list to the prose lane. In this layer the prose *is* the implementation.
|
|
296
|
+
*/
|
|
297
|
+
export const classifyFile = (file) => {
|
|
298
|
+
const raw = typeof file === 'string' ? file : '';
|
|
299
|
+
if (raw.trim() === '') return UNKNOWN;
|
|
300
|
+
|
|
301
|
+
const path = normalizePath(raw);
|
|
302
|
+
const segments = segmentsOf(path);
|
|
303
|
+
if (segments.length === 0) return UNKNOWN;
|
|
304
|
+
const basename = segments[segments.length - 1];
|
|
305
|
+
|
|
306
|
+
// Fail expensive first: a rulebook file is never derived and never prose,
|
|
307
|
+
// whatever it is called or where it sits.
|
|
308
|
+
if (isRulebookPath(path)) return CODE;
|
|
309
|
+
|
|
310
|
+
// 🔴 A test path is code too, and this line closes a claim the header used to
|
|
311
|
+
// make and the mechanism did not honour: "every test file classifies as
|
|
312
|
+
// `code`, so a change containing one cannot reach a cheap lane on
|
|
313
|
+
// classification alone". It did not hold for a fixture — `test/golden/
|
|
314
|
+
// expected.txt` and `test/fixtures/golden.md` classified as PROSE, so a
|
|
315
|
+
// deleted golden file reached `fast-path` with `prose-reviewer` as the whole
|
|
316
|
+
// gate. It also has to come before the derived look, or
|
|
317
|
+
// `packages/db/src/test/schema.generated.ts` composes two carve-outs into the
|
|
318
|
+
// lane that launches nobody.
|
|
319
|
+
if (isTestPath(path)) return CODE;
|
|
320
|
+
|
|
321
|
+
if (looksDerived(segments, basename)) return DERIVED;
|
|
322
|
+
|
|
323
|
+
const dot = basename.lastIndexOf('.');
|
|
324
|
+
const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
|
|
325
|
+
if (PROSE_EXTENSIONS.has(extension)) return PROSE;
|
|
326
|
+
|
|
327
|
+
return CODE;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/** Dependency manifests and lockfiles: a change here is the supply chain. */
|
|
331
|
+
const DEPENDENCY_FILES = new Set([
|
|
332
|
+
'package.json',
|
|
333
|
+
'pnpm-lock.yaml',
|
|
334
|
+
'package-lock.json',
|
|
335
|
+
'yarn.lock',
|
|
336
|
+
'npm-shrinkwrap.json',
|
|
337
|
+
// `.txt` is otherwise prose, so these two are actively DOWNGRADED rather than
|
|
338
|
+
// merely missed — and this layer is what `init` installs into a repo whose
|
|
339
|
+
// shape nobody here knows.
|
|
340
|
+
'requirements.txt',
|
|
341
|
+
'constraints.txt',
|
|
342
|
+
// 🔴 A `.txt` that is a BUILD SCRIPT, not prose. `CMakeLists.txt` runs
|
|
343
|
+
// `execute_process` and `FetchContent_Declare` at configure time — a new
|
|
344
|
+
// dependency, a new outbound destination and arbitrary shell, three of the
|
|
345
|
+
// gate's own `security-scanner` triggers — and it took the prose lane.
|
|
346
|
+
// `conanfile.txt` is C/C++'s `requirements.txt`. The same reasoning that
|
|
347
|
+
// pulled Python's manifests out of prose applies here and was simply not
|
|
348
|
+
// carried across.
|
|
349
|
+
'cmakelists.txt',
|
|
350
|
+
'conanfile.txt',
|
|
351
|
+
// Other ecosystems. These classify as `code` and so reach `model` anyway —
|
|
352
|
+
// what they buy is the `security-scanner` member of the reviewer floor, which
|
|
353
|
+
// a supply-chain edit is exactly the case for.
|
|
354
|
+
'pipfile',
|
|
355
|
+
'pipfile.lock',
|
|
356
|
+
'poetry.lock',
|
|
357
|
+
'pyproject.toml',
|
|
358
|
+
'gemfile',
|
|
359
|
+
'gemfile.lock',
|
|
360
|
+
'cargo.toml',
|
|
361
|
+
'cargo.lock',
|
|
362
|
+
'go.mod',
|
|
363
|
+
'go.sum',
|
|
364
|
+
'composer.json',
|
|
365
|
+
'composer.lock',
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The same manifests, in the forms a name-exact set cannot hold.
|
|
370
|
+
*
|
|
371
|
+
* `requirements-dev.txt`, `dev-requirements.txt` and `requirements/base.txt` are
|
|
372
|
+
* the shapes that actually appear, and each of them is `.txt` — the one prose
|
|
373
|
+
* extension a dependency manifest uses, so missing them does not merely fail to
|
|
374
|
+
* escalate, it actively downgrades a supply-chain edit to the prose lane.
|
|
375
|
+
*/
|
|
376
|
+
const isDependencyPath = (segments, basename) => {
|
|
377
|
+
const lower = basename.toLowerCase();
|
|
378
|
+
if (DEPENDENCY_FILES.has(lower)) return true;
|
|
379
|
+
if (!lower.endsWith('.txt')) return false;
|
|
380
|
+
// Matched on the STEM's words rather than on a shape. The shape form spelled
|
|
381
|
+
// `^(dev-)?requirements(-[a-z0-9.]+)?$` and missed `requirements_dev.txt`,
|
|
382
|
+
// `requirements-DEV.txt`, `requirements-dev-extra.txt` and
|
|
383
|
+
// `test-requirements.txt` — each of which then reached the PROSE lane, because
|
|
384
|
+
// `.txt` is the one prose extension a manifest uses.
|
|
385
|
+
// 🔴 The stem keeps its ORIGINAL CASE here. Pre-lowercasing destroyed the
|
|
386
|
+
// camel boundary `wordsOf` exists for — `requirementsDev` collapsed to one
|
|
387
|
+
// word and reached the prose lane — and buys nothing, since `wordsOf`
|
|
388
|
+
// lowercases every word it emits. Prefix-matched so a digit or a singular
|
|
389
|
+
// (`requirements2`, `requirement-dev`) cannot slip past either.
|
|
390
|
+
for (const word of wordsOf(basename.slice(0, -4))) {
|
|
391
|
+
if (word.startsWith('requirement') || word.startsWith('constraint')) return true;
|
|
392
|
+
}
|
|
393
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
394
|
+
const segment = segments[i].toLowerCase();
|
|
395
|
+
if (segment === 'requirements') return true;
|
|
396
|
+
// Go's vendor manifest is `vendor/modules.txt` and names neither word.
|
|
397
|
+
if (segment === 'vendor' && lower === 'modules.txt') return true;
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Files that ARE credentials, rather than files whose name mentions one.
|
|
404
|
+
*
|
|
405
|
+
* These reach `model` on classification anyway; what they buy is the
|
|
406
|
+
* `security-scanner` member of the reviewer floor, which the flag's own `why`
|
|
407
|
+
* already claims to cover.
|
|
408
|
+
*/
|
|
409
|
+
const SECRET_EXTENSIONS = new Set(['pem', 'key', 'p12', 'pfx', 'keystore', 'jks']);
|
|
410
|
+
const SECRET_BASENAMES = new Set(['.npmrc', '.netrc', '.pgpass', 'id_rsa', 'id_ed25519']);
|
|
411
|
+
|
|
412
|
+
const isSecretFile = (segments, basename) => {
|
|
413
|
+
if (SECRET_BASENAMES.has(basename)) return true;
|
|
414
|
+
if (basename === '.env' || basename.startsWith('.env.')) return true;
|
|
415
|
+
for (const segment of segments) if (segment === 'secrets' || segment === 'credentials') return true;
|
|
416
|
+
const dot = basename.lastIndexOf('.');
|
|
417
|
+
if (dot <= 0) return false;
|
|
418
|
+
const extension = basename.slice(dot + 1).toLowerCase();
|
|
419
|
+
// The extension is tested against the security words as well, because both
|
|
420
|
+
// sides stripped it before matching: `dist/local.env`, `dist/db.secret` and
|
|
421
|
+
// `dist/api.token` reached the lane that launches no reviewer, while
|
|
422
|
+
// `dist/svc.key` was caught — only because `key` happened to be an extension
|
|
423
|
+
// in the list. That inconsistency was the finding.
|
|
424
|
+
return SECRET_EXTENSIONS.has(extension) || SECURITY_WORDS.has(extension);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The words that make a path a security surface, matched as whole name-parts.
|
|
429
|
+
*
|
|
430
|
+
* Substring matching was the obvious form and it is wrong in the expensive
|
|
431
|
+
* direction *for the router*: `tokenizer.ts` and `authoring-guide.txt` are
|
|
432
|
+
* ordinary files this repository contains, and a router that escalates
|
|
433
|
+
* everything routes nothing — the cheap lanes stop being reachable and the whole
|
|
434
|
+
* mechanism becomes a slower way to do what `pr-ship` already did.
|
|
435
|
+
*/
|
|
436
|
+
const SECURITY_WORDS = new Set([
|
|
437
|
+
'auth',
|
|
438
|
+
'authn',
|
|
439
|
+
'authz',
|
|
440
|
+
'authenticate',
|
|
441
|
+
'authentication',
|
|
442
|
+
'authorization',
|
|
443
|
+
'authorize',
|
|
444
|
+
'oauth',
|
|
445
|
+
'oauth2',
|
|
446
|
+
'login',
|
|
447
|
+
'signin',
|
|
448
|
+
'signup',
|
|
449
|
+
'sso',
|
|
450
|
+
'saml',
|
|
451
|
+
'secret',
|
|
452
|
+
'secrets',
|
|
453
|
+
'credential',
|
|
454
|
+
'credentials',
|
|
455
|
+
'password',
|
|
456
|
+
'passwords',
|
|
457
|
+
'key',
|
|
458
|
+
'keys',
|
|
459
|
+
'apikey',
|
|
460
|
+
'apikeys',
|
|
461
|
+
'jwt',
|
|
462
|
+
'crypto',
|
|
463
|
+
'env',
|
|
464
|
+
'passwd',
|
|
465
|
+
'creds',
|
|
466
|
+
'cookie',
|
|
467
|
+
'csrf',
|
|
468
|
+
'cors',
|
|
469
|
+
'rbac',
|
|
470
|
+
'acl',
|
|
471
|
+
'iam',
|
|
472
|
+
'hmac',
|
|
473
|
+
'nonce',
|
|
474
|
+
'cert',
|
|
475
|
+
'certs',
|
|
476
|
+
'tls',
|
|
477
|
+
'mfa',
|
|
478
|
+
'totp',
|
|
479
|
+
'otp',
|
|
480
|
+
'bearer',
|
|
481
|
+
'oidc',
|
|
482
|
+
'ldap',
|
|
483
|
+
'authorise',
|
|
484
|
+
'authorised',
|
|
485
|
+
'authorisation',
|
|
486
|
+
'token',
|
|
487
|
+
'tokens',
|
|
488
|
+
'session',
|
|
489
|
+
'sessions',
|
|
490
|
+
'permission',
|
|
491
|
+
'permissions',
|
|
492
|
+
]);
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Split one path segment's stem into the words a name is made of.
|
|
496
|
+
*
|
|
497
|
+
* 🔴 **One forward pass, written by hand because the regex form was quadratic.**
|
|
498
|
+
* The obvious spelling — `replace(/([A-Z]+)([A-Z][a-z])/g, …)` to break an
|
|
499
|
+
* acronym off the word after it — backtracks the whole remaining run of capitals
|
|
500
|
+
* at every start position. Measured on a stem of N capitals reaching `route()`:
|
|
501
|
+
* 8k → 93 ms, 32k → 1.5 s, 100k → 14 s. And a 40 000-character path component
|
|
502
|
+
* needs no checkout to construct: `git mktree` puts it in a tree and
|
|
503
|
+
* `git diff --name-status` hands it straight back.
|
|
504
|
+
*
|
|
505
|
+
* This is a stall rather than a bypass — the router does not fail open, and a
|
|
506
|
+
* timeout exits 1, which the caller reads as `model`. It is fixed anyway,
|
|
507
|
+
* because `invariants.md` names this exact shape as the lesson that cost the
|
|
508
|
+
* most, and because the comment that used to sit here claimed "no backtracking
|
|
509
|
+
* regex" while pointing at one.
|
|
510
|
+
*
|
|
511
|
+
* A word boundary is a separator (`-`, `_`, `.`, space) or a camel hump: an
|
|
512
|
+
* uppercase char that either follows a non-uppercase one (`authService`) or is
|
|
513
|
+
* the last of a run before a lowercase one (`JWTVerify` → `jwt`, `verify`).
|
|
514
|
+
*/
|
|
515
|
+
const wordsOf = (stem) => {
|
|
516
|
+
const words = [];
|
|
517
|
+
const isUpper = (c) => c !== undefined && c >= 'A' && c <= 'Z';
|
|
518
|
+
const isLower = (c) => c !== undefined && c >= 'a' && c <= 'z';
|
|
519
|
+
const isDigit = (c) => c !== undefined && c >= '0' && c <= '9';
|
|
520
|
+
let start = 0;
|
|
521
|
+
const cut = (end) => {
|
|
522
|
+
if (end > start) words.push(stem.slice(start, end).toLowerCase());
|
|
523
|
+
start = end;
|
|
524
|
+
};
|
|
525
|
+
for (let i = 0; i < stem.length; i += 1) {
|
|
526
|
+
const ch = stem[i];
|
|
527
|
+
if (ch === '-' || ch === '_' || ch === '.' || ch === ' ') {
|
|
528
|
+
cut(i);
|
|
529
|
+
start = i + 1;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (i > start && isUpper(ch) && (!isUpper(stem[i - 1]) || isLower(stem[i + 1]))) cut(i);
|
|
533
|
+
// A digit after a letter is a boundary too: `auth2`, `oauth2` and
|
|
534
|
+
// `requirements2` each hid a whole word behind one character.
|
|
535
|
+
else if (i > start && isDigit(ch) && !isDigit(stem[i - 1])) cut(i);
|
|
536
|
+
// …and the mirror, which was missing: `v2auth`, `s3credentials` and
|
|
537
|
+
// `api2key` hid a whole word behind one leading character exactly the way
|
|
538
|
+
// `auth2` hid one behind a trailing character. Verified against every
|
|
539
|
+
// documented negative — `utf8parser`, `sha256hash`, `base64`, `http2server`
|
|
540
|
+
// all stay clean.
|
|
541
|
+
else if (i > start && !isDigit(ch) && isDigit(stem[i - 1])) cut(i);
|
|
542
|
+
}
|
|
543
|
+
cut(stem.length);
|
|
544
|
+
return words;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const isSecuritySurface = (path) => {
|
|
548
|
+
const segments = segmentsOf(path);
|
|
549
|
+
if (segments.length === 0) return false;
|
|
550
|
+
const basename = segments[segments.length - 1];
|
|
551
|
+
if (isDependencyPath(segments, basename)) return true;
|
|
552
|
+
if (isSecretFile(segments, basename)) return true;
|
|
553
|
+
|
|
554
|
+
for (const segment of segments) {
|
|
555
|
+
// One forward pass per segment: strip the extension, then split the stem on
|
|
556
|
+
// the separators a filename actually uses — including a camelCase boundary,
|
|
557
|
+
// because `authService.ts` is the same file as `auth-service.ts` and only
|
|
558
|
+
// one of them was being seen. No rescanning, no backtracking regex — the
|
|
559
|
+
// input is a path from a diff and its length is not ours.
|
|
560
|
+
const dot = segment.lastIndexOf('.');
|
|
561
|
+
const stem = dot > 0 ? segment.slice(0, dot) : segment;
|
|
562
|
+
for (const word of wordsOf(stem)) {
|
|
563
|
+
if (SECURITY_WORDS.has(word)) return true;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const TEST_DIRECTORIES = new Set([
|
|
570
|
+
'test',
|
|
571
|
+
'tests',
|
|
572
|
+
'__tests__',
|
|
573
|
+
'spec',
|
|
574
|
+
'specs',
|
|
575
|
+
'e2e',
|
|
576
|
+
'cypress',
|
|
577
|
+
]);
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Directory names that are a test root in one ecosystem and a documentation
|
|
581
|
+
* directory in another — resolved by the EXTENSION rather than by picking a
|
|
582
|
+
* side, because picking a side oscillates.
|
|
583
|
+
*
|
|
584
|
+
* 🔴 Both choices were made in this branch and both were wrong. Treating them
|
|
585
|
+
* as test roots made `docs/features/login.md` code, narrowing the very lane
|
|
586
|
+
* this module exists to open. Removing them put `integration/fixtures/
|
|
587
|
+
* expected.txt` (deleted) back on the prose lane and
|
|
588
|
+
* `integration/schema.generated.ts` into the lane that launches nobody — giving
|
|
589
|
+
* back, for two directory names, exactly the coverage the fixture fix had won.
|
|
590
|
+
*
|
|
591
|
+
* A `.md`/`.mdx` file under one of these is treated as documentation BY THIS
|
|
592
|
+
* PREDICATE; anything else is a test artifact. ⚠ That is not the same as
|
|
593
|
+
* reaching the prose lane — `.mdx` is code to the ladder (see
|
|
594
|
+
* `PROSE_EXTENSIONS`), so `integration/x.mdx` still routes `model`. Those two extensions and no more, deliberately: `.txt` under
|
|
595
|
+
* `integration/` is a golden file far more often than it is prose, and it was
|
|
596
|
+
* the `.txt` fixture that broke. The pair matches the gate sweep's own inert
|
|
597
|
+
* extensions, which is the one other place this distinction is drawn.
|
|
598
|
+
*/
|
|
599
|
+
const AMBIGUOUS_TEST_DIRECTORIES = new Set(['integration', 'features']);
|
|
600
|
+
const DOC_ONLY_EXTENSIONS = new Set(['md', 'mdx']);
|
|
601
|
+
|
|
602
|
+
const isTestPath = (path) => {
|
|
603
|
+
const segments = segmentsOf(path);
|
|
604
|
+
if (segments.length === 0) return false;
|
|
605
|
+
const basename = segments[segments.length - 1];
|
|
606
|
+
if (/\.(test|spec)\.[^.]+$/.test(basename)) return true;
|
|
607
|
+
// The JS-flavoured `.test.`/`.spec.` form was the only one recognised, so a
|
|
608
|
+
// deleted `critical_spec.generated.rb` reached the lane that launches no
|
|
609
|
+
// reviewer. `test_x.py`, `x_test.go` and `x_spec.rb` are tests too.
|
|
610
|
+
const dot = basename.lastIndexOf('.');
|
|
611
|
+
const stem = dot > 0 ? basename.slice(0, dot) : basename;
|
|
612
|
+
const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
|
|
613
|
+
const words = wordsOf(stem);
|
|
614
|
+
// Any word, not just the first or last: `critical_spec.generated.rb` puts it
|
|
615
|
+
// in the middle, and that exact name reached the no-reviewer lane when the
|
|
616
|
+
// check looked only at the ends. `words.length > 1` keeps a file simply named
|
|
617
|
+
// `spec.ts` out of it; over-escalating a `spec-loader.ts` that was DELETED is
|
|
618
|
+
// the safe direction and costs one reviewer.
|
|
619
|
+
if (words.length > 1) {
|
|
620
|
+
for (const word of words) {
|
|
621
|
+
if (word === 'test' || word === 'tests' || word === 'spec' || word === 'specs') return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
625
|
+
// Folded, for the same reason `isRulebookPath` folds: `Tests/`, `Test/` and
|
|
626
|
+
// `Spec/` are the ordinary conventions in .NET, Java and Swift, and on a
|
|
627
|
+
// case-insensitive checkout `git mv test Test` also survives — unlike the
|
|
628
|
+
// rulebook rename, silently, because test runners glob.
|
|
629
|
+
const segment = segments[i].toLowerCase();
|
|
630
|
+
if (TEST_DIRECTORIES.has(segment)) return true;
|
|
631
|
+
if (AMBIGUOUS_TEST_DIRECTORIES.has(segment) && !DOC_ONLY_EXTENSIONS.has(extension)) return true;
|
|
632
|
+
}
|
|
633
|
+
return false;
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The risk flags this change carries — at most one entry per flag, each naming
|
|
638
|
+
* the files that earned it.
|
|
639
|
+
*
|
|
640
|
+
* `elevated-path` is delegated to `elevatedPathsIn` rather than re-derived here.
|
|
641
|
+
* **One mechanism, one implementation** (`invariants.md`): two files deciding
|
|
642
|
+
* "is this path elevated" disagree the first time either changes, and the one
|
|
643
|
+
* nobody is looking at is the wrong one. It also inherits, for free, the two
|
|
644
|
+
* subtleties that copy would have lost — inert prose inside an elevated
|
|
645
|
+
* directory does not count, and a rulebook file counts wherever it sits.
|
|
646
|
+
*
|
|
647
|
+
* ⚠ **`test-removed` needs a status, and without one it is not evaluated.** A
|
|
648
|
+
* plain string list cannot say whether a file was deleted, so this flag stays
|
|
649
|
+
* silent there rather than guessing either way. That is safe and not a hole:
|
|
650
|
+
* every test file classifies as `code`, so a change containing one cannot reach
|
|
651
|
+
* a cheap lane on classification alone. The flag buys the *reason* being visible
|
|
652
|
+
* in the trace, not the escalation itself.
|
|
653
|
+
*/
|
|
654
|
+
export const riskFlagsIn = (files, { elevatedPaths = [] } = {}) => {
|
|
655
|
+
const list = Array.isArray(files) ? files : [];
|
|
656
|
+
|
|
657
|
+
const paths = [];
|
|
658
|
+
for (const file of list) paths.push(pathOf(file));
|
|
659
|
+
|
|
660
|
+
const elevated = elevatedPathsIn(paths, elevatedPaths);
|
|
661
|
+
|
|
662
|
+
const security = [];
|
|
663
|
+
const removedTests = [];
|
|
664
|
+
for (const file of list) {
|
|
665
|
+
const path = pathOf(file);
|
|
666
|
+
if (path === '') continue;
|
|
667
|
+
if (isSecuritySurface(path)) security.push(path);
|
|
668
|
+
if (statusOf(file) === 'removed' && isTestPath(path)) removedTests.push(path);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const flags = [];
|
|
672
|
+
if (elevated.length > 0) {
|
|
673
|
+
flags.push({
|
|
674
|
+
flag: 'elevated-path',
|
|
675
|
+
files: elevated,
|
|
676
|
+
why: 'a changed file sits under a path this project declares elevated, so the change is Tier 2 by what it touches',
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
if (security.length > 0) {
|
|
680
|
+
flags.push({
|
|
681
|
+
flag: 'security-surface',
|
|
682
|
+
files: security,
|
|
683
|
+
why: 'a changed file is a dependency manifest or names auth, secrets, tokens, sessions or permissions',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (removedTests.length > 0) {
|
|
687
|
+
flags.push({
|
|
688
|
+
flag: 'test-removed',
|
|
689
|
+
files: removedTests,
|
|
690
|
+
why:
|
|
691
|
+
'a test file left its path — deleted outright, or renamed away, which the diff \nreports the same way. Deleting a test to reach green is on the Never tier',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return flags;
|
|
695
|
+
};
|
|
696
|
+
|
|
697
|
+
const line = (gate, verdict, why) => ({ gate, verdict, why });
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Route one change.
|
|
701
|
+
*
|
|
702
|
+
* Returns `{ lane, reviewers, gates, risks, why }`. `gates` carries **one entry
|
|
703
|
+
* per member of `GATES`, always, in `GATES` order** — including the ones that
|
|
704
|
+
* were never reached, which say `skipped` and name the escalation. An absent
|
|
705
|
+
* line is indistinguishable from a declined one and from a gate that crashed,
|
|
706
|
+
* and a trace that cannot tell those apart is the shape a silently disabled gate
|
|
707
|
+
* hides in.
|
|
708
|
+
*
|
|
709
|
+
* 🔴 It **refuses** an absent or non-array file list rather than routing it. A
|
|
710
|
+
* zero and an unknown look identical in a count and mean opposite things: the
|
|
711
|
+
* permissive reading here is "nothing changed, take the cheapest lane", which is
|
|
712
|
+
* precisely the wrong answer written confidently.
|
|
713
|
+
*/
|
|
714
|
+
export const route = ({ files, elevatedPaths } = {}) => {
|
|
715
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
'the decision router needs the changed file list of the change being routed ' +
|
|
718
|
+
'(`git diff --name-status <base>...<head>`). An empty or missing list is an ' +
|
|
719
|
+
'absence, not a cheap change, and routing on it would send an unmeasured diff ' +
|
|
720
|
+
'down the lane that reviews least.',
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 🔴 The same refusal the CLI makes, made here so a library caller cannot
|
|
725
|
+
// reach the permissive answer the CLI is careful to avoid. With no
|
|
726
|
+
// declaration the `elevated-path` flag is not evaluated at all, and the gate
|
|
727
|
+
// line would then read `risk-flags clear — no risk flag fired`: an
|
|
728
|
+
// unevaluated check presenting as a pass, in the file whose whole argument is
|
|
729
|
+
// that those two must never look alike.
|
|
730
|
+
if (!Array.isArray(elevatedPaths) || elevatedPaths.length === 0) {
|
|
731
|
+
throw new Error(
|
|
732
|
+
'the decision router needs the project\'s declared elevated paths, and refuses ' +
|
|
733
|
+
'rather than routing without them: the `elevated-path` risk flag cannot be ' +
|
|
734
|
+
'evaluated against an empty declaration, and reporting that as "no risk flag ' +
|
|
735
|
+
'fired" would be an absence dressed as a pass. Read them with ' +
|
|
736
|
+
'`readDeclaredPaths(projectRoot)`.',
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const risks = riskFlagsIn(files, { elevatedPaths });
|
|
741
|
+
|
|
742
|
+
if (risks.length > 0) {
|
|
743
|
+
const named = risks.map((risk) => risk.flag).join(', ');
|
|
744
|
+
const why = `risk flags escalated ahead of the ladder: ${named}`;
|
|
745
|
+
return {
|
|
746
|
+
lane: 'model',
|
|
747
|
+
reviewers: reviewersFor(files, risks),
|
|
748
|
+
risks,
|
|
749
|
+
why,
|
|
750
|
+
gates: [
|
|
751
|
+
line('risk-flags', 'escalate', why),
|
|
752
|
+
line('deterministic', 'skipped', 'not evaluated — risk flags escalated ahead of it'),
|
|
753
|
+
line('fast-path', 'skipped', 'not evaluated — risk flags escalated ahead of it'),
|
|
754
|
+
line('model', 'route', 'the expensive path is warranted: a risk flag fired'),
|
|
755
|
+
],
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Three counts, because `derived` splits. It is a naming convention a
|
|
760
|
+
// hand-authored file can satisfy, so a derived file only counts as cheap when
|
|
761
|
+
// git says it was drift (`DERIVED_TRUSTED_STATUSES`); anything else is
|
|
762
|
+
// `derivedUntrusted`.
|
|
763
|
+
//
|
|
764
|
+
// 🔴 **Both cheap lanes consult it, and for one review round only one did.**
|
|
765
|
+
// The reasoning that dropped it from `fast-path` — "that lane still has a
|
|
766
|
+
// cold reader" — was wrong about which reader: `prose-reviewer` is scoped to
|
|
767
|
+
// documents that instruct agents, not to a new `.ts` file. Measured
|
|
768
|
+
// consequence: a diff adding `src/x.generated.ts` alongside one `.md` edit
|
|
769
|
+
// routed to `fast-path` and no reviewer read the code. Adding a derived-
|
|
770
|
+
// looking filename was a one-line way to drop `code-reviewer`.
|
|
771
|
+
// The raw prefix test, deliberately NOT the inert-aware one. `elevatedPathsIn`
|
|
772
|
+
// drops prose and test paths as inert, which is right for the gate sweep and
|
|
773
|
+
// wrong for the lane that launches nobody: composing that carve-out with the
|
|
774
|
+
// derived one put `packages/db/src/test/x.generated.ts` — a declared elevated
|
|
775
|
+
// path — into `deterministic` with zero reviewers. Each half is documented;
|
|
776
|
+
// the composition was not, and it contradicted this file's own headline.
|
|
777
|
+
//
|
|
778
|
+
// 🔴 Folded to lower case. `normalizePath` preserves case on purpose —
|
|
779
|
+
// folding THERE would create false positives in the gate sweep's escalation
|
|
780
|
+
// decision. These tests are the opposite direction: they can only move a file
|
|
781
|
+
// OUT of a cheap lane, so folding costs a few extra reviews and closes a real
|
|
782
|
+
// hole. Measured: with a declared `scripts/`, a case-mismatched
|
|
783
|
+
// `Scripts/y.generated.ts` reported `M` reached `deterministic` with ZERO
|
|
784
|
+
// reviewers — and no rename is needed, a repo that simply spells the
|
|
785
|
+
// directory differently from its declaration has it from day one.
|
|
786
|
+
const declared = elevatedPaths.map(normalizePath);
|
|
787
|
+
const folded = declared.map((prefix) => prefix.toLowerCase());
|
|
788
|
+
const underDeclaredPath = (path) => {
|
|
789
|
+
const normalized = normalizePath(path).toLowerCase();
|
|
790
|
+
return folded.some((prefix) => normalized.startsWith(prefix));
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* A file the sweep WOULD have called elevated if the case had matched.
|
|
795
|
+
*
|
|
796
|
+
* 🔴 This closes the residual rather than documenting it, and the shape
|
|
797
|
+
* matters: it asks `elevatedPathsIn` the same question twice, once folded and
|
|
798
|
+
* once not, so it inherits the sweep's inertness in BOTH answers. That is
|
|
799
|
+
* what keeps it consistent instead of merely stricter — `Scripts/notes.txt`
|
|
800
|
+
* now routes exactly where `scripts/notes.txt` routes (`model`, because
|
|
801
|
+
* `.txt` is not inert), and `Scripts/README.md` routes exactly where
|
|
802
|
+
* `scripts/README.md` routes (`fast-path`, because `.md` is).
|
|
803
|
+
*
|
|
804
|
+
* The measured hole: with a declared `scripts/`, `Scripts/notes.txt` reached
|
|
805
|
+
* `fast-path` with `prose-reviewer` as the whole gate while the same file
|
|
806
|
+
* spelled `scripts/` reached `model`. No rename is needed — a repo that
|
|
807
|
+
* spells the directory differently from its declaration has it from day one.
|
|
808
|
+
*
|
|
809
|
+
* What remains after this is the **flag**, not the lane: the change routes
|
|
810
|
+
* expensively, and `risks` stays empty because `elevatedPathsIn` is the
|
|
811
|
+
* sweep's answer and the sweep does not fold. That is limit 5, and it is now
|
|
812
|
+
* the whole of it.
|
|
813
|
+
*/
|
|
814
|
+
const caseOnlyElevated = (path) =>
|
|
815
|
+
elevatedPathsIn([path.toLowerCase()], folded).length > 0 &&
|
|
816
|
+
elevatedPathsIn([path], declared).length === 0;
|
|
817
|
+
|
|
818
|
+
let prose = 0;
|
|
819
|
+
let derivedUntrusted = 0;
|
|
820
|
+
let derivedUnderDeclaredPath = 0;
|
|
821
|
+
let other = 0;
|
|
822
|
+
for (const file of files) {
|
|
823
|
+
const path = pathOf(file);
|
|
824
|
+
const kind = classifyFile(path);
|
|
825
|
+
// Ahead of the classification, because it disqualifies every kind: a file
|
|
826
|
+
// whose only escape from the declaration is how its directory is spelled
|
|
827
|
+
// must not reach a cheap lane on being prose either.
|
|
828
|
+
if (caseOnlyElevated(path)) other += 1;
|
|
829
|
+
else if (kind === PROSE) prose += 1;
|
|
830
|
+
else if (kind !== DERIVED) other += 1; // code and unknown alike: the expensive answer
|
|
831
|
+
else if (underDeclaredPath(path)) {
|
|
832
|
+
// Counted apart from the status case so the gate line can name WHICH of
|
|
833
|
+
// the two refused the lane. One reason string covering both said "no
|
|
834
|
+
// status saying it was drift" about a file git reported as `M`, and that
|
|
835
|
+
// string is what lands in `decisions.jsonl`.
|
|
836
|
+
derivedUnderDeclaredPath += 1;
|
|
837
|
+
derivedUntrusted += 1;
|
|
838
|
+
} else if (!DERIVED_TRUSTED_STATUSES.has(statusOf(file))) derivedUntrusted += 1;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const clear = line('risk-flags', 'clear', 'no risk flag fired on the changed paths');
|
|
842
|
+
|
|
843
|
+
const everyFileDerived = other === 0 && prose === 0;
|
|
844
|
+
|
|
845
|
+
if (everyFileDerived && derivedUntrusted === 0) {
|
|
846
|
+
return {
|
|
847
|
+
lane: 'deterministic',
|
|
848
|
+
reviewers: [],
|
|
849
|
+
risks,
|
|
850
|
+
why: 'every changed file is a derived artifact whose drift a mechanical check already catches',
|
|
851
|
+
gates: [
|
|
852
|
+
clear,
|
|
853
|
+
line(
|
|
854
|
+
'deterministic',
|
|
855
|
+
'route',
|
|
856
|
+
'every changed file is derived — the checks are the review, and a model would re-read the generator',
|
|
857
|
+
),
|
|
858
|
+
line('fast-path', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
|
|
859
|
+
line('model', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
|
|
860
|
+
],
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// 🔴 The reason has to name which of the two disqualified the lane. A verdict
|
|
865
|
+
// saying "not every changed file is a derived artifact" about a change where
|
|
866
|
+
// every file IS one is a false line in `decisions.jsonl` — and that line is
|
|
867
|
+
// the only record of why the cheap gate refused.
|
|
868
|
+
const declinedDeterministic = line(
|
|
869
|
+
'deterministic',
|
|
870
|
+
'decline',
|
|
871
|
+
!everyFileDerived
|
|
872
|
+
? 'not every changed file is a derived artifact'
|
|
873
|
+
: derivedUnderDeclaredPath > 0
|
|
874
|
+
? 'every changed file is derived, but at least one sits under a path this project declares elevated'
|
|
875
|
+
: 'every changed file is derived, but at least one carries no status saying it was drift — an addition, a copy, a rename, a type change, or a list with no statuses at all',
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
if (other === 0 && derivedUntrusted === 0 && prose > 0) {
|
|
879
|
+
return {
|
|
880
|
+
lane: 'fast-path',
|
|
881
|
+
reviewers: ['prose-reviewer'],
|
|
882
|
+
risks,
|
|
883
|
+
why: 'the change is documentation outside the rulebook, with any derived file travelling with it reported as drift',
|
|
884
|
+
gates: [
|
|
885
|
+
clear,
|
|
886
|
+
declinedDeterministic,
|
|
887
|
+
line(
|
|
888
|
+
'fast-path',
|
|
889
|
+
'route',
|
|
890
|
+
'nothing in the change classifies as code or as a rulebook document — `code-reviewer` has nothing to read',
|
|
891
|
+
),
|
|
892
|
+
line('model', 'skipped', 'not evaluated — a cheaper gate claimed the change'),
|
|
893
|
+
],
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Same rule one lane down: say which count sent it here. There are two
|
|
898
|
+
// reasons and they are not the same finding.
|
|
899
|
+
const reasons = [];
|
|
900
|
+
if (other > 0) reasons.push('code, a rulebook document, or a path the router could not classify');
|
|
901
|
+
if (derivedUnderDeclaredPath > 0) {
|
|
902
|
+
reasons.push('a derived artifact under a path this project declares elevated');
|
|
903
|
+
}
|
|
904
|
+
// Strictly greater, so a single file tripping BOTH is reported once by its
|
|
905
|
+
// sharper reason rather than twice. A diff carrying one of each names both.
|
|
906
|
+
if (derivedUntrusted > derivedUnderDeclaredPath) {
|
|
907
|
+
reasons.push('a derived artifact with no status saying it was drift');
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return {
|
|
911
|
+
lane: 'model',
|
|
912
|
+
reviewers: reviewersFor(files, risks),
|
|
913
|
+
risks,
|
|
914
|
+
why: `the change carries ${reasons.join(', and ')}`,
|
|
915
|
+
gates: [
|
|
916
|
+
clear,
|
|
917
|
+
declinedDeterministic,
|
|
918
|
+
line(
|
|
919
|
+
'fast-path',
|
|
920
|
+
'decline',
|
|
921
|
+
prose === 0
|
|
922
|
+
? 'nothing in the change classifies as documentation, which is what this lane admits'
|
|
923
|
+
: 'the change is not documentation-only',
|
|
924
|
+
),
|
|
925
|
+
line('model', 'route', 'the expensive path is warranted'),
|
|
926
|
+
],
|
|
927
|
+
};
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Who the expensive lane fans out to — `code-reviewer` first, always, and the
|
|
932
|
+
* conditional gates `pr-ship` already names, decided from the same paths.
|
|
933
|
+
*/
|
|
934
|
+
const reviewersFor = (files, risks) => {
|
|
935
|
+
const reviewers = ['code-reviewer'];
|
|
936
|
+
|
|
937
|
+
let wantsProse = false;
|
|
938
|
+
for (const file of files) {
|
|
939
|
+
const path = pathOf(file);
|
|
940
|
+
if (path === '') continue;
|
|
941
|
+
// `DOC_EXTENSIONS` rather than the PROSE classification: `.mdx` is code to
|
|
942
|
+
// the lane logic and a document to a reader, and both are true.
|
|
943
|
+
const segments = segmentsOf(path);
|
|
944
|
+
const basename = segments[segments.length - 1] ?? '';
|
|
945
|
+
const dot = basename.lastIndexOf('.');
|
|
946
|
+
const extension = dot > 0 ? basename.slice(dot + 1).toLowerCase() : '';
|
|
947
|
+
if (DOC_EXTENSIONS.has(extension) || isRulebookPath(path)) {
|
|
948
|
+
wantsProse = true;
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (wantsProse) reviewers.push('prose-reviewer');
|
|
953
|
+
|
|
954
|
+
for (const risk of risks) {
|
|
955
|
+
if (risk.flag === 'security-surface') {
|
|
956
|
+
reviewers.push('security-scanner');
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return reviewers;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/** The verdict a human reads, as opposed to the JSON a caller parses. */
|
|
964
|
+
export const render = (result) => {
|
|
965
|
+
const rows = result.gates.map(
|
|
966
|
+
(gate) => ` ${gate.gate.padEnd(14)} ${gate.verdict.padEnd(9)} ${gate.why}`,
|
|
967
|
+
);
|
|
968
|
+
const reviewers = result.reviewers.length > 0 ? result.reviewers.join(', ') : '(none)';
|
|
969
|
+
return (
|
|
970
|
+
`decision-router: lane ${result.lane.toUpperCase()} — ${result.why}\n\n` +
|
|
971
|
+
`${rows.join('\n')}\n\n` +
|
|
972
|
+
`reviewers: ${reviewers}\n\n` +
|
|
973
|
+
'🔴 the lane is the value on stdout, never the exit code: 0 means the router ran.\n' +
|
|
974
|
+
' Do not chain this on `&&`.\n'
|
|
975
|
+
);
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
// CLI
|
|
980
|
+
// ---------------------------------------------------------------------------
|
|
981
|
+
|
|
982
|
+
const STATUS_LETTERS = Object.freeze({
|
|
983
|
+
A: 'added',
|
|
984
|
+
C: 'copied',
|
|
985
|
+
D: 'removed',
|
|
986
|
+
M: 'modified',
|
|
987
|
+
R: 'renamed',
|
|
988
|
+
// 🔴 NOT `modified`. A type change swaps a file for a symlink (or back), which
|
|
989
|
+
// is not "the generator ran again" — so it must not buy the trusted status
|
|
990
|
+
// that unlocks the lane with no reviewer.
|
|
991
|
+
T: 'type-changed',
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
// 🔴 The DEFAULT, and it is the point: round four fixed `T` by adding a table
|
|
995
|
+
// entry, which left the class open. Every letter not in the table — `U`
|
|
996
|
+
// unmerged, `X` unknown, `B` broken pairing, and whatever git adds next —
|
|
997
|
+
// resolved to `modified`, the one status that unlocks the lane with no
|
|
998
|
+
// reviewer. An unclassifiable input resolves to the expensive answer here like
|
|
999
|
+
// everywhere else in this file.
|
|
1000
|
+
const UNKNOWN_STATUS = 'unknown';
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* `git diff --name-status -z` into `{ path, status }` records.
|
|
1004
|
+
*
|
|
1005
|
+
* `-z` and a NUL split rather than lines: with `core.quotePath` on (the default)
|
|
1006
|
+
* a non-ASCII path arrives quoted and octal-escaped, matching no declared
|
|
1007
|
+
* prefix, and a filename containing a newline splits into two junk paths. Both
|
|
1008
|
+
* failures point the same way — an elevated file that stops looking elevated.
|
|
1009
|
+
*
|
|
1010
|
+
* A rename arrives as three fields (`R100`, old, new); everything else as two.
|
|
1011
|
+
*/
|
|
1012
|
+
export const parseNameStatus = (raw) => {
|
|
1013
|
+
const fields = String(raw ?? '').split('\0');
|
|
1014
|
+
const files = [];
|
|
1015
|
+
let i = 0;
|
|
1016
|
+
while (i < fields.length) {
|
|
1017
|
+
const code = fields[i];
|
|
1018
|
+
if (!code) {
|
|
1019
|
+
i += 1;
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const letter = code[0];
|
|
1023
|
+
const status = STATUS_LETTERS[letter] ?? UNKNOWN_STATUS;
|
|
1024
|
+
if (letter === 'R' || letter === 'C') {
|
|
1025
|
+
const from = fields[i + 1];
|
|
1026
|
+
const to = fields[i + 2];
|
|
1027
|
+
// 🔴 A rename DELETES its source, and the source path is the only place
|
|
1028
|
+
// that deletion appears in the diff. Keeping just the destination let a
|
|
1029
|
+
// rename carry a test out of the suite and a hook out of `.claude/` with
|
|
1030
|
+
// nothing left for any flag to see — measured, not predicted: a diff
|
|
1031
|
+
// renaming `test/foo.test.ts` and `.claude/hooks/guard-bash.mjs` into
|
|
1032
|
+
// `docs/` routed to `fast-path` with `prose-reviewer` as the whole gate.
|
|
1033
|
+
// A COPY leaves its source in place, so only a rename records one.
|
|
1034
|
+
if (from && letter === 'R') files.push({ path: from, status: 'removed' });
|
|
1035
|
+
if (to) files.push({ path: to, status });
|
|
1036
|
+
i += 3;
|
|
1037
|
+
} else {
|
|
1038
|
+
const path = fields[i + 1];
|
|
1039
|
+
if (path) files.push({ path, status });
|
|
1040
|
+
i += 2;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return files;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* A revision this file is willing to hand to git.
|
|
1048
|
+
*
|
|
1049
|
+
* `${base}...${head}` is one argv element with no `--` separator ahead of it, so
|
|
1050
|
+
* a value starting with `-` is read by git as an OPTION rather than a revision —
|
|
1051
|
+
* `--base '--output=/tmp/x'` really does make git write a file. There is no
|
|
1052
|
+
* shell here and the caller already owns the process, so this is hardening
|
|
1053
|
+
* rather than a hole; it is refused because the module treats its argv as
|
|
1054
|
+
* untrusted everywhere else and a half-guarded input is the one people rely on.
|
|
1055
|
+
*/
|
|
1056
|
+
const revisionOrNull = (value) =>
|
|
1057
|
+
typeof value === 'string' && value !== '' && !value.startsWith('-') ? value : null;
|
|
1058
|
+
|
|
1059
|
+
const parseArgs = (argv) => {
|
|
1060
|
+
const args = { base: 'origin/HEAD', head: 'HEAD', files: null, json: false, bad: null };
|
|
1061
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1062
|
+
const arg = argv[i];
|
|
1063
|
+
if (arg === '--json') args.json = true;
|
|
1064
|
+
else if (arg === '--base' || arg === '--head') {
|
|
1065
|
+
const key = arg.slice(2);
|
|
1066
|
+
const value = revisionOrNull(argv[++i]);
|
|
1067
|
+
if (value === null) args.bad = arg;
|
|
1068
|
+
else args[key] = value;
|
|
1069
|
+
} else if (arg === '--files') args.files = argv[++i] ?? '';
|
|
1070
|
+
// An unrecognised flag was silently ignored, so `--file README.md` routed
|
|
1071
|
+
// the whole branch diff at exit 0 — a different change than the caller
|
|
1072
|
+
// asked about, reported as if it were theirs. That is the one input this
|
|
1073
|
+
// module was not treating as untrusted.
|
|
1074
|
+
//
|
|
1075
|
+
// The FIRST offender is kept, not the last: `--file README.md` leaves two
|
|
1076
|
+
// unrecognised tokens behind, and naming the second one points the reader
|
|
1077
|
+
// at the argument rather than at the typo that stranded it.
|
|
1078
|
+
else if (args.bad === null) args.bad = arg;
|
|
1079
|
+
}
|
|
1080
|
+
return args;
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The changed files of the branch under review.
|
|
1085
|
+
*
|
|
1086
|
+
* `<base>...<head>` — three dots — is right *here* and wrong after a merge: it
|
|
1087
|
+
* is `merge-base..head`, which is exactly "what this branch added" while the
|
|
1088
|
+
* branch is still open. (The post-merge close step needs `<merge>^1 <merge>`
|
|
1089
|
+
* instead, and mixing the two up returns an empty list.)
|
|
1090
|
+
*/
|
|
1091
|
+
const gitFiles = (base, head) => {
|
|
1092
|
+
const raw = execFileSync('git', ['diff', '--name-status', '-z', `${base}...${head}`], {
|
|
1093
|
+
encoding: 'utf8',
|
|
1094
|
+
// A process started under a git hook inherits an absolute GIT_DIR, and a
|
|
1095
|
+
// child that keeps it reports the file list of ANOTHER repository — silently,
|
|
1096
|
+
// because the list comes back non-empty.
|
|
1097
|
+
env: withoutGitLocation(),
|
|
1098
|
+
// git's own stderr is captured rather than inherited, so it does not
|
|
1099
|
+
// interleave with this file's output mid-run.
|
|
1100
|
+
//
|
|
1101
|
+
// ⚠ Stated exactly, because the first version of this comment claimed a
|
|
1102
|
+
// sanitisation that does not happen: `execFileSync` folds the captured
|
|
1103
|
+
// stderr into `error.message`, and the handler below prints that verbatim.
|
|
1104
|
+
// So git's `fatal:` text and any repository-supplied name in it still reach
|
|
1105
|
+
// the terminal — just at the end rather than streamed. Capturing buys
|
|
1106
|
+
// ordering, not filtering.
|
|
1107
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1108
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1109
|
+
});
|
|
1110
|
+
return parseNameStatus(raw);
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Was this file invoked directly?
|
|
1115
|
+
*
|
|
1116
|
+
* Compared by REALPATH on both sides, the same way every sibling CLI in this
|
|
1117
|
+
* directory does it. ESM resolves `import.meta.url` through symlinks while
|
|
1118
|
+
* `process.argv[1]` keeps the path as typed, so a checkout behind a link — a
|
|
1119
|
+
* macOS temp dir, a symlinked home — fails a naive equality check and the script
|
|
1120
|
+
* exits 0 having printed nothing. For this file that is the worst shape
|
|
1121
|
+
* available: the gate reads the lane off stdout, and empty stdout with exit 0 is
|
|
1122
|
+
* indistinguishable from a router that ran.
|
|
1123
|
+
*/
|
|
1124
|
+
const invokedDirectly = () => {
|
|
1125
|
+
if (!process.argv[1]) return false;
|
|
1126
|
+
const real = (p) => {
|
|
1127
|
+
try {
|
|
1128
|
+
return realpathSync(p);
|
|
1129
|
+
} catch {
|
|
1130
|
+
return p;
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
if (invokedDirectly()) {
|
|
1137
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1138
|
+
if (args.bad !== null) {
|
|
1139
|
+
process.stderr.write(
|
|
1140
|
+
`decision-router: ${args.bad === '' ? '""' : args.bad} is not a flag this router ` +
|
|
1141
|
+
'understands, or was given a value starting with "-" that git would read as an ' +
|
|
1142
|
+
'option rather than a revision. Nothing was routed — treat this as the expensive ' +
|
|
1143
|
+
'lane.\n',
|
|
1144
|
+
);
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
1148
|
+
|
|
1149
|
+
let files;
|
|
1150
|
+
try {
|
|
1151
|
+
files =
|
|
1152
|
+
args.files === null
|
|
1153
|
+
? gitFiles(args.base, args.head)
|
|
1154
|
+
: args.files
|
|
1155
|
+
.split(',')
|
|
1156
|
+
.map((entry) => entry.trim())
|
|
1157
|
+
.filter(Boolean);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
process.stderr.write(
|
|
1160
|
+
`decision-router: could not read the changed file list from git ` +
|
|
1161
|
+
`(${error?.message ?? error}). Nothing was routed — an unreadable diff is an ` +
|
|
1162
|
+
'absence, not a cheap change.\n',
|
|
1163
|
+
);
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// 🔴 A project that declares no elevated path cannot have the `elevated-path`
|
|
1168
|
+
// flag EVALUATED — and degrading that to an empty declaration made the trace
|
|
1169
|
+
// say `risk-flags clear — no risk flag fired`, which is an unevaluated check
|
|
1170
|
+
// reading as a pass. That is the one thing a gate's own journal must never
|
|
1171
|
+
// produce, so this refuses instead, exactly as `recordCompletedTier` does.
|
|
1172
|
+
const declared = readDeclaredPaths(projectRoot);
|
|
1173
|
+
if (!declared || declared.length === 0) {
|
|
1174
|
+
process.stderr.write(
|
|
1175
|
+
'decision-router: nothing in this project declares an elevated path, so the ' +
|
|
1176
|
+
'`elevated-path` risk flag cannot be evaluated and no lane can be trusted. Add an ' +
|
|
1177
|
+
'`elevated-paths` block to CLAUDE.md or a rule file. Nothing was routed — treat ' +
|
|
1178
|
+
'this as the expensive lane, never as a cheap one.\n',
|
|
1179
|
+
);
|
|
1180
|
+
process.exit(1);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
let result;
|
|
1184
|
+
try {
|
|
1185
|
+
result = route({ files, elevatedPaths: declared });
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
// The refusal, as a diagnosis rather than a stack dump: a caller reading a
|
|
1188
|
+
// node trace on stderr learns nothing about why its change was not routed.
|
|
1189
|
+
process.stderr.write(`decision-router: ${error?.message ?? error}\n`);
|
|
1190
|
+
process.exit(1);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// 🔴 A router with no journal line is a decision nobody can retrace. One
|
|
1194
|
+
// record per gate — including the skipped ones, because within a run that
|
|
1195
|
+
// declared a directory, "no line" is exactly how a gate that silently stopped
|
|
1196
|
+
// matching looks.
|
|
1197
|
+
//
|
|
1198
|
+
// ⚠ Read that condition exactly, because the two absences are different
|
|
1199
|
+
// facts: this writes ONLY when the run declared `RIG_RUN_DIR`, so an empty
|
|
1200
|
+
// `decisions.jsonl` across an undeclared run is the ordinary state and says
|
|
1201
|
+
// nothing about the gates. Inventing a directory here would make this CLI a
|
|
1202
|
+
// second owner of the `.claude/runs/<run-id>/` convention.
|
|
1203
|
+
const runDir = process.env.RIG_RUN_DIR;
|
|
1204
|
+
if (runDir) {
|
|
1205
|
+
let journal = null;
|
|
1206
|
+
try {
|
|
1207
|
+
journal = await import('./run-journal.mjs');
|
|
1208
|
+
for (const gate of result.gates) {
|
|
1209
|
+
journal.recordDecision({
|
|
1210
|
+
runDir,
|
|
1211
|
+
gate: `review-routing:${gate.gate}`,
|
|
1212
|
+
verdict: gate.verdict,
|
|
1213
|
+
why: gate.why,
|
|
1214
|
+
// Only on the lane that was actually taken: the set belongs to the
|
|
1215
|
+
// route, not to the gates that declined it. Recorded because it
|
|
1216
|
+
// cannot be recomputed afterwards — it is a floor `pr-ship` may add
|
|
1217
|
+
// to (limit 6), so paths alone do not reproduce it.
|
|
1218
|
+
...(gate.verdict === 'route' ? { reviewers: result.reviewers } : {}),
|
|
1219
|
+
now: new Date().toISOString(),
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
// Same split the queue CLI makes, and for the same reason: a trace that
|
|
1224
|
+
// cannot accept another record is over, and the routing is not. Asking the
|
|
1225
|
+
// module which failure this is — never matching on the message text, which
|
|
1226
|
+
// would put the decision in two files and let them drift.
|
|
1227
|
+
const classify = journal?.isTraceExhausted;
|
|
1228
|
+
if (typeof classify === 'function' && classify(error)) {
|
|
1229
|
+
process.stderr.write(
|
|
1230
|
+
`run journal: ${error.message}\n` +
|
|
1231
|
+
` the route below was NOT recorded in ${runDir}. This run's trace ends here; ` +
|
|
1232
|
+
'the routing is fine, and a new run needs a new run directory.\n',
|
|
1233
|
+
);
|
|
1234
|
+
} else {
|
|
1235
|
+
process.stderr.write(`run journal: ${error?.message ?? error}\n`);
|
|
1236
|
+
process.exit(1);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
process.stdout.write(args.json ? `${JSON.stringify(result, null, 2)}\n` : render(result));
|
|
1242
|
+
}
|