eval-quality 3.1.0 → 3.2.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.
@@ -0,0 +1,618 @@
1
+ // A published gate: every fenced command-line invocation in the pages a
2
+ // consumer names is run against the binary they name, and the exit code is
3
+ // compared with what the page claims.
4
+ //
5
+ // The check exists because the documentation once described a product this
6
+ // repository does not contain. A usage exit is what a command line returns when
7
+ // a command or a flag does not exist, so a reference page only survives the
8
+ // build while every flag it documents is one the parser really has.
9
+ //
10
+ // A usage exit is not the only way a documented example can be wrong. An
11
+ // example whose inputs no longer parse exits with some other code, and for a
12
+ // while every pre-flight example in this repository's site did exactly that,
13
+ // against a type that had become a discriminated union, while this check
14
+ // reported no problems. So the exit code is judged too, wherever judging it
15
+ // means anything:
16
+ //
17
+ // * A usage error and a crash always fail, for every invocation. Both are
18
+ // about the command line alone, so a stand-in input cannot excuse them.
19
+ // * An invocation is FAITHFUL when every input it names resolved to real
20
+ // bytes: a file the repository ships, a file the same page told the reader
21
+ // to create, or an artifact an earlier command on the page wrote. A
22
+ // faithful run is the page's own claim, so it has to exit 0.
23
+ // * A page that deliberately demonstrates a failure declares the code it
24
+ // expects, in an HTML comment on the line before the fence:
25
+ //
26
+ // <!-- expect-exit: 4 -->
27
+ //
28
+ // A faithful run under that declaration has to exit exactly 4. Declaring a
29
+ // code the run does not produce fails too: a documented rejection that
30
+ // stopped rejecting is as stale as a flag that stopped existing.
31
+ // * Everything else names a file only the reader has, such as `<path>`, so
32
+ // this check substitutes a stand-in and the exit code says nothing about
33
+ // the page. Those keep the usage-error judgment and no more.
34
+ //
35
+ // The exit code alone is a weak claim, because it is shared. One code commonly
36
+ // covers a whole family of failures, so a page can name one failure while the
37
+ // binary reports another and the codes still agree. A page that declares its
38
+ // exit code may therefore transcribe the diagnostic beside it, and that block is
39
+ // compared line for line against what the run wrote to stderr. Four rules shape
40
+ // which block gets compared:
41
+ //
42
+ // * The block is a `text` fence separated from the command's fence by blank
43
+ // lines only. Prose between them detaches it, and a fence carrying any
44
+ // other label is left alone.
45
+ // * The fence above it holds exactly one invocation. Two commands share the
46
+ // fence's declaration, so neither owns the block.
47
+ // * Each documented line has to describe the stderr line at the same
48
+ // position, whole. `...` inside a line elides a run of characters there,
49
+ // and a line that is exactly `...` matches any one line. Without an
50
+ // elision the documented line has to be the entire line, because failure
51
+ // codes share prefixes and an unanchored tail would describe a sibling
52
+ // failure as readily as its own. A page cannot transcribe a literal
53
+ // ellipsis. The indentation the block shares is stripped before any of
54
+ // this, so a fence inside a list item compares the same as one at the
55
+ // margin, and whatever indentation the diagnostic itself emits survives.
56
+ // * Stderr may run past the block, and the block may never run past stderr.
57
+ // A page that transcribes the first lines of a longer diagnostic is making
58
+ // a claim about those lines, and the lines it left out stay unchecked. An
59
+ // empty block claims nothing and is left unattached, and a line carrying
60
+ // more elisions than the configured limit is an error, since matching them
61
+ // is polynomial in the count.
62
+ //
63
+ // To make a page's own examples faithful, the run replays each page in
64
+ // document order inside its own sandbox: a `cat > path <<'EOF'` heredoc, an
65
+ // `echo ... > path` redirect, and a `mkdir -p` all take effect, and `--out`
66
+ // lands where the page says it does. Every one of those paths is rebased under
67
+ // a temporary directory first, so the check still writes nothing into the
68
+ // repository and nothing outside its own sandbox.
69
+ //
70
+ // The gate writes to no stream and calls no exit. It returns a report, and
71
+ // `gates-cli.ts` turns that into output and a code.
72
+ import { spawnSync } from 'node:child_process';
73
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs';
74
+ import { tmpdir } from 'node:os';
75
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
76
+ /** A path the configuration named and the tree does not have. */
77
+ export const DOC_PATH_ERROR = 'EVAL_QUALITY_DOC_PATH';
78
+ const codedError = (code, message) => Object.assign(new Error(message), { code });
79
+ /** What the shell would take over. Everything from here on is not the binary's. */
80
+ const SHELL_OPERATORS = new Set(['|', '||', '>', '>>', '<', '&&', ';', '&']);
81
+ const EXPECT_EXIT_PATTERN = /^<!--\s*expect-exit:\s*(\d{1,3})\s*-->$/;
82
+ /** What a page writes where it left characters, or a whole line, out. */
83
+ const ELISION = '...';
84
+ /**
85
+ * The pages under one root. A dependency's README and a tool's own directory
86
+ * carry fenced commands nobody here wrote, and running those is the inverse of
87
+ * what this check is for, so the walk never descends into either.
88
+ */
89
+ const isSkipped = (name) => name === 'node_modules' || name.startsWith('.');
90
+ const collectMarkdown = (target) => {
91
+ if (isSkipped(basename(target)))
92
+ return [];
93
+ const info = statSync(target, { throwIfNoEntry: false });
94
+ if (!info)
95
+ return [];
96
+ if (info.isFile())
97
+ return target.endsWith('.md') ? [target] : [];
98
+ return readdirSync(target).flatMap((entry) => collectMarkdown(join(target, entry)));
99
+ };
100
+ /** Splits on whitespace, keeping a quoted value in one piece. */
101
+ function tokenize(line) {
102
+ const tokens = [];
103
+ let current = '';
104
+ let quote = null;
105
+ let started = false;
106
+ for (const char of line) {
107
+ if (quote !== null) {
108
+ if (char === quote)
109
+ quote = null;
110
+ else
111
+ current += char;
112
+ continue;
113
+ }
114
+ if (char === '"' || char === "'") {
115
+ quote = char;
116
+ started = true;
117
+ continue;
118
+ }
119
+ if (/\s/.test(char)) {
120
+ if (started)
121
+ tokens.push(current);
122
+ current = '';
123
+ started = false;
124
+ continue;
125
+ }
126
+ current += char;
127
+ started = true;
128
+ }
129
+ if (started)
130
+ tokens.push(current);
131
+ return tokens;
132
+ }
133
+ const isMetavariable = (token) => /^<.+>$/.test(token) || /^\[.+\]$/.test(token);
134
+ /** A bare word with no separator and no extension is an id or a subcommand. */
135
+ const looksLikePath = (token) => !token.startsWith('-') &&
136
+ (token.includes('/') || /\.[A-Za-z0-9]+$/.test(token));
137
+ const escapeForPattern = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
+ /**
139
+ * One spelling of the binary, as the page writes it, capturing the argument
140
+ * tail. The consumer declares the spelling as the literal text a reader types,
141
+ * so nothing here compiles a regular expression the configuration wrote.
142
+ *
143
+ * The bare form requires whitespace or end of line after the name, so a
144
+ * rendered diagnostic is left alone: a line a command line writes to stderr
145
+ * opens with the binary's name and a colon, and a documented sample of that
146
+ * output is not an invocation.
147
+ */
148
+ const spellingPattern = (spelling) => new RegExp(`^${spelling.trim().split(/\s+/).map(escapeForPattern).join('\\s+')}(?:\\s+(.*))?$`);
149
+ /**
150
+ * One page's sandbox. `root` is the working directory every command on the
151
+ * page runs in, and every path the page names lands under it, so an absolute
152
+ * path in the documentation reaches a file the check owns.
153
+ */
154
+ function createPageSandbox(workDir, index) {
155
+ const root = join(workDir, `page-${index}`);
156
+ mkdirSync(root, { recursive: true });
157
+ return {
158
+ root,
159
+ /** Where a documented path lives inside this sandbox. */
160
+ rebase: (documented) => isAbsolute(documented)
161
+ ? join(root, documented.slice(1))
162
+ : resolve(root, documented),
163
+ };
164
+ }
165
+ const writeInto = (target, contents) => {
166
+ mkdirSync(dirname(target), { recursive: true });
167
+ writeFileSync(target, contents, 'utf8');
168
+ };
169
+ /**
170
+ * Where one documented input token really points. `faithful` is false when the
171
+ * page named something only its reader has, which is what tells the caller the
172
+ * exit code of the run is not the page's own.
173
+ */
174
+ function realizeInput(token, sandbox, context) {
175
+ const { repoRoot, sampleInput, installedPrefix } = context;
176
+ if (isMetavariable(token))
177
+ return { value: sampleInput, faithful: false };
178
+ if (!looksLikePath(token))
179
+ return { value: token, faithful: true };
180
+ const candidates = installedPrefix !== undefined && token.startsWith(installedPrefix)
181
+ ? [
182
+ resolve(repoRoot, token.slice(installedPrefix.length)),
183
+ resolve(repoRoot, token),
184
+ ]
185
+ : [resolve(repoRoot, token)];
186
+ const shipped = candidates.find((candidate) => existsSync(candidate));
187
+ // The page's own bytes win over anything on the real filesystem. A page
188
+ // that writes `mcp-contract.json` and then reads it is describing the file
189
+ // it just wrote, and a reader who followed the page leaves a copy of that
190
+ // name at the clone root. Resolving against the repository first would read
191
+ // the leftover, so the gate would be measuring a file nobody is editing.
192
+ //
193
+ // A directory in the sandbox is the one exception. `--out` and `mkdir -p`
194
+ // create one under every path they are given, and `mkdir -p` over a path the
195
+ // repository already carries is a no-op in a reader's clone. So the empty
196
+ // sandbox copy yields: in front of a file it would fail the run on EISDIR,
197
+ // which the exit code reports as a usage error the page never made, and in
198
+ // front of a directory it would run the command over nothing at all.
199
+ const authored = sandbox.rebase(token);
200
+ const made = statSync(authored, { throwIfNoEntry: false });
201
+ const shadows = made?.isDirectory() === true && shipped !== undefined;
202
+ if (made !== undefined && !shadows) {
203
+ return { value: authored, faithful: true };
204
+ }
205
+ if (shipped !== undefined)
206
+ return { value: shipped, faithful: true };
207
+ return { value: sampleInput, faithful: false };
208
+ }
209
+ /**
210
+ * Rewrites the argument tail into something safe to execute, and reports
211
+ * whether every input in it resolved to real bytes.
212
+ */
213
+ function realizeArguments(tail, sandbox, context) {
214
+ const tokens = [];
215
+ let faithful = true;
216
+ const raw = tokenize(tail);
217
+ for (let index = 0; index < raw.length; index += 1) {
218
+ const token = raw[index];
219
+ if (SHELL_OPERATORS.has(token))
220
+ break;
221
+ // `--out` is where the page says it is, rebased into the sandbox, so the
222
+ // next command on the page can read what this one wrote.
223
+ if (token === '--out') {
224
+ const target = sandbox.rebase(raw[index + 1] ?? '.');
225
+ mkdirSync(target.endsWith('.json') ? dirname(target) : target, {
226
+ recursive: true,
227
+ });
228
+ tokens.push('--out', target);
229
+ index += 1;
230
+ continue;
231
+ }
232
+ if (token.startsWith('--out=')) {
233
+ const target = sandbox.rebase(token.slice('--out='.length));
234
+ mkdirSync(target.endsWith('.json') ? dirname(target) : target, {
235
+ recursive: true,
236
+ });
237
+ tokens.push(`--out=${target}`);
238
+ continue;
239
+ }
240
+ const equals = token.indexOf('=');
241
+ if (token.startsWith('--') && equals !== -1) {
242
+ const realized = realizeInput(token.slice(equals + 1), sandbox, context);
243
+ faithful &&= realized.faithful;
244
+ tokens.push(`${token.slice(0, equals)}=${realized.value}`);
245
+ continue;
246
+ }
247
+ const realized = realizeInput(token, sandbox, context);
248
+ faithful &&= realized.faithful;
249
+ tokens.push(realized.value);
250
+ }
251
+ return { tokens, faithful };
252
+ }
253
+ /** A fence's own label, so a `text` fence carrying attributes still counts. */
254
+ const labelOf = (fence) => fence.trim().slice(3).trim().split(/[\s{]/)[0];
255
+ /**
256
+ * A block without the indentation it shares, so a fence inside a list item
257
+ * compares against the same bytes it would at the margin. The shared prefix is
258
+ * the block's own, which keeps whatever indentation the diagnostic itself emits
259
+ * and stays right where the fence and its body are indented differently.
260
+ */
261
+ function dedent(block) {
262
+ const prefixes = block
263
+ .filter((line) => line.trim() !== '')
264
+ .map((line) => line.slice(0, line.length - line.trimStart().length));
265
+ const shared = prefixes.reduce((a, b) => {
266
+ let common = 0;
267
+ while (common < a.length && a[common] === b[common])
268
+ common += 1;
269
+ return a.slice(0, common);
270
+ }, prefixes[0] ?? '');
271
+ return block.map((line) => line.slice(shared.length));
272
+ }
273
+ /** The transcribed block without the blank lines that frame it in the page. */
274
+ function trimBlankEdges(block) {
275
+ let first = 0;
276
+ let last = block.length;
277
+ while (first < last && block[first].trim() === '')
278
+ first += 1;
279
+ while (last > first && block[last - 1].trim() === '')
280
+ last -= 1;
281
+ return block.slice(first, last);
282
+ }
283
+ /**
284
+ * Whether one documented output line describes the line the run really wrote.
285
+ * `...` elides a run of characters within the line, and the rest of the line is
286
+ * matched whole: failure codes share prefixes, so a documented line left
287
+ * hanging would describe a sibling failure as readily as its own. A line that
288
+ * is exactly `...` matches any one line.
289
+ */
290
+ function describesLine(documented, actual) {
291
+ if (documented === ELISION)
292
+ return true;
293
+ const pattern = documented
294
+ .split(ELISION)
295
+ .map(escapeForPattern)
296
+ .join('[\\s\\S]*');
297
+ return new RegExp(`^${pattern}$`).test(actual);
298
+ }
299
+ /**
300
+ * Pulls one page's actions out of its fenced blocks, in document order: the
301
+ * files it tells the reader to create, and the commands it tells them to run.
302
+ *
303
+ * A `$ ` prompt is stripped, a trailing backslash joins the next line, and a
304
+ * line that names no binary is output. A tail opening with a metavariable is a
305
+ * synopsis, so it is skipped.
306
+ *
307
+ * A block introduced by a `Usage:` line is the binary's own grammar reproduced
308
+ * from `--help`, so every line under it is skipped: `[--in <path>]` is optional
309
+ * -flag notation, and the grammar wraps across lines, which would otherwise be
310
+ * executed as a command missing half its flags. The block ends at the next
311
+ * fence or the next unindented line.
312
+ *
313
+ * A `text` fence separated from a declared-exit invocation by nothing but blank
314
+ * lines is that invocation's transcribed diagnostic, and it travels on the run
315
+ * as `expectStderr`. Only a declared-exit invocation collects one: a page that
316
+ * shows the output of a command that succeeded is showing stdout, and every
317
+ * page that documents a failure prints it on stderr. The fence has to have
318
+ * pushed exactly one such invocation, since both would carry its declaration.
319
+ */
320
+ function extractActions(file, source, spellings) {
321
+ const lines = source.split('\n');
322
+ const actions = [];
323
+ let inFence = false;
324
+ let inGrammar = false;
325
+ let expectExit = null;
326
+ let previous = '';
327
+ /** The declared-exit run a `text` fence opening here would describe. */
328
+ let described = null;
329
+ /** The declared-exit runs the open fence has pushed so far. */
330
+ let fenceRuns = [];
331
+ for (let index = 0; index < lines.length; index += 1) {
332
+ const raw = lines[index];
333
+ if (raw.trimStart().startsWith('```')) {
334
+ if (!inFence) {
335
+ if (described !== null && labelOf(raw) === 'text') {
336
+ const body = [];
337
+ index += 1;
338
+ while (index < lines.length &&
339
+ !lines[index].trimStart().startsWith('```')) {
340
+ body.push(lines[index]);
341
+ index += 1;
342
+ }
343
+ const transcript = dedent(trimBlankEdges(body));
344
+ // An empty block claims nothing, so it is left unattached
345
+ // and the run keeps the exit-code judgment alone.
346
+ if (transcript.length > 0)
347
+ described.expectStderr = transcript;
348
+ described = null;
349
+ previous = raw;
350
+ continue;
351
+ }
352
+ described = null;
353
+ fenceRuns = [];
354
+ const declared = previous.trim().match(EXPECT_EXIT_PATTERN);
355
+ expectExit = declared ? Number(declared[1]) : null;
356
+ }
357
+ else {
358
+ // Two commands in one fence share its declaration, so neither
359
+ // owns the block below: attaching to the last would quote one
360
+ // command's transcript against the other's run.
361
+ described = fenceRuns.length === 1 ? fenceRuns[0] : null;
362
+ expectExit = null;
363
+ }
364
+ inFence = !inFence;
365
+ inGrammar = false;
366
+ previous = raw;
367
+ continue;
368
+ }
369
+ if (inGrammar && raw.trim() !== '' && !/^\s/.test(raw))
370
+ inGrammar = false;
371
+ if (raw.trim() === 'Usage:') {
372
+ inGrammar = true;
373
+ previous = raw;
374
+ described = null;
375
+ continue;
376
+ }
377
+ if (inGrammar || !inFence) {
378
+ if (raw.trim() !== '') {
379
+ previous = raw;
380
+ described = null;
381
+ }
382
+ continue;
383
+ }
384
+ let text = raw.trim().replace(/^\$\s+/, '');
385
+ // `cat > path <<'EOF'` … `EOF`: a file the page tells the reader to write.
386
+ const heredoc = text.match(/^cat\s+>\s*(\S+)\s*<<-?\s*'?([A-Za-z_]\w*)'?$/);
387
+ if (heredoc) {
388
+ const [, target, delimiter] = heredoc;
389
+ const body = [];
390
+ index += 1;
391
+ while (index < lines.length && lines[index].trim() !== delimiter) {
392
+ body.push(lines[index]);
393
+ index += 1;
394
+ }
395
+ actions.push({
396
+ kind: 'write',
397
+ target,
398
+ contents: `${body.join('\n')}\n`,
399
+ });
400
+ described = null;
401
+ continue;
402
+ }
403
+ const echoed = text.match(/^echo\s+(.+?)\s*>\s*(\S+)$/);
404
+ if (echoed) {
405
+ actions.push({
406
+ kind: 'write',
407
+ target: echoed[2],
408
+ contents: `${echoed[1].replace(/^['"]|['"]$/g, '')}\n`,
409
+ });
410
+ described = null;
411
+ continue;
412
+ }
413
+ const made = text.match(/^mkdir\s+-p\s+(\S+)$/);
414
+ if (made) {
415
+ actions.push({ kind: 'mkdir', target: made[1] });
416
+ described = null;
417
+ continue;
418
+ }
419
+ const startLine = index + 1;
420
+ while (text.endsWith('\\') && index + 1 < lines.length) {
421
+ index += 1;
422
+ text = `${text.slice(0, -1).trim()} ${lines[index].trim()}`;
423
+ }
424
+ const match = spellings.map((pattern) => text.match(pattern)).find(Boolean);
425
+ if (!match)
426
+ continue;
427
+ const tail = (match[1] ?? '').trim();
428
+ if (tail === '')
429
+ continue;
430
+ const first = tokenize(tail)[0];
431
+ if (first !== undefined && isMetavariable(first))
432
+ continue;
433
+ const action = {
434
+ kind: 'run',
435
+ file,
436
+ line: startLine,
437
+ invocation: text,
438
+ tail,
439
+ expectExit,
440
+ expectStderr: null,
441
+ };
442
+ actions.push(action);
443
+ if (expectExit !== null)
444
+ fenceRuns.push(action);
445
+ }
446
+ return actions;
447
+ }
448
+ /**
449
+ * Runs every documented invocation under one configuration and reports what
450
+ * failed.
451
+ *
452
+ * `root` is the directory the configuration file sits in, and every path the
453
+ * section names resolves against it.
454
+ */
455
+ export function runDocInvocations(root, section) {
456
+ const entry = resolve(root, section.binary.entry);
457
+ // A build is a precondition rather than an excuse. Skipping here would let
458
+ // the gate exit 0 having executed nothing, which is the vacuous pass the
459
+ // whole check exists to prevent.
460
+ if (!existsSync(entry)) {
461
+ throw codedError(DOC_PATH_ERROR, `${entry} does not exist; the "doc-invocations" section names it under binary.entry, so build it before the gate runs`);
462
+ }
463
+ const sampleInput = resolve(root, section.sampleInput);
464
+ if (!existsSync(sampleInput)) {
465
+ throw codedError(DOC_PATH_ERROR, `${sampleInput} does not exist; the "doc-invocations" section names it under sampleInput, and it is what stands in for an input only a reader has`);
466
+ }
467
+ // A page root that encloses the configuration is the easiest thing to write
468
+ // and the worst thing to run: it reaches every page in the tree, planning
469
+ // material included, and every fenced command inside them.
470
+ //
471
+ // Canonical paths, because `resolve` follows no symlink: a link pointing at
472
+ // the repository would otherwise walk straight past this guard.
473
+ const canonical = (target) => {
474
+ try {
475
+ return realpathSync(target);
476
+ }
477
+ catch {
478
+ return target;
479
+ }
480
+ };
481
+ const configRoot = canonical(resolve(root));
482
+ for (const page of section.pages) {
483
+ const named = canonical(resolve(root, page));
484
+ const enclosing = named.endsWith(sep) ? named : `${named}${sep}`;
485
+ if (`${configRoot}${configRoot.endsWith(sep) ? '' : sep}`.startsWith(enclosing)) {
486
+ throw codedError(DOC_PATH_ERROR, `the "doc-invocations" section names "${page}" under pages, and that encloses the directory the configuration sits in; name a page or a directory inside it, since every fenced command under a whole repository is more than this gate should run`);
487
+ }
488
+ }
489
+ const spellings = section.binary.spellings.map(spellingPattern);
490
+ const context = {
491
+ repoRoot: resolve(root),
492
+ sampleInput,
493
+ installedPrefix: section.binary.installedPrefix,
494
+ };
495
+ const files = section.pages
496
+ .flatMap((page) => collectMarkdown(resolve(root, page)))
497
+ .sort();
498
+ // A root that reaches no page is a gate reporting a pass over nothing.
499
+ if (files.length === 0) {
500
+ throw codedError(DOC_PATH_ERROR, `no markdown under ${section.pages.join(', ')}; the "doc-invocations" section names those under pages and the gate would report a pass over nothing`);
501
+ }
502
+ const workDir = mkdtempSync(join(tmpdir(), 'doc-invocations-'));
503
+ const failures = [];
504
+ let scanned = 0;
505
+ let judged = 0;
506
+ let compared = 0;
507
+ try {
508
+ for (const [index, absolute] of files.entries()) {
509
+ const file = absolute.startsWith(context.repoRoot)
510
+ ? absolute.slice(context.repoRoot.length + 1)
511
+ : absolute;
512
+ const sandbox = createPageSandbox(workDir, index);
513
+ for (const action of extractActions(file, readFileSync(absolute, 'utf8'), spellings)) {
514
+ if (action.kind === 'mkdir') {
515
+ mkdirSync(sandbox.rebase(action.target), { recursive: true });
516
+ continue;
517
+ }
518
+ if (action.kind === 'write') {
519
+ writeInto(sandbox.rebase(action.target), action.contents);
520
+ continue;
521
+ }
522
+ scanned += 1;
523
+ const { tokens, faithful } = realizeArguments(action.tail, sandbox, context);
524
+ // The sandbox root is the working directory and stdin is closed: a
525
+ // relative write lands inside the sandbox, and a command that reads
526
+ // stdin sees an empty stream and returns at once.
527
+ const result = spawnSync(process.execPath, [entry, ...tokens], {
528
+ cwd: sandbox.root,
529
+ encoding: 'utf8',
530
+ input: '',
531
+ timeout: section.timeoutMs,
532
+ });
533
+ // A run that never started carries no streams, so the report reads
534
+ // them defensively rather than dying while writing a failure.
535
+ const record = (reason) => failures.push({
536
+ ...action,
537
+ stderr: (result.stderr ?? '').trim(),
538
+ status: result.status,
539
+ reason,
540
+ });
541
+ if (result.error) {
542
+ // A timeout arrives here too, and a documented command that never
543
+ // finishes is the page's problem rather than the configuration's.
544
+ record(`did not run to a verdict: ${result.error.message} (the section allows ${section.timeoutMs}ms)`);
545
+ continue;
546
+ }
547
+ // A killed process carries a null status, so every comparison below
548
+ // would miss and the report would read "exited null".
549
+ if (result.status === null) {
550
+ record(`was killed by ${result.signal ?? 'a signal'} and decided nothing`);
551
+ continue;
552
+ }
553
+ // A Node stack means the binary died before it could decide anything,
554
+ // and a check that only read the exit code would take that for a pass.
555
+ if (/\bnode:internal\b/.test(result.stderr)) {
556
+ record('the binary crashed');
557
+ continue;
558
+ }
559
+ if (result.status === section.usageExit) {
560
+ record('usage error: the documented command or flag does not exist');
561
+ continue;
562
+ }
563
+ if (!faithful) {
564
+ if (action.expectExit !== null) {
565
+ record(`the block declares expect-exit ${action.expectExit}, but this invocation names a file only a reader has, so its exit code is not the page's own`);
566
+ }
567
+ continue;
568
+ }
569
+ judged += 1;
570
+ const expected = action.expectExit ?? 0;
571
+ if (result.status !== expected) {
572
+ record(action.expectExit === null
573
+ ? `exited ${result.status} over inputs this repository really has; a documented example has to work, or declare its exit with an "<!-- expect-exit: N -->" comment before the block`
574
+ : `exited ${result.status}, and the block declares expect-exit ${expected}`);
575
+ continue;
576
+ }
577
+ // The exit code is shared, so it cannot tell one structural failure
578
+ // from another. The transcribed diagnostic is what names the code
579
+ // the page claims, and it is compared line for line.
580
+ if (action.expectStderr === null)
581
+ continue;
582
+ compared += 1;
583
+ const written = result.stderr.split('\n');
584
+ const documented = action.expectStderr;
585
+ const overElided = documented.findIndex((line) => line.split(ELISION).length - 1 > section.elisionLimit);
586
+ if (overElided !== -1) {
587
+ record(`line ${overElided + 1} of the block beside it elides ${section.elisionLimit} times over; transcribe the line or cut it`);
588
+ continue;
589
+ }
590
+ // A documented line past the end of stderr fails, `...` included: a
591
+ // page may transcribe less than the run wrote and never more.
592
+ const drift = documented.findIndex((line, position) => {
593
+ const wrote = written[position];
594
+ return (wrote === undefined ||
595
+ !describesLine(line.trimEnd(), wrote.trimEnd()));
596
+ });
597
+ if (drift !== -1) {
598
+ const wrote = written[drift] ?? '';
599
+ const line = documented[drift];
600
+ record(line.trim() === wrote.trim() && line !== wrote
601
+ ? `the block beside it is indented differently from the output at line ${drift + 1}`
602
+ : `the block beside it transcribes "${line.trim()}" as line ${drift + 1} of the output, and the run wrote something else`);
603
+ }
604
+ }
605
+ }
606
+ }
607
+ finally {
608
+ rmSync(workDir, { recursive: true, force: true });
609
+ }
610
+ // A run that extracted nothing is the shape a mistyped spelling takes: every
611
+ // page is read, every fence is skipped, and the gate reports a clean pass over
612
+ // no commands at all. The pages are there and the binary is there, so the one
613
+ // thing left to name is the spelling list.
614
+ if (scanned === 0) {
615
+ throw codedError(DOC_PATH_ERROR, `no fenced command in ${files.length} page(s) matched any spelling the "doc-invocations" section declares (${section.binary.spellings.join(', ')}); a gate that extracted nothing reports a pass over nothing`);
616
+ }
617
+ return { failures, scanned, judged, compared, pages: files.length };
618
+ }