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