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.
- package/README.md +1 -1
- package/dist/application/index.d.ts +6 -0
- package/dist/application/index.js +8 -0
- package/dist/gates/audit-lockfile-age.mjs +105 -8
- package/dist/gates/check-dependency-direction.js +10 -10
- package/dist/gates/check-doc-claims.js +1026 -0
- package/dist/gates/check-doc-counts.js +408 -0
- package/dist/gates/check-doc-invocations.mjs +677 -0
- package/dist/gates/check-licenses.mjs +89 -16
- package/dist/gates/consumer-pattern.js +104 -0
- package/dist/gates/dependency-direction.js +130 -0
- package/dist/gates/gate-config.js +179 -12
- package/dist/gates/gates-cli.js +216 -19
- package/dist/gates/lineage-ownership.js +20 -23
- package/dist/gates/module-value.js +187 -0
- package/dist/gates/package-boundary.js +33 -144
- package/dist/gates/scanned-paths.js +110 -0
- package/dist/gates/typescript-scanner.js +181 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +1 -1
- package/dist/ports/environment-probe-port.d.ts +12 -12
- package/package.json +8 -5
|
@@ -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
|
+
}
|