create-agent-rig 0.5.0 → 0.6.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 +140 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +11 -3
- package/templates/agent-os/init/CLAUDE.md +11 -3
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +28 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +23 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +8 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -3
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +138 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +394 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +298 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +309 -49
- package/templates/release-ledger.json +9 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// doctor — the harness audits itself (AR-5).
|
|
3
|
+
//
|
|
4
|
+
// node .claude/scripts/doctor.mjs # the report, ready to paste
|
|
5
|
+
// node .claude/scripts/doctor.mjs --root <dir> # audit another checkout
|
|
6
|
+
// node .claude/scripts/doctor.mjs --json
|
|
7
|
+
//
|
|
8
|
+
// One question, asked of every hook this project runs: **does the hook the
|
|
9
|
+
// project OWNS have a test beside it?** The three-part pattern in
|
|
10
|
+
// `.claude/rules/invariants.md` — a stated rule, a mechanical check, a test for
|
|
11
|
+
// the check — is decoration with any part missing, and the part a rig loses
|
|
12
|
+
// first is the third: the shipped hooks arrive with their tests in the generator
|
|
13
|
+
// that produced them, and the moment one is edited its test is the rig's own.
|
|
14
|
+
//
|
|
15
|
+
// Ownership is read from `.claude/.rig-manifest.json`, the install manifest the
|
|
16
|
+
// generator writes (its `files` map is install-relative path → sha256 of the
|
|
17
|
+
// bytes it wrote). It is evidence, and three answers come out of it:
|
|
18
|
+
//
|
|
19
|
+
// - `shipped` — the bytes on disk still hash to the manifest's entry: the
|
|
20
|
+
// hook is the generator's, tested upstream. Not a finding.
|
|
21
|
+
// - `owned` — the hash differs, or the manifest has no entry for the file:
|
|
22
|
+
// authored or edited here, so its test is this project's.
|
|
23
|
+
// - `unknown` — there is no manifest to read (a pre-0.4.0 rig, or the
|
|
24
|
+
// generator's own checkout). A hook with a test beside it still
|
|
25
|
+
// passes — the test is there whoever owns it; one without is
|
|
26
|
+
// reported `unknown`, never as a pass: "could not look" is not
|
|
27
|
+
// "it is fine".
|
|
28
|
+
//
|
|
29
|
+
// The manifest is the only ownership source this script reads. The generator's
|
|
30
|
+
// CLI also carries a hash history of every release for manifest-less rigs; it
|
|
31
|
+
// lives in the CLI, and a rig script that re-implemented it would be a second
|
|
32
|
+
// copy of a table nobody here maintains (`invariants.md`, "one mechanism, one
|
|
33
|
+
// implementation"). A rig without a manifest gets `unknown` on every untested
|
|
34
|
+
// hook and the advice to run `upgrade`, which writes one.
|
|
35
|
+
//
|
|
36
|
+
// A test neighbour is `<hook>.test.mjs` in the same directory — the shape
|
|
37
|
+
// `.claude/skills/new-invariant/guard-invariant.example.test.mjs` prescribes and
|
|
38
|
+
// `node --test` runs. Exemptions are an explicit file list with reasons, in
|
|
39
|
+
// `.claude/doctor-exemptions.json` (`{ "<rel path>": "<reason>" }`): an exempt
|
|
40
|
+
// hook is reported as `exempt` with its reason, an exemption with no reason is a
|
|
41
|
+
// finding, and an exemption naming a file that is not there is a finding too —
|
|
42
|
+
// a list that outlives what it exempts is how a check goes quiet by accident.
|
|
43
|
+
//
|
|
44
|
+
// Scope: every `.mjs` directly in `.claude/hooks/` (not `*.test.mjs`, not
|
|
45
|
+
// `lib/`), and EVERY file directly in `.husky/` when that directory exists — a
|
|
46
|
+
// husky hook is never in the manifest, so it is `owned` whenever ownership can
|
|
47
|
+
// be read at all, and a stray file there (a README, a dotfile) is audited like a
|
|
48
|
+
// hook — the generator's `test/template/doctor.test.ts` › "audits every file in
|
|
49
|
+
// .husky/ as a hook — a stray README there is a finding until it is exempted
|
|
50
|
+
// with a reason" pins it: exempt it with a reason rather than teaching this
|
|
51
|
+
// script which names are not hooks. One entry that cannot be stat'ed (a
|
|
52
|
+
// dangling symlink) is reported by name and the rest of the listing survives —
|
|
53
|
+
// › "a dangling symlink among the hooks is reported by name as unreadable, and
|
|
54
|
+
// never nulls the listing". A `.claude/hooks/` that is missing or unreadable is a FAIL and the
|
|
55
|
+
// run is STOP — a doctor that looked nowhere must never say clean — and so is
|
|
56
|
+
// an exemption file that is present but not readable JSON. When `.husky/` is absent the report says so rather than
|
|
57
|
+
// staying silent about a directory it never looked at — the generator's
|
|
58
|
+
// `test/template/doctor.test.ts` › "names an absent .husky/ instead of staying
|
|
59
|
+
// silent about it" pins the line.
|
|
60
|
+
//
|
|
61
|
+
// 🔴 What this script does NOT check is printed at the end of every report, the
|
|
62
|
+
// same way `preflight.mjs` prints its unchecked items: a script that half-checks
|
|
63
|
+
// is only safe while the boundary is visible.
|
|
64
|
+
import { createHash } from 'node:crypto';
|
|
65
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
66
|
+
import path from 'node:path';
|
|
67
|
+
import { fileURLToPath } from 'node:url';
|
|
68
|
+
|
|
69
|
+
export const MANIFEST_REL = '.claude/.rig-manifest.json';
|
|
70
|
+
export const EXEMPTIONS_REL = '.claude/doctor-exemptions.json';
|
|
71
|
+
export const HOOKS_DIR = '.claude/hooks';
|
|
72
|
+
export const HUSKY_DIR = '.husky';
|
|
73
|
+
|
|
74
|
+
/** What this script cannot decide from the file system alone. */
|
|
75
|
+
export const UNCHECKED = [
|
|
76
|
+
'that the neighbour test exercises the hook it sits beside — a file named ' +
|
|
77
|
+
'`<hook>.test.mjs` that asserts nothing satisfies this check; run `node --test` on it',
|
|
78
|
+
'that the hook is wired in .claude/settings.json (or .codex/hooks.json) — an ' +
|
|
79
|
+
'unwired hook passes its own test and guards nothing',
|
|
80
|
+
'that a `shipped` hook still matches the rule it enforces — the manifest says ' +
|
|
81
|
+
'the bytes are the generator’s, not that the generator’s rule is this project’s',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export const sha256 = (data) => createHash('sha256').update(data).digest('hex');
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Who owns a hook, from the manifest's evidence.
|
|
88
|
+
*
|
|
89
|
+
* `recorded` is the manifest's hash for the file, `undefined` when the manifest
|
|
90
|
+
* has no entry, and `null` when there is no manifest at all — three inputs that
|
|
91
|
+
* must stay three, because collapsing "no entry" into "no manifest" would read
|
|
92
|
+
* an authored hook as unknowable, and the reverse would read a manifest-less
|
|
93
|
+
* rig's every hook as authored here.
|
|
94
|
+
*/
|
|
95
|
+
export const ownershipOf = ({ recorded, actual }) => {
|
|
96
|
+
if (recorded === null) return 'unknown';
|
|
97
|
+
if (typeof recorded === 'string' && recorded === actual) return 'shipped';
|
|
98
|
+
return 'owned';
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** The test neighbour's path: `guard-x.mjs` → `guard-x.test.mjs`, `pre-commit` → `pre-commit.test.mjs`. */
|
|
102
|
+
export const neighbourOf = (rel) => {
|
|
103
|
+
const dir = path.posix.dirname(rel);
|
|
104
|
+
const base = path.posix.basename(rel);
|
|
105
|
+
const stem = base.endsWith('.mjs') ? base.slice(0, -'.mjs'.length) : base;
|
|
106
|
+
return path.posix.join(dir, `${stem}.test.mjs`);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** The same ladder `preflight.mjs` uses: any FAIL stops, any unknown cautions. */
|
|
110
|
+
export const verdictOf = (marks) => {
|
|
111
|
+
if (marks.some((mark) => mark === 'FAIL')) return 'STOP';
|
|
112
|
+
if (marks.some((mark) => mark === 'unknown')) return 'CAUTION';
|
|
113
|
+
return 'GO';
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Control bytes stripped before a string from a repo file reaches the terminal
|
|
118
|
+
* (the same concern `queue/core.mjs` › printable has): an exemption reason
|
|
119
|
+
* carrying an escape sequence could repaint the line above it. Printable
|
|
120
|
+
* Unicode stays — a reason is prose.
|
|
121
|
+
*/
|
|
122
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the subject of this regex
|
|
123
|
+
export const printable = (text) => String(text).replace(/[\x00-\x1F\x7F-\x9F]/g, '');
|
|
124
|
+
|
|
125
|
+
const reasonOf = (exemptions, rel) => {
|
|
126
|
+
if (!exemptions || typeof exemptions !== 'object' || Array.isArray(exemptions)) return undefined;
|
|
127
|
+
return Object.prototype.hasOwnProperty.call(exemptions, rel) ? exemptions[rel] : undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The audit, pure: hooks in, marks out. Each hook is `{ rel, ownership,
|
|
132
|
+
* hasTest }`; each result adds `mark` (`pass` | `FAIL` | `unknown` | `exempt`)
|
|
133
|
+
* and a `detail` a reader can act on.
|
|
134
|
+
*
|
|
135
|
+
* Bounded by construction: one pass over the hooks, one over the exemptions.
|
|
136
|
+
*/
|
|
137
|
+
export const auditHooks = ({ hooks = [], exemptions = {} } = {}) => {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
const results = [];
|
|
140
|
+
for (const hook of hooks) {
|
|
141
|
+
const { rel, ownership, hasTest } = hook;
|
|
142
|
+
seen.add(rel);
|
|
143
|
+
const reason = reasonOf(exemptions, rel);
|
|
144
|
+
const exempt = reason !== undefined;
|
|
145
|
+
let mark;
|
|
146
|
+
let detail;
|
|
147
|
+
if (exempt && (typeof reason !== 'string' || reason.trim() === '')) {
|
|
148
|
+
mark = 'FAIL';
|
|
149
|
+
detail = `exempted without a reason in ${EXEMPTIONS_REL} — an exemption is a file AND why`;
|
|
150
|
+
} else if (hasTest) {
|
|
151
|
+
mark = 'pass';
|
|
152
|
+
detail =
|
|
153
|
+
ownership === 'shipped'
|
|
154
|
+
? `test neighbour ${neighbourOf(rel)} (and the generator’s upstream tests)`
|
|
155
|
+
: `test neighbour ${neighbourOf(rel)}`;
|
|
156
|
+
} else if (ownership === 'shipped') {
|
|
157
|
+
mark = 'pass';
|
|
158
|
+
detail = 'unchanged since install — tested upstream, in the generator that produced it';
|
|
159
|
+
} else if (exempt) {
|
|
160
|
+
mark = 'exempt';
|
|
161
|
+
detail = `no test neighbour; exempt — ${printable(reason.trim())}`;
|
|
162
|
+
} else if (ownership === 'owned') {
|
|
163
|
+
mark = 'FAIL';
|
|
164
|
+
detail =
|
|
165
|
+
`owned here (${'edited or authored in this project'}) and no ${neighbourOf(rel)} — ` +
|
|
166
|
+
'copy .claude/skills/new-invariant/guard-invariant.example.test.mjs beside it';
|
|
167
|
+
} else {
|
|
168
|
+
mark = 'unknown';
|
|
169
|
+
detail =
|
|
170
|
+
`no ${MANIFEST_REL} to say who owns it, and no ${neighbourOf(rel)} — ` +
|
|
171
|
+
'not a pass; `upgrade` writes a manifest, or add the test';
|
|
172
|
+
}
|
|
173
|
+
results.push({ rel, ownership, hasTest, mark, detail });
|
|
174
|
+
}
|
|
175
|
+
if (exemptions && typeof exemptions === 'object' && !Array.isArray(exemptions)) {
|
|
176
|
+
for (const rel of Object.keys(exemptions)) {
|
|
177
|
+
if (seen.has(rel)) continue;
|
|
178
|
+
results.push({
|
|
179
|
+
rel: printable(rel),
|
|
180
|
+
ownership: 'absent',
|
|
181
|
+
hasTest: false,
|
|
182
|
+
mark: 'FAIL',
|
|
183
|
+
detail: `stale-exemption: ${EXEMPTIONS_REL} names a file this audit did not find — remove the entry`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { verdict: verdictOf(results.map((r) => r.mark)), hooks: results };
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// --- the file-system half -----------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const readJson = (file) => {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** The manifest's `files` map, or `null` when there is nothing trustworthy to read. */
|
|
201
|
+
export const manifestFilesOf = (root) => {
|
|
202
|
+
const parsed = readJson(path.join(root, ...MANIFEST_REL.split('/')));
|
|
203
|
+
const files = parsed?.files;
|
|
204
|
+
if (!files || typeof files !== 'object' || Array.isArray(files)) return null;
|
|
205
|
+
return files;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The files directly in `dir`: `{ names, unreadable }`, or `null` when the
|
|
210
|
+
* directory itself cannot be listed. One entry that cannot be stat'ed (a dangling
|
|
211
|
+
* symlink) is reported by name, never allowed to null the whole listing.
|
|
212
|
+
*/
|
|
213
|
+
const listFiles = (dir) => {
|
|
214
|
+
let entries;
|
|
215
|
+
try {
|
|
216
|
+
entries = readdirSync(dir).sort();
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const names = [];
|
|
221
|
+
const unreadable = [];
|
|
222
|
+
for (const name of entries) {
|
|
223
|
+
try {
|
|
224
|
+
if (statSync(path.join(dir, name)).isFile()) names.push(name);
|
|
225
|
+
} catch {
|
|
226
|
+
unreadable.push(name);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { names, unreadable };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** A finding the audit itself raises — a scope it could not read. */
|
|
233
|
+
const problem = (rel, detail) => ({ rel, ownership: 'absent', hasTest: false, mark: 'FAIL', detail });
|
|
234
|
+
|
|
235
|
+
/** Every hook in scope, as `{ rel, ownership, hasTest }`, plus the scopes it looked at. */
|
|
236
|
+
export const collectHooks = (root) => {
|
|
237
|
+
const files = manifestFilesOf(root);
|
|
238
|
+
const hooks = [];
|
|
239
|
+
const scopes = [];
|
|
240
|
+
const problems = [];
|
|
241
|
+
const hookDir = listFiles(path.join(root, ...HOOKS_DIR.split('/')));
|
|
242
|
+
scopes.push({ dir: HOOKS_DIR, present: hookDir !== null });
|
|
243
|
+
if (hookDir === null) {
|
|
244
|
+
// Not a rig, or not the directory the caller meant: never a clean report.
|
|
245
|
+
problems.push(problem(HOOKS_DIR, `not found or unreadable under ${root} — nothing was audited`));
|
|
246
|
+
}
|
|
247
|
+
for (const name of hookDir?.names ?? []) {
|
|
248
|
+
if (!name.endsWith('.mjs') || name.endsWith('.test.mjs')) continue;
|
|
249
|
+
hooks.push(`${HOOKS_DIR}/${name}`);
|
|
250
|
+
}
|
|
251
|
+
const husky = listFiles(path.join(root, HUSKY_DIR));
|
|
252
|
+
scopes.push({ dir: HUSKY_DIR, present: husky !== null });
|
|
253
|
+
for (const name of husky?.names ?? []) {
|
|
254
|
+
if (name.endsWith('.test.mjs')) continue;
|
|
255
|
+
hooks.push(`${HUSKY_DIR}/${name}`);
|
|
256
|
+
}
|
|
257
|
+
for (const [dir, listing] of [
|
|
258
|
+
[HOOKS_DIR, hookDir],
|
|
259
|
+
[HUSKY_DIR, husky],
|
|
260
|
+
]) {
|
|
261
|
+
for (const name of listing?.unreadable ?? []) {
|
|
262
|
+
problems.push(problem(`${dir}/${printable(name)}`, 'cannot be read (a dangling symlink?) — not audited, not a pass'));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
scopes,
|
|
267
|
+
problems,
|
|
268
|
+
hooks: hooks.map((rel) => {
|
|
269
|
+
const actual = sha256(readFileSync(path.join(root, ...rel.split('/'))));
|
|
270
|
+
const recorded = files === null ? null : files[rel];
|
|
271
|
+
return {
|
|
272
|
+
rel,
|
|
273
|
+
ownership: ownershipOf({ recorded, actual }),
|
|
274
|
+
hasTest: existsSync(path.join(root, ...neighbourOf(rel).split('/'))),
|
|
275
|
+
};
|
|
276
|
+
}),
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const report = (root) => {
|
|
281
|
+
const { hooks, scopes, problems } = collectHooks(root);
|
|
282
|
+
// One read, two outcomes: absent is an empty list, present-but-unreadable is a
|
|
283
|
+
// finding. Testing for existence first and reading second would be two truths.
|
|
284
|
+
let exemptions = {};
|
|
285
|
+
let raw = null;
|
|
286
|
+
try {
|
|
287
|
+
raw = readFileSync(path.join(root, ...EXEMPTIONS_REL.split('/')), 'utf8');
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error?.code !== 'ENOENT') {
|
|
290
|
+
problems.push(problem(EXEMPTIONS_REL, `present but unreadable — ${error?.code ?? 'read failed'}`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (raw !== null) {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(raw);
|
|
297
|
+
} catch {
|
|
298
|
+
parsed = null;
|
|
299
|
+
}
|
|
300
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) exemptions = parsed;
|
|
301
|
+
else problems.push(problem(EXEMPTIONS_REL, 'present but unreadable — not JSON, or not an object of path → reason'));
|
|
302
|
+
}
|
|
303
|
+
const audited = auditHooks({ hooks, exemptions });
|
|
304
|
+
const all = [...problems, ...audited.hooks];
|
|
305
|
+
const audit = { verdict: verdictOf(all.map((r) => r.mark)), hooks: all };
|
|
306
|
+
const absent = scopes.filter((scope) => !scope.present && scope.dir !== HOOKS_DIR).map((scope) => scope.dir);
|
|
307
|
+
const lines = [
|
|
308
|
+
`**doctor** — verdict: ${audit.verdict}`,
|
|
309
|
+
'',
|
|
310
|
+
// Names come from the file system, reasons from a repo file; both are
|
|
311
|
+
// stripped of control bytes here, once, where they reach the terminal.
|
|
312
|
+
...audit.hooks.map((hook) => `- ${hook.mark} · ${printable(hook.rel)} — ${printable(hook.detail)}`),
|
|
313
|
+
...(absent.length > 0
|
|
314
|
+
? ['', `_Not present, so not audited: ${absent.join(', ')}._`]
|
|
315
|
+
: []),
|
|
316
|
+
'',
|
|
317
|
+
`_Not checked by this script — still yours (${UNCHECKED.length}):_`,
|
|
318
|
+
...UNCHECKED.map((item) => `- ${item}`),
|
|
319
|
+
];
|
|
320
|
+
return { ...audit, scopes, unchecked: UNCHECKED, rendered: lines.join('\n') };
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const invokedDirectly = () => {
|
|
324
|
+
if (!process.argv[1]) return false;
|
|
325
|
+
const real = (p) => {
|
|
326
|
+
try {
|
|
327
|
+
return realpathSync(p);
|
|
328
|
+
} catch {
|
|
329
|
+
return p;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
if (invokedDirectly()) {
|
|
336
|
+
const args = process.argv.slice(2);
|
|
337
|
+
const rootIndex = args.indexOf('--root');
|
|
338
|
+
const rootArg = rootIndex === -1 ? null : args[rootIndex + 1];
|
|
339
|
+
if (rootIndex !== -1 && (rootArg === undefined || rootArg.startsWith('--'))) {
|
|
340
|
+
process.stderr.write('doctor: --root needs a directory — auditing the working directory instead would be a guess\n');
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
const root = path.resolve(rootArg ?? process.cwd());
|
|
344
|
+
const result = report(root);
|
|
345
|
+
process.stdout.write(
|
|
346
|
+
args.includes('--json')
|
|
347
|
+
? `${JSON.stringify({ verdict: result.verdict, hooks: result.hooks, scopes: result.scopes, unchecked: result.unchecked }, null, 2)}\n`
|
|
348
|
+
: `${result.rendered}\n`,
|
|
349
|
+
);
|
|
350
|
+
process.exit(result.verdict === 'STOP' ? 1 : 0);
|
|
351
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate coverage — did every reviewer this round asked for actually answer, and
|
|
3
|
+
* for the commit being merged?
|
|
4
|
+
*
|
|
5
|
+
* Three writers already record the three halves of a gate round, and until this
|
|
6
|
+
* module existed nothing compared them:
|
|
7
|
+
*
|
|
8
|
+
* - `decision-router.mjs` journals the set the route ASKED FOR, on the lane it
|
|
9
|
+
* actually took (`gate: "review-routing:<lane>"`, with `reviewers`);
|
|
10
|
+
* - `pr-ship` journals the set it actually LAUNCHED, with the head it launched
|
|
11
|
+
* against (`gate: "reviewer-fan-out"`);
|
|
12
|
+
* - each verdict that PARSED is journalled under the reviewer's own name.
|
|
13
|
+
*
|
|
14
|
+
* The two failures that hide in the gap between them both end in a merge that
|
|
15
|
+
* reads as fully gated: a reviewer the router named and nobody started, and a
|
|
16
|
+
* reviewer that answered — about a commit two pushes ago.
|
|
17
|
+
*
|
|
18
|
+
* 🔴 **A verdict that names no commit is not coverage of this one.** `headSha` is
|
|
19
|
+
* optional in the schema — every report written before it existed omits one — and
|
|
20
|
+
* reading absence as "it must have meant the head I am holding" is the inference
|
|
21
|
+
* the schema's own limit 6 forbids. It gets its own list rather than a pass.
|
|
22
|
+
*
|
|
23
|
+
* 🔴 **No fan-out record is not a clean round.** A run whose journal records no
|
|
24
|
+
* fan-out has not been shown to be covered, it has been shown to be unreadable —
|
|
25
|
+
* and four empty lists are exactly what a clean round looks like. That case
|
|
26
|
+
* answers `ok: false` and says why in `reason`. A missing fan-out, a missing route,
|
|
27
|
+
* an unconsumed route, a fan-out missing its head commit, and a fan-out for a different head
|
|
28
|
+
* commit are the unreadable-round answers; each reason names the evidence boundary the journal
|
|
29
|
+
* could not establish.
|
|
30
|
+
*
|
|
31
|
+
* 🔴 **A round is judged against its OWN route, not the run's last one.** The
|
|
32
|
+
* answers are scoped to the records after the last fan-out; the route needs the
|
|
33
|
+
* mirror of that, and not having it made the refusal above vacuous in the case it
|
|
34
|
+
* was written for. A run directory spans several rounds and several PRs, so an
|
|
35
|
+
* earlier round's route would otherwise satisfy a later round that journalled
|
|
36
|
+
* none — and its *set* would be inherited too, which after a `deterministic` lane
|
|
37
|
+
* means `neverLaunched` is empty for the rest of the run whatever the route asked
|
|
38
|
+
* for. So the route that counts is the last one carrying `reviewers` **between the
|
|
39
|
+
* previous fan-out and the last one**: the route that produced the fan-out being
|
|
40
|
+
* judged.
|
|
41
|
+
*
|
|
42
|
+
* 🔴 **An unconsumed route after the last fan-out is an unreadable boundary.**
|
|
43
|
+
* The journal cannot say whether this round's fan-out record is missing or the
|
|
44
|
+
* router was re-run after a completed fan-out. Both readings refuse coverage;
|
|
45
|
+
* re-running the fan-out for this head records the boundary either one needs.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The gate name `pr-ship` writes its fan-out under. */
|
|
49
|
+
const FAN_OUT = 'reviewer-fan-out';
|
|
50
|
+
|
|
51
|
+
/** Every lane the router reports on shares this prefix; only the taken one carries a set. */
|
|
52
|
+
const ROUTING = 'review-routing:';
|
|
53
|
+
|
|
54
|
+
const gateOf = (record) => (typeof record?.gate === 'string' ? record.gate : '');
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The reviewer names in a journalled set.
|
|
58
|
+
*
|
|
59
|
+
* `recordDecision` checks for a list of strings and nothing about what a name
|
|
60
|
+
* is, so a blank one is dropped here rather than reported as a reviewer nobody
|
|
61
|
+
* can launch.
|
|
62
|
+
*/
|
|
63
|
+
const namesOf = (value) =>
|
|
64
|
+
Array.isArray(value) ? value.filter((name) => typeof name === 'string' && name.trim() !== '') : [];
|
|
65
|
+
|
|
66
|
+
/** Order-preserving, because the output is read by a human looking for a name. */
|
|
67
|
+
const uniq = (names) => [...new Set(names)];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The shortest commit id the verdict schema accepts, and the floor a prefix
|
|
71
|
+
* match needs to be worth anything (`lib/verdict.mjs`, `isCommitId`).
|
|
72
|
+
*/
|
|
73
|
+
const SHORTEST_COMMIT = 7;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The lengths a COMPLETE commit id has: sha-1 and sha-256.
|
|
77
|
+
*
|
|
78
|
+
* An abbreviation resolves against a complete id and nothing else. Without this,
|
|
79
|
+
* any strict extension of a full id prefix-matched it — a 50-character value
|
|
80
|
+
* built by appending to a 40-character commit was counted as coverage of that
|
|
81
|
+
* commit, and it passes the schema (7–64 hex), so it arrives through the fully
|
|
82
|
+
* validated path rather than as obvious junk.
|
|
83
|
+
*/
|
|
84
|
+
const COMPLETE_COMMIT_LENGTHS = new Set([40, 64]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Are these two ids the same commit?
|
|
88
|
+
*
|
|
89
|
+
* 🔴 **Not string equality, and the difference is a false HOLD.** The schema
|
|
90
|
+
* accepts an abbreviated id, so a reviewer may answer `b4e3ef0` about the commit
|
|
91
|
+
* `git rev-parse HEAD` prints in full — the same commit, written shorter. Exact
|
|
92
|
+
* comparison reports that as answered-for-another-commit and holds the merge on
|
|
93
|
+
* honest work, which is the way a guard loses the room.
|
|
94
|
+
*
|
|
95
|
+
* So: equal ids match, and otherwise the shorter must be a prefix of a
|
|
96
|
+
* **complete** one, case-insensitively — the way git resolves an abbreviation.
|
|
97
|
+
* Two bounds, each closing one direction:
|
|
98
|
+
*
|
|
99
|
+
* - **the floor.** Both must reach `SHORTEST_COMMIT`. A shorter value is one the
|
|
100
|
+
* verdict schema refuses, and stretching it into a match would let a value
|
|
101
|
+
* nothing accepted decide that a gate was covered. ⚠ The journal is the other
|
|
102
|
+
* way in and it does not apply that schema (`run-journal.mjs` takes `headSha`
|
|
103
|
+
* as any non-blank string), so this floor is enforced here on its own account,
|
|
104
|
+
* not on the strength of an upstream check.
|
|
105
|
+
* - **the ceiling.** The longer must be a complete id. An abbreviation resolves
|
|
106
|
+
* against a whole commit; a value that merely *extends* one is not that commit
|
|
107
|
+
* written shorter, and counting it as coverage fails in the unsafe direction.
|
|
108
|
+
*
|
|
109
|
+
* One case is decided rather than left ambiguous: a 40-character value that
|
|
110
|
+
* prefixes a 64-character one matches, because the longer is complete. It could
|
|
111
|
+
* be a sha-256 abbreviated to 40 or a sha-1 with characters appended, and nothing
|
|
112
|
+
* here can resolve which — a pure function has no repository to ask. The first
|
|
113
|
+
* reading is the one an honest run produces.
|
|
114
|
+
*/
|
|
115
|
+
const sameCommit = (one, other) => {
|
|
116
|
+
if (one.length < SHORTEST_COMMIT || other.length < SHORTEST_COMMIT) return false;
|
|
117
|
+
const first = one.toLowerCase();
|
|
118
|
+
const second = other.toLowerCase();
|
|
119
|
+
if (first === second) return true;
|
|
120
|
+
const [shorter, longer] = first.length <= second.length ? [first, second] : [second, first];
|
|
121
|
+
if (!COMPLETE_COMMIT_LENGTHS.has(longer.length)) return false;
|
|
122
|
+
return longer.startsWith(shorter);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Which reviewers are outstanding for `headSha`, and in which of the four ways.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ records?: unknown, headSha?: unknown }} input
|
|
129
|
+
* `records` is `readRun(...).decisions` — the run's decision journal, in
|
|
130
|
+
* journal order. `headSha` is the commit the round is about.
|
|
131
|
+
* @returns {{
|
|
132
|
+
* ok: boolean, routed: string[], launched: string[],
|
|
133
|
+
* neverLaunched: string[], unanswered: string[],
|
|
134
|
+
* unattributed: string[], stale: string[], reason?: string,
|
|
135
|
+
* }}
|
|
136
|
+
*/
|
|
137
|
+
export const coverageOf = ({ records, headSha } = {}) => {
|
|
138
|
+
const journal = Array.isArray(records) ? records : [];
|
|
139
|
+
const target = typeof headSha === 'string' ? headSha : '';
|
|
140
|
+
|
|
141
|
+
// One forward pass, keeping the LAST of each: a branch gets a second gate
|
|
142
|
+
// round after fixes, and the round's whole question is whether THIS round's
|
|
143
|
+
// reviewers answered. An earlier fan-out answers about a round already over.
|
|
144
|
+
//
|
|
145
|
+
// `pendingRoute` is what makes the route round-scoped: a routing record is held
|
|
146
|
+
// until a fan-out consumes it, and each fan-out takes only what was journalled
|
|
147
|
+
// since the previous one. Nothing survives a fan-out, so no round can inherit
|
|
148
|
+
// the round before it.
|
|
149
|
+
let pendingRoute = null;
|
|
150
|
+
let routeOfRound = null;
|
|
151
|
+
let fanOutAt = -1;
|
|
152
|
+
let launchedNames = [];
|
|
153
|
+
let fanOutHead = null;
|
|
154
|
+
|
|
155
|
+
for (let index = 0; index < journal.length; index += 1) {
|
|
156
|
+
const record = journal[index];
|
|
157
|
+
const gate = gateOf(record);
|
|
158
|
+
// Only the taken lane carries a set; a declined lane's line has no
|
|
159
|
+
// `reviewers` key at all, and reading one off it would compare the answers
|
|
160
|
+
// against a lane nobody took.
|
|
161
|
+
if (gate.startsWith(ROUTING) && Array.isArray(record?.reviewers)) {
|
|
162
|
+
// A set was RECORDED, which is a different fact from the set being empty.
|
|
163
|
+
// The `deterministic` lane legitimately routes nobody; a round that never
|
|
164
|
+
// journalled a route knows nothing about who should have been launched.
|
|
165
|
+
pendingRoute = namesOf(record.reviewers);
|
|
166
|
+
}
|
|
167
|
+
if (gate === FAN_OUT) {
|
|
168
|
+
fanOutAt = index;
|
|
169
|
+
launchedNames = namesOf(record?.reviewers);
|
|
170
|
+
fanOutHead = typeof record?.headSha === 'string' ? record.headSha : null;
|
|
171
|
+
routeOfRound = pendingRoute;
|
|
172
|
+
pendingRoute = null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (pendingRoute !== null) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
routed: uniq(pendingRoute),
|
|
180
|
+
launched: [],
|
|
181
|
+
neverLaunched: [],
|
|
182
|
+
unanswered: [],
|
|
183
|
+
unattributed: [],
|
|
184
|
+
stale: [],
|
|
185
|
+
reason:
|
|
186
|
+
'a routed reviewer set was journalled after the last fan-out, so the round ' +
|
|
187
|
+
'boundary is unreadable: either this round\'s fan-out record is missing, or the ' +
|
|
188
|
+
'router ran again after the fan-out. Rerun the fan-out step for this head so ' +
|
|
189
|
+
'the round it belongs to has a record.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const routeRecorded = routeOfRound !== null;
|
|
194
|
+
const routedNames = routeOfRound ?? [];
|
|
195
|
+
|
|
196
|
+
const routed = uniq(routedNames);
|
|
197
|
+
|
|
198
|
+
if (fanOutAt === -1) {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
routed,
|
|
202
|
+
launched: [],
|
|
203
|
+
neverLaunched: [],
|
|
204
|
+
unanswered: [],
|
|
205
|
+
unattributed: [],
|
|
206
|
+
stale: [],
|
|
207
|
+
reason:
|
|
208
|
+
'this run journalled no reviewer fan-out, so there is no set to check the verdicts ' +
|
|
209
|
+
'against. That is not a round with nothing outstanding — it is a round nothing can ' +
|
|
210
|
+
'read, and the two look identical from the lists alone.',
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (fanOutHead === null) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
routed: [],
|
|
218
|
+
launched: [],
|
|
219
|
+
neverLaunched: [],
|
|
220
|
+
unanswered: [],
|
|
221
|
+
unattributed: [],
|
|
222
|
+
stale: [],
|
|
223
|
+
reason:
|
|
224
|
+
'the last reviewer fan-out names no head commit, so it cannot be coverage of ' +
|
|
225
|
+
'this one. Rerun the fan-out step for this head and record the commit it launched.',
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!sameCommit(fanOutHead, target)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
routed: [],
|
|
233
|
+
launched: [],
|
|
234
|
+
neverLaunched: [],
|
|
235
|
+
unanswered: [],
|
|
236
|
+
unattributed: [],
|
|
237
|
+
stale: [],
|
|
238
|
+
reason:
|
|
239
|
+
'the last reviewer fan-out names a different head commit, so its launched set ' +
|
|
240
|
+
'does not cover this one. Rerun the fan-out step for this head before reading verdicts.',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const launched = uniq(launchedNames);
|
|
245
|
+
// Answers are the records that come AFTER the fan-out. A verdict from the
|
|
246
|
+
// previous round is still in the journal, and a reader that scanned the whole
|
|
247
|
+
// file would find an answer and report the current round covered.
|
|
248
|
+
const after = journal.slice(fanOutAt + 1);
|
|
249
|
+
|
|
250
|
+
// The lane is a floor and never a ceiling — the triggers may only ADD
|
|
251
|
+
// reviewers — so a launched set larger than the routed one is the ordinary
|
|
252
|
+
// case and is not a finding. Only the other direction is.
|
|
253
|
+
const neverLaunched = routed.filter((name) => !launched.includes(name));
|
|
254
|
+
|
|
255
|
+
const unanswered = [];
|
|
256
|
+
const unattributed = [];
|
|
257
|
+
const stale = [];
|
|
258
|
+
|
|
259
|
+
for (const name of launched) {
|
|
260
|
+
// Matched by gate name, which is what keeps another writer's record — the
|
|
261
|
+
// router's own line, `pr-ship`'s, a deploy verdict — from being read as a
|
|
262
|
+
// reviewer's answer.
|
|
263
|
+
const commits = after
|
|
264
|
+
.filter((record) => gateOf(record) === name)
|
|
265
|
+
.map((record) => record?.headSha)
|
|
266
|
+
.filter((commit) => typeof commit === 'string' && commit !== '');
|
|
267
|
+
|
|
268
|
+
const answered = after.some((record) => gateOf(record) === name);
|
|
269
|
+
if (!answered) {
|
|
270
|
+
unanswered.push(name);
|
|
271
|
+
} else if (commits.some((commit) => sameCommit(commit, target))) {
|
|
272
|
+
// Covered. A HOLD counts here exactly as a SHIP does: coverage is about
|
|
273
|
+
// who spoke for which commit, never about what they said.
|
|
274
|
+
} else if (commits.length === 0) {
|
|
275
|
+
unattributed.push(name);
|
|
276
|
+
} else {
|
|
277
|
+
stale.push(name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const outstanding =
|
|
282
|
+
neverLaunched.length + unanswered.length + unattributed.length + stale.length;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
ok: routeRecorded && outstanding === 0,
|
|
286
|
+
routed,
|
|
287
|
+
launched,
|
|
288
|
+
neverLaunched,
|
|
289
|
+
unanswered,
|
|
290
|
+
unattributed,
|
|
291
|
+
stale,
|
|
292
|
+
// Unlike the missing fan-out, this one still has a launched set and answers
|
|
293
|
+
// to compare, so the four lists are computed and returned — the reason says
|
|
294
|
+
// which half of the check could not run, rather than replacing the half that
|
|
295
|
+
// could.
|
|
296
|
+
...(routeRecorded
|
|
297
|
+
? {}
|
|
298
|
+
: {
|
|
299
|
+
reason:
|
|
300
|
+
'this round journalled no routed reviewer set, so `neverLaunched` was compared ' +
|
|
301
|
+
'against nothing: a reviewer the route asked for and nobody launched would not ' +
|
|
302
|
+
'appear. An empty route that WAS recorded is a different answer, and so is a ' +
|
|
303
|
+
'route belonging to an earlier round — neither is this one.',
|
|
304
|
+
}),
|
|
305
|
+
};
|
|
306
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The revalidation points — ONE spelling of the fact (AR-137).
|
|
3
|
+
*
|
|
4
|
+
* `queue/index.mjs next` writes the SELECT record; `revalidate.mjs` writes the
|
|
5
|
+
* other two and answers outcomes at all three; `revalidation-report.mjs`
|
|
6
|
+
* counts all three. Before this module each carried its own list, and one
|
|
7
|
+
* computed a fourth (`['SELECT', ...POINTS]`). Four spellings of one fact is
|
|
8
|
+
* how a point gets added to the report and not to the script, silently.
|
|
9
|
+
*
|
|
10
|
+
* `REVALIDATES` is DERIVED, not restated: the asymmetry that SELECT is written
|
|
11
|
+
* elsewhere is encoded here once, where a reader looks for it.
|
|
12
|
+
*
|
|
13
|
+
* The prose that names these points is kept in step by a two-direction check —
|
|
14
|
+
* the generator's test/template/correspondence.test.ts › "every point the
|
|
15
|
+
* module knows is named by the loop or pr-ship skill, and vice versa".
|
|
16
|
+
*
|
|
17
|
+
* Maintenance and noise cost, recorded (AR-137): adding a point touches this
|
|
18
|
+
* list and the prose that names it as `--point X` or `point: X`, and the check
|
|
19
|
+
* says which side was forgotten. The check reads only the `loop` and `pr-ship`
|
|
20
|
+
* skills — a point named anywhere else is invisible to it — and it parses
|
|
21
|
+
* those two by that exact spelling, so a rewording breaks it loudly, as a named
|
|
22
|
+
* failure, never silently.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const POINTS = Object.freeze(['SELECT', 'BEFORE_PR', 'BEFORE_CLOSE']);
|
|
26
|
+
|
|
27
|
+
/** What `revalidate.mjs` can revalidate itself: every point but SELECT. */
|
|
28
|
+
export const REVALIDATES = Object.freeze(POINTS.filter((point) => point !== 'SELECT'));
|