entropy-machines 0.1.1

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.
Files changed (51) hide show
  1. package/LICENSE +93 -0
  2. package/README.md +68 -0
  3. package/agents/isolated-worker.md +128 -0
  4. package/agents/verifier.md +158 -0
  5. package/bin/dispatch +700 -0
  6. package/bin/doclint +460 -0
  7. package/bin/drain +507 -0
  8. package/bin/drain-pick.py +168 -0
  9. package/bin/drain-prompt.md +67 -0
  10. package/bin/drain-run.sh +342 -0
  11. package/bin/entropy-machines-init +285 -0
  12. package/bin/handoff +1151 -0
  13. package/bin/init +232 -0
  14. package/bin/post-fold-audit +377 -0
  15. package/bin/serve +724 -0
  16. package/bin/status +208 -0
  17. package/bin/tracker +153 -0
  18. package/docs/AGENT-QUICKSTART.md +86 -0
  19. package/docs/CONFIG.md +68 -0
  20. package/docs/NPM.md +91 -0
  21. package/docs/SERVE.md +74 -0
  22. package/docs/TRACKER-ADAPTER.md +66 -0
  23. package/doctrine/HANDOFF-PROMPT.md +63 -0
  24. package/doctrine/README.md +62 -0
  25. package/doctrine/ROLES.md +27 -0
  26. package/doctrine/WORKFLOW.md +87 -0
  27. package/hooks/commit-msg +24 -0
  28. package/hooks/post-checkout +354 -0
  29. package/hooks/pre-commit +33 -0
  30. package/lib/PRD-001-orientation.html +1180 -0
  31. package/lib/REPORT-TEMPLATE.html +413 -0
  32. package/lib/changelog-collate.mjs +328 -0
  33. package/lib/changelog-guard.sh +157 -0
  34. package/lib/changelog-new.mjs +70 -0
  35. package/lib/config.mjs +283 -0
  36. package/lib/config.py +317 -0
  37. package/lib/doc-template.html +807 -0
  38. package/lib/entropy-drain.plist.in +59 -0
  39. package/lib/entropy-drain.service.in +53 -0
  40. package/lib/entropy-drain.timer.in +36 -0
  41. package/lib/fail-first.mjs +901 -0
  42. package/lib/handoff-guard.sh +623 -0
  43. package/lib/install-hooks.sh +169 -0
  44. package/lib/notes.py +675 -0
  45. package/lib/preflight-tree.mjs +82 -0
  46. package/lib/roots.sh +212 -0
  47. package/lib/themes/daylight.css +84 -0
  48. package/lib/themes/high-contrast.css +36 -0
  49. package/lib/tracker-file +333 -0
  50. package/lib/tracker-view.py +784 -0
  51. package/package.json +38 -0
@@ -0,0 +1,901 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fail-first — a commit that claims a regression guard must SHOW the guard red
4
+ * against the unfixed code.
5
+ *
6
+ * WHAT IT PROVES, AND WHAT IT DOES NOT
7
+ * ------------------------------------
8
+ * It proves one thing: the named test passes at the commit and does NOT pass
9
+ * once the commit's fix is reverted out from under it. That is the "fail-first"
10
+ * ritual, done by machine instead of by an agent remembering to do it and a
11
+ * human re-running it by hand.
12
+ *
13
+ * It does NOT read the assertion. A test can be red for the wrong reason and
14
+ * still satisfy this job — and, more importantly, an assertion that is vacuous
15
+ * in a way unrelated to the fix will sail through. The real case this cannot
16
+ * catch: a config-validation guard whose nested-key check matched a leaf name
17
+ * ANYWHERE in the document, so an unrelated sibling field satisfied a check
18
+ * meant for a specific nested key. It reported most of the missing keys, hid
19
+ * one of the exact cases it was written for, and it would have gone red on
20
+ * the unfixed file all the same. Nothing here would have noticed. Reading the
21
+ * assertion is still a human's job.
22
+ *
23
+ * Two further limits, stated so nobody reads more into a green run:
24
+ *
25
+ * - A commit that introduces the code and its test together often has no
26
+ * "unfixed" state the test can even COMPILE against. Reverting the source
27
+ * breaks the build; the run is red for a reason that proves nothing. That
28
+ * is reported as `inconclusive`, not as a pass — narrow `guard-revert` to
29
+ * specific hunks, or take the escape hatch.
30
+ * - Build-error detection is a short list of markers (see BUILD_MARKERS).
31
+ * A compile failure this list misses is counted as red. `guard-red:` is
32
+ * the fix for that: give the failure text you actually saw, and the
33
+ * reverted run has to produce it.
34
+ *
35
+ * DECLARATION
36
+ * -----------
37
+ * A commit declares a guard in the changelog fragment it already adds (the
38
+ * fragment directory is config.json's `changelog.fragmentDir`). It is
39
+ * opt-in and there is no inference from the diff: intent cannot be read off
40
+ * a patch, and a job that guesses produces false positives on ordinary
41
+ * refactors. A job that cries wolf gets ignored, which is worse than no job.
42
+ * A commit that declares nothing is a silent pass — most commits are not
43
+ * guards.
44
+ *
45
+ * ---
46
+ * status: pending
47
+ * date: 2026-08-05
48
+ * issue: i-example
49
+ * title: "fix(parser): flagKeys mapped a flag no command declares"
50
+ * guard-test: go test ./internal/config -run '^TestFlagKeysAreRealFlags$'
51
+ * guard-cwd: backend
52
+ * guard-revert: backend/internal/config/resolve.go
53
+ * guard-red: cmd/app/run.go declares no such flag
54
+ * ---
55
+ *
56
+ * guard-test (required to claim a guard) the command, run through `sh -c`.
57
+ * Point it at ONE test. It is run twice.
58
+ * guard-cwd directory to run it in, relative to the repo root. Default `.`.
59
+ * guard-revert whitespace-separated paths whose change this commit made and
60
+ * that get reverted. Default: every path the commit touched
61
+ * that is not a test, a doc, or a fragment. A path may name
62
+ * hunks — `path#1,3` — numbered as they appear in
63
+ * `git show <sha> -- <path>`, for when reverting the whole file
64
+ * takes the test's own scaffolding with it.
65
+ * guard-red text the reverted run's output must contain. Optional and
66
+ * worth writing: it is what separates "the test failed" from
67
+ * "the tree stopped compiling".
68
+ * guard-skip a reason. The escape hatch — same shape as SKIP_CHANGELOG=1:
69
+ * explicit, recorded in the repo, and greppable later. One form
70
+ * only, unlike changelog-guard's two, because there is only one
71
+ * caller and it reads the fragment.
72
+ *
73
+ * WHERE A BAD DECLARATION IS CAUGHT
74
+ * ---------------------------------
75
+ * `--lint` reads the fragments on disk and nothing else, so it can only decide
76
+ * whether a declaration is WELL-FORMED: known keys, a spec that parses. It has
77
+ * no commit, so it cannot know whether `guard-revert: src/shrared/x.ts` names a
78
+ * real path — a misspelling, or a path renamed by a later commit, lints clean
79
+ * forever and is only found when someone runs `--range` over that commit, which
80
+ * for a local-first repo may be never. A batch of fragments shipped an
81
+ * unrunnable spec that way once, all lint-clean, because lint had validated
82
+ * the key names and never looked at whether the value pointed anywhere real.
83
+ *
84
+ * So the half that needs a commit lives in `--scan`, which already walks the
85
+ * range: for every guard a commit declares, `resolveRevertSpecs` says whether
86
+ * the spec parses, names anything at all, and names only paths that commit
87
+ * actually changes. That is still git-and-node-builtins cheap, it runs before
88
+ * anything is installed, and it fails AT the commit that introduced the bad
89
+ * path. `checkGuard` calls the same function — the point is not to move the
90
+ * check out of the per-commit run, it is to reach it without one.
91
+ *
92
+ * What it still cannot say: that the path is the RIGHT one. A guard-revert
93
+ * naming a real file the commit changed but not the file carrying the fix is
94
+ * well-formed, runnable, and wrong; only the two runs show that, as `not-red`.
95
+ *
96
+ * HOW THE REVERT IS DONE
97
+ * ----------------------
98
+ * The commit's tree is extracted to a temp directory with `git archive` — the
99
+ * repo is never mutated, no worktree is registered, nothing is checked out.
100
+ * Whole-file reverts write the parent's blob (or delete a file the commit
101
+ * added), so they cannot fail to apply. Hunk selection is the only form that
102
+ * can: it reverse-applies a filtered patch with `git apply -R`, and a refusal
103
+ * is reported as `revert-unclean` rather than swallowed.
104
+ *
105
+ * MODES
106
+ * --commit <sha> check one commit
107
+ * --range A..B check every non-merge commit in the range
108
+ * --scan --range A..B print has_guards=true|false; also lints every
109
+ * fragment's guard-* keys AND every guard-revert
110
+ * path in the range against the commit that
111
+ * declared it (see below). Git and node builtins
112
+ * only — this is the cheap CI gate that decides
113
+ * whether the expensive setup runs at all. Exits
114
+ * non-zero on a declaration git alone can already
115
+ * call unrunnable.
116
+ * --lint lint guard-* keys in all fragments, nothing else
117
+ * --try <sha> ad-hoc run against a commit, taking the
118
+ * declaration from flags instead of a fragment:
119
+ * --test / --cwd / --revert / --red. For proving a
120
+ * guard before you commit it.
121
+ */
122
+
123
+ import { execFileSync, spawnSync } from 'node:child_process';
124
+ import fs from 'node:fs';
125
+ import os from 'node:os';
126
+ import path from 'node:path';
127
+ import { fileURLToPath } from 'node:url';
128
+ import { loadConfig, matchesAny } from './config.mjs';
129
+
130
+ /**
131
+ * FAIL_FIRST_REPO points the whole script at another repository. It exists for
132
+ * one reason: a test can build a throwaway repo with a real commit that really
133
+ * declares a guard, and run this script against it. Without the seam the only
134
+ * thing exercisable end to end is `--try`, and the path CI actually takes —
135
+ * fragment -> declaration -> revert -> two runs -> exit code — would ship
136
+ * untested.
137
+ */
138
+ export const REPO_ROOT = process.env.FAIL_FIRST_REPO
139
+ ? path.resolve(process.env.FAIL_FIRST_REPO)
140
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
141
+
142
+ /**
143
+ * config.json, loaded once against REPO_ROOT rather than the process cwd —
144
+ * this file's own REPO_ROOT seam exists precisely so it can be pointed at a
145
+ * repo other than the one it lives in, and the config it reads has to follow
146
+ * that same seam or a test repo would be checked against the harness's own
147
+ * settings instead of its own.
148
+ */
149
+ const CONFIG = loadConfig({ cwd: REPO_ROOT });
150
+
151
+ const FRAGMENT_PREFIX = 'changelog.d/';
152
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
153
+
154
+ /** Known guard keys. Anything else starting with `guard` is a typo, not a feature. */
155
+ export const GUARD_KEYS = ['guard-test', 'guard-cwd', 'guard-revert', 'guard-red', 'guard-skip'];
156
+
157
+ /**
158
+ * Substrings that mean the run never got as far as running the test. Narrow on
159
+ * purpose: a false positive here turns a genuine red into `inconclusive` and
160
+ * blocks a correct commit. A false negative counts a compile error as red,
161
+ * which `guard-red:` is there to close.
162
+ *
163
+ * Sourced from config.json's `guards.buildFailureMarkers` — empty by default,
164
+ * per docs/CONFIG.md's rule that a default must be inert rather than a guess
165
+ * at a toolchain. What a "tree didn't build" message looks like is specific
166
+ * to one language's compiler and one project's test runner; a project names
167
+ * its own (e.g. `Cannot find module` for a Node resolver, `no required module
168
+ * provides package` for `go build`) instead of inheriting someone else's.
169
+ */
170
+ export const BUILD_MARKERS = CONFIG.guards.buildFailureMarkers;
171
+
172
+ /**
173
+ * Paths whose revert would take the guard itself away, making the check
174
+ * vacuous. Sourced from config.json's `guards.testPathPatterns` — this file
175
+ * no longer guesses a test-file convention (it used to assume `tests/`,
176
+ * `_test.go` and `.test.ts`/`.spec.js` specifically); a project names its own.
177
+ */
178
+ export function isTestPath(p) {
179
+ return matchesAny(CONFIG.guards.testPathPatterns, p);
180
+ }
181
+
182
+ /**
183
+ * Paths that are not code, so reverting them proves nothing about a fix.
184
+ * The fragment directory is always excluded — a guard reverting its own
185
+ * declaration is a different bug than the one this file is checking for.
186
+ * Everything else is config.json's `guards.nonCodePatterns`.
187
+ */
188
+ export function isNonCodePath(p) {
189
+ return p.startsWith(FRAGMENT_PREFIX) || matchesAny(CONFIG.guards.nonCodePatterns, p);
190
+ }
191
+
192
+ /** Default revert set: what the commit changed, minus tests, docs and fragments. */
193
+ export function deriveRevertPaths(changedPaths) {
194
+ return changedPaths.filter((p) => !isTestPath(p) && !isNonCodePath(p));
195
+ }
196
+
197
+ /** `a/b.go c/d.go#1,3` -> [{path:'a/b.go',hunks:null},{path:'c/d.go',hunks:[1,3]}] */
198
+ export function parseRevertSpec(spec) {
199
+ const out = [];
200
+ for (const token of spec.split(/\s+/).filter(Boolean)) {
201
+ const hash = token.indexOf('#');
202
+ if (hash === -1) {
203
+ // A comma here is always a mistake, and it used to be a SILENT one: this
204
+ // spec is whitespace-separated, the comma separates HUNKS inside
205
+ // `path#1,3`, so `guard-revert: a.ts,b.ts` parsed as one path literally
206
+ // named "a.ts,b.ts" and the commit check then reported it as a path the
207
+ // commit does not change. Six fragments shipped that way on 2026-08-25
208
+ // and --lint passed all of them, because it validated key names and never
209
+ // looked inside the value. Caught here so both --lint and the per-commit
210
+ // path reject it, and the message names the fix.
211
+ if (token.includes(',')) {
212
+ throw new Error(
213
+ `revert spec ${JSON.stringify(token)} contains a comma: paths are separated by ` +
214
+ `SPACES, and a comma only separates hunk numbers after a '#' (path#1,3). ` +
215
+ `Write: ${token.split(',').filter(Boolean).join(' ')}`
216
+ );
217
+ }
218
+ out.push({ path: token, hunks: null });
219
+ continue;
220
+ }
221
+ const p = token.slice(0, hash);
222
+ const rest = token.slice(hash + 1);
223
+ const hunks = rest
224
+ .split(',')
225
+ .filter(Boolean)
226
+ .map((n) => {
227
+ if (!/^\d+$/.test(n)) throw new Error(`bad hunk number ${JSON.stringify(n)} in ${token}`);
228
+ return Number(n);
229
+ });
230
+ if (!p) throw new Error(`revert spec ${JSON.stringify(token)} has no path`);
231
+ if (hunks.length === 0) throw new Error(`revert spec ${JSON.stringify(token)} names no hunks`);
232
+ out.push({ path: p, hunks });
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Settle a declaration's revert set against the paths a commit changed.
239
+ * Returns `{specs}` when it is runnable, or `{problem:{code,detail}}` when git
240
+ * alone can already say it is not.
241
+ *
242
+ * Everything here needs the commit's name-only diff and nothing else — no
243
+ * checkout, no archive, no toolchain — which is why both `--scan` and
244
+ * `checkGuard` call it. Keeping the two in one function is the point: they used
245
+ * to disagree about when a spec was usable, and the cheap gate that runs on
246
+ * every push was the one that did not look.
247
+ */
248
+ export function resolveRevertSpecs(decl, changed) {
249
+ let specs;
250
+ try {
251
+ specs = decl.revert
252
+ ? parseRevertSpec(decl.revert)
253
+ : deriveRevertPaths(changed).map((p) => ({ path: p, hunks: null }));
254
+ } catch (err) {
255
+ return { problem: { code: 'bad-revert-spec', detail: err.message } };
256
+ }
257
+
258
+ if (specs.length === 0) {
259
+ return {
260
+ problem: {
261
+ code: 'revert-empty',
262
+ detail:
263
+ 'nothing to revert: every path this commit touches is a test, a doc or a fragment. ' +
264
+ 'Name the fix explicitly with guard-revert, or take guard-skip with a reason.',
265
+ },
266
+ };
267
+ }
268
+
269
+ for (const { path: p } of specs) {
270
+ if (!changed.includes(p)) {
271
+ return {
272
+ problem: {
273
+ code: 'bad-revert-spec',
274
+ detail: `guard-revert names ${p}, which this commit does not change`,
275
+ },
276
+ };
277
+ }
278
+ }
279
+ return { specs };
280
+ }
281
+
282
+ /**
283
+ * Keep only the numbered hunks of a single-file patch. Hunks are numbered in
284
+ * the order `git show` prints them, 1-based.
285
+ */
286
+ export function filterHunks(patch, wanted) {
287
+ const lines = patch.split('\n');
288
+ const firstHunk = lines.findIndex((l) => l.startsWith('@@'));
289
+ if (firstHunk === -1) {
290
+ throw new Error('patch has no @@ hunks (binary, rename or mode-only change?)');
291
+ }
292
+ const head = lines.slice(0, firstHunk);
293
+ const hunks = [];
294
+ let current = null;
295
+ for (const line of lines.slice(firstHunk)) {
296
+ if (line.startsWith('@@')) {
297
+ if (current) hunks.push(current);
298
+ current = [line];
299
+ } else if (current) {
300
+ current.push(line);
301
+ }
302
+ }
303
+ if (current) hunks.push(current);
304
+
305
+ for (const n of wanted) {
306
+ if (n < 1 || n > hunks.length) {
307
+ throw new Error(`hunk ${n} does not exist (${hunks.length} hunk(s) in this file's diff)`);
308
+ }
309
+ }
310
+ const kept = wanted
311
+ .slice()
312
+ .sort((a, b) => a - b)
313
+ .map((n) => hunks[n - 1].join('\n'));
314
+ return `${head.join('\n')}\n${kept.join('\n')}`.replace(/\n*$/, '\n');
315
+ }
316
+
317
+ /**
318
+ * What a reverted run's result means.
319
+ * exit 0 -> not-red: the guard proves nothing
320
+ * non-zero, guard-red set+missing -> wrong-red
321
+ * non-zero, build marker, no red -> inconclusive
322
+ * non-zero -> red
323
+ */
324
+ export function classifyReverted({ code, output, red }) {
325
+ if (code === 0) return { outcome: 'not-red' };
326
+ if (red) {
327
+ return output.includes(red) ? { outcome: 'red' } : { outcome: 'wrong-red', needle: red };
328
+ }
329
+ const marker = BUILD_MARKERS.find((m) => output.includes(m));
330
+ if (marker) return { outcome: 'inconclusive', marker };
331
+ return { outcome: 'red' };
332
+ }
333
+
334
+ /** Parse a `---`-delimited fragment header. Same shape changelog-collate reads. */
335
+ export function parseHeader(name, text) {
336
+ const lines = text.split('\n');
337
+ let i = 0;
338
+ while (i < lines.length && lines[i].trim() === '') i++;
339
+ if (lines[i]?.trim() !== '---') {
340
+ throw new Error(`${name}: expected a \`---\` header block on the first non-blank line`);
341
+ }
342
+ i++;
343
+ const header = {};
344
+ for (; i < lines.length; i++) {
345
+ if (lines[i].trim() === '---') break;
346
+ const m = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
347
+ if (!m) continue; // collate is the parser of record for malformed headers
348
+ let value = m[2].trim();
349
+ if (
350
+ (value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
351
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)
352
+ ) {
353
+ value = value.slice(1, -1);
354
+ }
355
+ header[m[1]] = value;
356
+ }
357
+ return header;
358
+ }
359
+
360
+ /**
361
+ * Turn a fragment header into a declaration.
362
+ * {kind:'none'} no claim -> silent pass
363
+ * {kind:'skip', reason}
364
+ * {kind:'guard', test, cwd, revert, red}
365
+ * Throws on a header that claims something it cannot mean.
366
+ */
367
+ export function readDeclaration(name, header) {
368
+ // Case-sensitive on purpose: `Guard-Test:` would otherwise parse as a header
369
+ // key, be looked up under the lowercase name, come back undefined, and the
370
+ // commit would pass as claiming nothing. A claim that silently does not exist
371
+ // is the failure this whole file is about.
372
+ const unknown = Object.keys(header).filter((k) => /^guard/i.test(k) && !GUARD_KEYS.includes(k));
373
+ if (unknown.length) {
374
+ throw new Error(
375
+ `${name}: unknown key(s) ${unknown.join(', ')} — did you mean one of ${GUARD_KEYS.join(', ')}?`,
376
+ );
377
+ }
378
+ const skip = header['guard-skip'];
379
+ const test = header['guard-test'];
380
+ if (skip !== undefined) {
381
+ if (!skip.trim()) throw new Error(`${name}: guard-skip needs a reason, not an empty value`);
382
+ return { kind: 'skip', reason: skip.trim(), test: test?.trim() || null };
383
+ }
384
+ if (!test) {
385
+ for (const k of ['guard-revert', 'guard-red', 'guard-cwd']) {
386
+ if (header[k]) throw new Error(`${name}: ${k} without guard-test — nothing would run it`);
387
+ }
388
+ return { kind: 'none' };
389
+ }
390
+ if (!test.trim()) throw new Error(`${name}: guard-test is empty`);
391
+ return {
392
+ kind: 'guard',
393
+ test: test.trim(),
394
+ cwd: (header['guard-cwd'] || '.').trim(),
395
+ revert: header['guard-revert']?.trim() || null,
396
+ red: header['guard-red']?.trim() || null,
397
+ };
398
+ }
399
+
400
+ // ---------------------------------------------------------------------------
401
+ // git + process plumbing
402
+ // ---------------------------------------------------------------------------
403
+
404
+ function git(args, opts = {}) {
405
+ return execFileSync('git', ['-C', REPO_ROOT, ...args], {
406
+ encoding: 'utf8',
407
+ maxBuffer: 256 * 1024 * 1024,
408
+ ...opts,
409
+ });
410
+ }
411
+
412
+ function gitOk(args) {
413
+ const r = spawnSync('git', ['-C', REPO_ROOT, ...args], { encoding: 'utf8' });
414
+ return r.status === 0;
415
+ }
416
+
417
+ function shortLog(sha) {
418
+ return git(['log', '-1', '--format=%h %s', sha]).trim();
419
+ }
420
+
421
+ function changedPaths(sha) {
422
+ return git(['show', '--pretty=format:', '--name-only', '--no-renames', '--diff-filter=ACMRD', sha])
423
+ .split('\n')
424
+ .map((l) => l.trim())
425
+ .filter(Boolean);
426
+ }
427
+
428
+ /** Fragments this commit ADDED, as [{name, header}]. */
429
+ function addedFragments(sha) {
430
+ const names = git([
431
+ 'show',
432
+ '--pretty=format:',
433
+ '--name-only',
434
+ '--diff-filter=A',
435
+ sha,
436
+ '--',
437
+ FRAGMENT_PREFIX,
438
+ ])
439
+ .split('\n')
440
+ .map((l) => l.trim())
441
+ .filter((l) => l.endsWith('.md') && !path.basename(l).startsWith('_'));
442
+
443
+ return names.map((name) => ({
444
+ name,
445
+ header: parseHeader(name, git(['show', `${sha}:${name}`])),
446
+ }));
447
+ }
448
+
449
+ /**
450
+ * Does `p`, at this commit, define a test the guard command names?
451
+ *
452
+ * Pulls the test identifiers out of guard-test — Go's `-run '^TestFoo$'` and
453
+ * vitest/jest path arguments both end up as bare words — and looks for a
454
+ * definition of one inside the file. Deliberately conservative: when the
455
+ * command names nothing recognisable, or the blob cannot be read, it answers
456
+ * TRUE, so an unparseable declaration keeps the old blanket rejection rather
457
+ * than silently permitting a vacuous revert.
458
+ */
459
+ function definesGuardTest(sha, p, testCmd) {
460
+ // A path argument to the runner is the guard's own file, plainly.
461
+ if (testCmd.includes(p) || testCmd.includes(path.basename(p))) return true;
462
+ const names = [...testCmd.matchAll(/\b(Test[A-Za-z0-9_]+)\b/g)].map((m) => m[1]);
463
+ if (names.length === 0) return true;
464
+ let blob;
465
+ try {
466
+ blob = git(['show', `${sha}:${p}`]);
467
+ } catch {
468
+ return true;
469
+ }
470
+ return names.some((n) => new RegExp(`func\\s+${n}\\s*\\(`).test(blob));
471
+ }
472
+
473
+ function extractTree(sha, dest) {
474
+ fs.mkdirSync(dest, { recursive: true });
475
+ const tar = path.join(dest, '..', `tree-${process.pid}.tar`);
476
+ git(['archive', '--format=tar', '-o', tar, sha]);
477
+ execFileSync('tar', ['-xf', tar, '-C', dest]);
478
+ fs.rmSync(tar, { force: true });
479
+ // vitest and anything else node-side needs the deps; the archive has none.
480
+ const deps = path.join(REPO_ROOT, 'node_modules');
481
+ if (fs.existsSync(deps) && !fs.existsSync(path.join(dest, 'node_modules'))) {
482
+ fs.symlinkSync(deps, path.join(dest, 'node_modules'), 'dir');
483
+ }
484
+ regenerate(dest);
485
+ }
486
+
487
+ /**
488
+ * Run `generate.cmd` from config.json inside the extracted tree.
489
+ *
490
+ * WHY THIS STEP EXISTS AT ALL. `git archive` carries TRACKED files only, and a
491
+ * project's generated modules are usually gitignored on purpose. Anything
492
+ * importing one fails to RESOLVE inside the archive, which surfaces here as
493
+ * `control-not-green` — on a guard that passes perfectly well in the working
494
+ * tree.
495
+ *
496
+ * That is worse than it sounds. The failure is indistinguishable from "your
497
+ * test is broken", so the natural response is to mark the guard skipped — and
498
+ * then every guard whose module graph reaches a generated file drifts into
499
+ * being skipped, one at a time, each for a locally reasonable reason. The job
500
+ * stays green while quietly covering less and less. This was found exactly
501
+ * that way: a guard whose only sin was importing a generated module.
502
+ *
503
+ * Best-effort by design: a project with no generate.cmd, or one whose generate
504
+ * fails, still gets the run — it just fails the same way it would have anyway.
505
+ * A regeneration step that can veto the whole job would be a worse trade.
506
+ */
507
+ function regenerate(dest) {
508
+ if (!fs.existsSync(path.join(dest, 'package.json'))) return;
509
+ try {
510
+ execFileSync('npm', ['run', 'gen', '--silent'], {
511
+ cwd: dest, stdio: 'ignore', timeout: 120_000,
512
+ });
513
+ } catch {
514
+ // leave the tree as-extracted; the run below reports the real failure
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Undo the commit's change to the given paths inside `tree`.
520
+ * Whole-file: write the parent's blob, or delete a file the commit added.
521
+ * Hunk-selected: reverse-apply a filtered patch, which can refuse.
522
+ */
523
+ function applyRevert(sha, tree, specs) {
524
+ const parent = `${sha}^`;
525
+ for (const { path: rel, hunks } of specs) {
526
+ const target = path.join(tree, rel);
527
+ if (hunks) {
528
+ const patch = git(['diff', '--no-renames', '--no-color', parent, sha, '--', rel]);
529
+ if (!patch.trim()) throw new Error(`${rel}: this commit does not change it`);
530
+ const filtered = filterHunks(patch, hunks);
531
+ const file = path.join(tree, `.fail-first-${path.basename(rel)}.patch`);
532
+ fs.writeFileSync(file, filtered);
533
+ const r = spawnSync('git', ['apply', '-R', '--verbose', file], {
534
+ cwd: tree,
535
+ encoding: 'utf8',
536
+ });
537
+ fs.rmSync(file, { force: true });
538
+ if (r.status !== 0) {
539
+ throw new Error(`revert-unclean: git apply -R refused ${rel}#${hunks.join(',')}\n${r.stderr}`);
540
+ }
541
+ continue;
542
+ }
543
+ if (gitOk(['cat-file', '-e', `${parent}:${rel}`])) {
544
+ const before = git(['show', `${parent}:${rel}`], { encoding: 'buffer' });
545
+ fs.mkdirSync(path.dirname(target), { recursive: true });
546
+ fs.writeFileSync(target, before);
547
+ } else {
548
+ fs.rmSync(target, { force: true }); // the commit added it
549
+ }
550
+ }
551
+ }
552
+
553
+ function runTest(cmd, cwd, timeout) {
554
+ const r = spawnSync('sh', ['-c', cmd], {
555
+ cwd,
556
+ encoding: 'utf8',
557
+ timeout,
558
+ maxBuffer: 64 * 1024 * 1024,
559
+ env: {
560
+ ...process.env,
561
+ CI: process.env.CI ?? '1',
562
+ // `go test` caches passing results, and the two runs here differ only in
563
+ // a file the test opens at runtime. Caught during development:
564
+ // the control run cached a PASS for the guard test and
565
+ // the reverted run was served that cache, so the job reported `not-red`
566
+ // for a guard that is red the moment you run it by hand. A cached green is
567
+ // the exact failure this whole check exists to stop, so -count=1 goes in
568
+ // the environment where the author cannot forget it. The BUILD cache is
569
+ // untouched; only the result cache is bypassed.
570
+ GOFLAGS: `${process.env.GOFLAGS ? `${process.env.GOFLAGS} ` : ''}-count=1`,
571
+ },
572
+ });
573
+ const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
574
+ if (r.error && r.error.code === 'ETIMEDOUT') {
575
+ return { code: 124, output: `${output}\nfail-first: timed out after ${timeout}ms` };
576
+ }
577
+ if (r.error) return { code: 125, output: `${output}\nfail-first: ${r.error.message}` };
578
+ return { code: r.status ?? 1, output };
579
+ }
580
+
581
+ function tail(text, n = 40) {
582
+ const lines = text.trimEnd().split('\n');
583
+ return lines
584
+ .slice(-n)
585
+ .map((l) => ` | ${l}`)
586
+ .join('\n');
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // the check
591
+ // ---------------------------------------------------------------------------
592
+
593
+ /**
594
+ * Run one declared guard against one commit.
595
+ * Returns {ok, code, detail}. `code` is a stable label for the report.
596
+ */
597
+ export function checkGuard(sha, decl, { timeout = DEFAULT_TIMEOUT_MS, log = console.log } = {}) {
598
+ if (!gitOk(['rev-parse', '--verify', `${sha}^{commit}`])) {
599
+ return { ok: false, code: 'no-such-commit', detail: sha };
600
+ }
601
+ if (!gitOk(['rev-parse', '--verify', `${sha}^^{commit}`])) {
602
+ return { ok: false, code: 'no-parent', detail: 'a root commit has no unfixed state to revert to' };
603
+ }
604
+
605
+ const changed = changedPaths(sha);
606
+ // Parse, non-empty and path-exists all live in resolveRevertSpecs, because
607
+ // --scan reaches the same verdicts from git alone, before anything installs.
608
+ const { specs, problem } = resolveRevertSpecs(decl, changed);
609
+ if (problem) return { ok: false, code: problem.code, detail: problem.detail };
610
+
611
+ for (const { path: p } of specs) {
612
+ // Reverting the file that DEFINES the guard is vacuous — the check would
613
+ // not be running against unfixed code, it would not be running. But this
614
+ // rejected EVERY *_test.go, which is too blunt: when a commit's fix and its
615
+ // sibling tests land together, those siblings reference symbols the
616
+ // reverted source no longer has, and keeping them turns an assertion-red
617
+ // into a compile-red. A compile-red proves the tree does not build, not
618
+ // that the guard catches the defect.
619
+ //
620
+ // Found on a change whose guard test lived in one file while the revert
621
+ // legitimately needed two OTHER test files reverted alongside it. The
622
+ // author's by-hand A/B had done exactly that and was sound; only the
623
+ // declaration could not express it.
624
+ if (isTestPath(p) && definesGuardTest(sha, p, decl.test)) {
625
+ return {
626
+ ok: false,
627
+ code: 'bad-revert-spec',
628
+ detail: `guard-revert names ${p}, which defines the guard test itself — reverting the guard along with the fix makes the check vacuous`,
629
+ };
630
+ }
631
+ }
632
+
633
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fail-first-'));
634
+ const tree = path.join(tmp, 'tree');
635
+ try {
636
+ extractTree(sha, tree);
637
+ const cwd = path.resolve(tree, decl.cwd);
638
+ if (!fs.existsSync(cwd)) {
639
+ return { ok: false, code: 'bad-cwd', detail: `guard-cwd ${decl.cwd} does not exist at this commit` };
640
+ }
641
+
642
+ log(` control $ ${decl.test}`);
643
+ const control = runTest(decl.test, cwd, timeout);
644
+ if (control.code !== 0) {
645
+ return {
646
+ ok: false,
647
+ code: 'control-not-green',
648
+ detail:
649
+ `the test does not pass at this commit (exit ${control.code}), so a red run below would ` +
650
+ `prove nothing about the fix. Check the command names a real, passing test.\n${tail(control.output)}`,
651
+ };
652
+ }
653
+
654
+ try {
655
+ applyRevert(sha, tree, specs);
656
+ } catch (err) {
657
+ const unclean = String(err.message).startsWith('revert-unclean');
658
+ return {
659
+ ok: false,
660
+ code: unclean ? 'revert-unclean' : 'bad-revert-spec',
661
+ detail: String(err.message),
662
+ };
663
+ }
664
+
665
+ log(` reverted $ ${decl.test} [reverted: ${specs.map((s) => (s.hunks ? `${s.path}#${s.hunks.join(',')}` : s.path)).join(' ')}]`);
666
+ const reverted = runTest(decl.test, cwd, timeout);
667
+ const verdict = classifyReverted({ code: reverted.code, output: reverted.output, red: decl.red });
668
+
669
+ if (verdict.outcome === 'red') {
670
+ return { ok: true, code: 'red', detail: `exit ${reverted.code} with the fix reverted` };
671
+ }
672
+ if (verdict.outcome === 'not-red') {
673
+ return {
674
+ ok: false,
675
+ code: 'not-red',
676
+ detail:
677
+ 'the test PASSES with the fix reverted. It does not guard what the commit claims — ' +
678
+ 'either it asserts something the fix did not change, or guard-revert is pointed at the wrong hunk.',
679
+ };
680
+ }
681
+ if (verdict.outcome === 'wrong-red') {
682
+ return {
683
+ ok: false,
684
+ code: 'wrong-red',
685
+ detail: `red, but the output does not contain guard-red ${JSON.stringify(verdict.needle)}.\n${tail(reverted.output)}`,
686
+ };
687
+ }
688
+ return {
689
+ ok: false,
690
+ code: 'inconclusive',
691
+ detail:
692
+ `red, but the output carries ${JSON.stringify(verdict.marker)} — the tree stopped building, so the ` +
693
+ `test never ran and nothing is proven. Narrow guard-revert to hunks (path#1,3), set guard-red to the ` +
694
+ `failure you actually saw, or take guard-skip with a reason.\n${tail(reverted.output)}`,
695
+ };
696
+ } finally {
697
+ // FAIL_FIRST_KEEP=1 leaves the reverted tree on disk. The only way to see
698
+ // what a red run was actually looking at.
699
+ if (process.env.FAIL_FIRST_KEEP === '1') log(` kept ${tree}`);
700
+ else fs.rmSync(tmp, { recursive: true, force: true });
701
+ }
702
+ }
703
+
704
+ /** Every declaration a commit makes, one per fragment it adds. */
705
+ function declarationsFor(sha) {
706
+ return addedFragments(sha).map(({ name, header }) => ({
707
+ name,
708
+ decl: readDeclaration(name, header),
709
+ }));
710
+ }
711
+
712
+ function checkCommit(sha, opts = {}) {
713
+ const label = shortLog(sha);
714
+ let decls;
715
+ try {
716
+ decls = declarationsFor(sha);
717
+ } catch (err) {
718
+ console.error(`fail-first: ${label}\n ${err.message}`);
719
+ return { checked: 0, failed: 1, skipped: 0 };
720
+ }
721
+
722
+ let checked = 0;
723
+ let failed = 0;
724
+ let skipped = 0;
725
+ for (const { name, decl } of decls) {
726
+ if (decl.kind === 'none') continue;
727
+ if (decl.kind === 'skip') {
728
+ skipped++;
729
+ console.log(`fail-first: ${label}\n ~ ${name}: guard-skip — ${decl.reason}`);
730
+ continue;
731
+ }
732
+ checked++;
733
+ console.log(`fail-first: ${label}\n ? ${name}`);
734
+ const r = checkGuard(sha, decl, opts);
735
+ if (r.ok) {
736
+ console.log(` ✓ fail-first proven: ${r.detail}`);
737
+ } else {
738
+ failed++;
739
+ console.error(` ✗ ${r.code}: ${r.detail}`);
740
+ if (process.env.GITHUB_ACTIONS) {
741
+ console.error(`::error::fail-first ${r.code} — ${label} (${name})`);
742
+ }
743
+ }
744
+ }
745
+ return { checked, failed, skipped };
746
+ }
747
+
748
+ function listFragmentFiles() {
749
+ const dir = path.join(REPO_ROOT, 'changelog.d');
750
+ if (!fs.existsSync(dir)) return [];
751
+ return fs
752
+ .readdirSync(dir)
753
+ .filter((n) => n.endsWith('.md') && !n.startsWith('_') && !n.startsWith('.'))
754
+ .sort();
755
+ }
756
+
757
+ /** Guard-key lint over every fragment on disk. Node builtins only. */
758
+ function lintAll() {
759
+ const problems = [];
760
+ let claims = 0;
761
+ for (const name of listFragmentFiles()) {
762
+ const text = fs.readFileSync(path.join(REPO_ROOT, 'changelog.d', name), 'utf8');
763
+ try {
764
+ const decl = readDeclaration(name, parseHeader(name, text));
765
+ if (decl.kind !== 'none') claims++;
766
+ } catch (err) {
767
+ problems.push(err.message);
768
+ }
769
+ }
770
+ for (const p of problems) console.error(`fail-first: ${p}`);
771
+ return { problems: problems.length, claims };
772
+ }
773
+
774
+ function rangeCommits(range) {
775
+ return git(['rev-list', '--no-merges', '--reverse', range])
776
+ .split('\n')
777
+ .map((l) => l.trim())
778
+ .filter(Boolean);
779
+ }
780
+
781
+ function flag(args, name) {
782
+ const i = args.indexOf(name);
783
+ return i === -1 ? undefined : args[i + 1];
784
+ }
785
+
786
+ function main() {
787
+ const args = process.argv.slice(2);
788
+ const timeout = Number(flag(args, '--timeout') ?? DEFAULT_TIMEOUT_MS);
789
+
790
+ if (args.includes('--lint')) {
791
+ const { problems, claims } = lintAll();
792
+ if (problems) process.exit(1);
793
+ console.log(`fail-first: fragment guard keys OK (${claims} carry a claim).`);
794
+ return;
795
+ }
796
+
797
+ if (args.includes('--scan')) {
798
+ const { problems } = lintAll();
799
+ if (problems) process.exit(1);
800
+ const range = flag(args, '--range');
801
+ const one = flag(args, '--commit');
802
+ const shas = range ? rangeCommits(range) : one ? [one] : [];
803
+ let any = false;
804
+ let bad = 0;
805
+ // No early break once a guard is found, unlike before: the point of walking
806
+ // the whole range is to check every declaration's revert set against its
807
+ // OWN commit. It is still one `git show --name-only` per commit.
808
+ for (const sha of shas) {
809
+ let decls;
810
+ try {
811
+ decls = declarationsFor(sha);
812
+ } catch {
813
+ any = true; // malformed: let the real run report it
814
+ continue;
815
+ }
816
+ const guards = decls.filter((d) => d.decl.kind === 'guard');
817
+ if (guards.length) any = true;
818
+ let changed = null;
819
+ for (const { name, decl } of guards) {
820
+ changed ??= changedPaths(sha);
821
+ const { problem } = resolveRevertSpecs(decl, changed);
822
+ if (!problem) continue;
823
+ bad++;
824
+ console.error(`fail-first: ${shortLog(sha)}\n ✗ ${problem.code}: ${name}: ${problem.detail}`);
825
+ if (process.env.GITHUB_ACTIONS) {
826
+ console.error(`::error::fail-first ${problem.code} — ${shortLog(sha)} (${name})`);
827
+ }
828
+ }
829
+ }
830
+ if (bad) {
831
+ console.error(
832
+ `\nfail-first: ${bad} guard declaration(s) name a revert set this commit cannot use. ` +
833
+ `Nothing was run — fix the declaration, or take guard-skip with a reason.`,
834
+ );
835
+ process.exit(1);
836
+ }
837
+ const line = `has_guards=${any}`;
838
+ console.log(line);
839
+ if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${line}\n`);
840
+ return;
841
+ }
842
+
843
+ const trySha = flag(args, '--try');
844
+ if (trySha) {
845
+ const test = flag(args, '--test');
846
+ if (!test) {
847
+ console.error('--try needs --test "<command>"');
848
+ process.exit(2);
849
+ }
850
+ const decl = {
851
+ kind: 'guard',
852
+ test,
853
+ cwd: flag(args, '--cwd') ?? '.',
854
+ revert: flag(args, '--revert') ?? null,
855
+ red: flag(args, '--red') ?? null,
856
+ };
857
+ console.log(`fail-first: ${shortLog(trySha)}\n ? --try`);
858
+ const r = checkGuard(trySha, decl, { timeout });
859
+ if (r.ok) {
860
+ console.log(` ✓ fail-first proven: ${r.detail}`);
861
+ return;
862
+ }
863
+ console.error(` ✗ ${r.code}: ${r.detail}`);
864
+ process.exit(1);
865
+ }
866
+
867
+ const one = flag(args, '--commit');
868
+ const range = flag(args, '--range');
869
+ if (!one && !range) {
870
+ console.error(
871
+ 'usage: fail-first.mjs [--commit <sha> | --range A..B | --scan --range A..B | --lint | --try <sha> --test "<cmd>"]',
872
+ );
873
+ process.exit(2);
874
+ }
875
+
876
+ const { problems } = lintAll();
877
+ if (problems) process.exit(1);
878
+
879
+ const shas = one ? [one] : rangeCommits(range);
880
+ let checked = 0;
881
+ let failed = 0;
882
+ let skipped = 0;
883
+ for (const sha of shas) {
884
+ const r = checkCommit(sha, { timeout });
885
+ checked += r.checked;
886
+ failed += r.failed;
887
+ skipped += r.skipped;
888
+ }
889
+
890
+ if (failed) {
891
+ console.error(`\nfail-first: ${failed} of ${checked} claimed guard(s) not proven.`);
892
+ process.exit(1);
893
+ }
894
+ console.log(
895
+ `fail-first: ${shas.length} commit(s), ${checked} guard(s) proven, ${skipped} skipped by guard-skip.`,
896
+ );
897
+ }
898
+
899
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
900
+ main();
901
+ }