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.
- package/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- 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
|
+
}
|