@jterrazz/test 12.2.0 → 13.0.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/dist/checker.js CHANGED
@@ -1,7 +1,497 @@
1
1
  #!/usr/bin/env node
2
- import { a as LiterateSyntaxError, o as parseLiterateFile, r as TOKEN_KINDS } from "./match.js";
3
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { a as assertedStreams, d as isMap, f as isScalar, i as SpecSyntaxError, m as renderYamlSource, n as RUN_KEYS, o as parseSpecDocument, p as isSeq, r as SPEC_EXTENSION, s as readSpecFile, t as DOCUMENT_KEYS, u as Scalar } from "./spec-document.js";
3
+ import { o as resolveFixtureSource, r as TOKEN_KINDS } from "./match.js";
4
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
+ //#region src/lint/checker-spec.ts
7
+ function finding(rule, line, message, severity = "error") {
8
+ return {
9
+ line,
10
+ message,
11
+ rule,
12
+ severity
13
+ };
14
+ }
15
+ /** Directories the walks never enter — `fixtures/` holds INPUTS, never scenarios. */
16
+ const PRUNED$1 = /* @__PURE__ */ new Set([
17
+ ".git",
18
+ "dist",
19
+ "fixtures",
20
+ "node_modules"
21
+ ]);
22
+ function listSpecFiles(dir) {
23
+ const out = [];
24
+ const visit = (current) => {
25
+ let entries;
26
+ try {
27
+ entries = readdirSync(current, { withFileTypes: true });
28
+ } catch {
29
+ return;
30
+ }
31
+ for (const entry of entries) {
32
+ const path = join(current, entry.name);
33
+ if (entry.isDirectory()) {
34
+ if (!PRUNED$1.has(entry.name)) visit(path);
35
+ } else if (entry.name.endsWith(".spec.yaml")) out.push(path);
36
+ }
37
+ };
38
+ visit(dir);
39
+ return out;
40
+ }
41
+ function readText$1(path) {
42
+ try {
43
+ return readFileSync(path, "utf8");
44
+ } catch {
45
+ return "";
46
+ }
47
+ }
48
+ function documentMap(file) {
49
+ const root = file.source.document.contents;
50
+ return isMap(root) ? root : null;
51
+ }
52
+ function pairsOf(map) {
53
+ return map.items;
54
+ }
55
+ function keyName(pair) {
56
+ return isScalar(pair.key) && typeof pair.key.value === "string" ? pair.key.value : null;
57
+ }
58
+ function runMaps(file) {
59
+ const runs = documentMap(file)?.get("runs", true);
60
+ return isSeq(runs) ? runs.items.filter((item) => isMap(item)) : [];
61
+ }
62
+ /** The stream scalars of one run — the values the block-scalar rule judges. */
63
+ function streamScalars(run) {
64
+ const found = [];
65
+ for (const pair of pairsOf(run)) {
66
+ const key = keyName(pair);
67
+ if (key === null) continue;
68
+ if ([
69
+ "stderr",
70
+ "stdin",
71
+ "stdout"
72
+ ].includes(key) && isScalar(pair.value)) {
73
+ found.push({
74
+ key,
75
+ scalar: pair.value
76
+ });
77
+ continue;
78
+ }
79
+ if (key !== "files" || !isMap(pair.value)) continue;
80
+ for (const entry of pairsOf(pair.value)) {
81
+ if (!isMap(entry.value)) continue;
82
+ for (const assertion of pairsOf(entry.value)) if (keyName(assertion) === "equals" && isScalar(assertion.value)) found.push({
83
+ key: `files.${keyName(entry) ?? "?"}.equals`,
84
+ scalar: assertion.value
85
+ });
86
+ }
87
+ }
88
+ return found;
89
+ }
90
+ function isBlock(scalar) {
91
+ return String(scalar.type ?? "").startsWith("BLOCK");
92
+ }
93
+ /**
94
+ * The canonical order, top level and inside a run: the ground a session stands
95
+ * on before the session, what was given to a command before what came back.
96
+ * Two authors ordering differently make an unreadable diff out of an unchanged
97
+ * document; a `runs:` above `env:` makes a reader meet the session before its
98
+ * setup.
99
+ */
100
+ function checkKeyOrder(file, lineAt) {
101
+ const findings = [];
102
+ const inspect = (map, order, where) => {
103
+ let highest = -1;
104
+ let highestKey = "";
105
+ for (const pair of pairsOf(map)) {
106
+ const key = keyName(pair);
107
+ const rank = key === null ? -1 : order.indexOf(key);
108
+ if (rank === -1) continue;
109
+ if (rank < highest) {
110
+ findings.push(finding("d4b-spec-key-order", lineAt(pair), `"${key}:" comes after "${highestKey}:" in ${where} — the canonical order is ${order.join(", ")} (fixable: \`node dist/checker.js <root> --fix\`)`));
111
+ return;
112
+ }
113
+ highest = rank;
114
+ highestKey = key ?? "";
115
+ }
116
+ };
117
+ const map = documentMap(file);
118
+ if (map !== null) inspect(map, DOCUMENT_KEYS, "a spec document");
119
+ for (const run of runMaps(file)) inspect(run, RUN_KEYS, "a run");
120
+ return findings;
121
+ }
122
+ /**
123
+ * A stream is written as a block scalar (`|` or `|-`), never as a quoted string
124
+ * with `\n` escapes. A one-line golden is unreadable, and no diff can show it:
125
+ * the whole point of the format is that the expected output looks like output.
126
+ */
127
+ function checkBlockScalars(file, lineOf) {
128
+ const findings = [];
129
+ for (const run of runMaps(file)) for (const { key, scalar } of streamScalars(run)) {
130
+ if (isBlock(scalar) || scalar.value === "") continue;
131
+ findings.push(finding("d4b-spec-block-scalar", lineOf(scalar), `${key} is a ${scalar.type === Scalar.PLAIN ? "plain" : "quoted"} scalar — a stream is written as a block scalar (\`|\` keeps the final newline, \`|-\` drops it) (fixable: \`node dist/checker.js <root> --fix\`)`));
132
+ }
133
+ return findings;
134
+ }
135
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
136
+ /** Words the `.spec.yaml` suffix already says — a case name repeating one says nothing. */
137
+ const REDUNDANT_WORDS = /* @__PURE__ */ new Set([
138
+ "cli",
139
+ "spec",
140
+ "test"
141
+ ]);
142
+ /**
143
+ * The file is `<case>.spec.yaml`, and `<case>` NAMES the case: kebab-case, no
144
+ * word the suffix already carries, and never the bare name of its directory —
145
+ * a `rm/` folder holding `rm.spec.yaml` says nothing twice, where `removed`,
146
+ * `unattended` and `missing` each say one thing.
147
+ */
148
+ function checkFileName(rel) {
149
+ const name = basename(rel);
150
+ const caseName = name.slice(0, -SPEC_EXTENSION.length);
151
+ const findings = [];
152
+ if (!KEBAB.test(caseName)) {
153
+ findings.push(finding("c12-spec-file-name", 1, `"${name}" — <case> is kebab-case: lowercase words joined by single hyphens`));
154
+ return findings;
155
+ }
156
+ const redundant = caseName.split("-").filter((word) => REDUNDANT_WORDS.has(word));
157
+ if (redundant.length > 0) findings.push(finding("c12-spec-file-name", 1, `"${name}" repeats "${redundant[0]}" — the .spec.yaml suffix already says it; name the CASE instead`));
158
+ const folder = basename(dirname(rel));
159
+ if (caseName === folder) findings.push(finding("c12-spec-file-name", 1, `"${name}" repeats its directory "${folder}/" — name what this case proves, not where it sits`));
160
+ return findings;
161
+ }
162
+ const MAX_DESCRIPTION = 100;
163
+ /** A first word that is an identifier, not prose — exempt from the lowercase rule. */
164
+ const SYMBOL_WORD = /^[A-Z0-9_]+$/;
165
+ /**
166
+ * The description IS the vitest test title, and it is held to the title rules:
167
+ * one lowercase line of prose, no trailing period, short enough to read in a
168
+ * runner's output. A title that is a paragraph is a comment in the wrong place.
169
+ */
170
+ function checkDescription(document) {
171
+ const findings = [];
172
+ const text = document.description;
173
+ const line = document.descriptionLine;
174
+ const at = (message) => finding("j5-spec-description", line, message);
175
+ if (text.includes("\n")) {
176
+ findings.push(at("description: is one line — the vitest title, not a paragraph"));
177
+ return findings;
178
+ }
179
+ if (text.length > MAX_DESCRIPTION) findings.push(at(`description: is ${text.length} characters — keep it under ${MAX_DESCRIPTION}`));
180
+ if (text.endsWith(".")) findings.push(at("description: ends with a period — a title is a fragment, not a sentence"));
181
+ const first = text.split(/\s+/)[0] ?? "";
182
+ if (/^[A-Z]/.test(text) && !SYMBOL_WORD.test(first)) findings.push(at("description: starts with a capital — a title is lowercase prose"));
183
+ return findings;
184
+ }
185
+ /** The literals the update writer must have failed to tokenise, and their token. */
186
+ const VOLATILE = [
187
+ {
188
+ pattern: /\b(?:127\.0\.0\.1|localhost|0\.0\.0\.0):\d{2,5}\b/,
189
+ token: "{{url}} (or {{port}})"
190
+ },
191
+ {
192
+ pattern: /(?:^|[\s"'(=])(?:\/private)?\/tmp\//,
193
+ token: "{{workdir}}"
194
+ },
195
+ {
196
+ pattern: /(?:^|[\s"'(=])\/var\/folders\//,
197
+ token: "{{workdir}}"
198
+ },
199
+ {
200
+ pattern: /(?:^|[\s"'(=])\/(?:Users|home)\/[^\s"'/]+\//,
201
+ token: "{{workdir}} or {{path}}"
202
+ }
203
+ ];
204
+ /**
205
+ * A stream that pins a loopback port, a temp directory or somebody's home is a
206
+ * golden that passed once, on one machine: the port was free that second, the
207
+ * temp path belonged to that run. It is the exact shape of a value the update
208
+ * writer would have tokenised, which is why finding one means a token was
209
+ * overwritten by hand.
210
+ */
211
+ function checkVolatileLiterals(document) {
212
+ const findings = [];
213
+ for (const stream of assertedStreams(document)) for (const [index, text] of stream.text.split("\n").entries()) for (const { pattern, token } of VOLATILE) {
214
+ const found = pattern.exec(text);
215
+ if (found === null) continue;
216
+ findings.push(finding("d5-spec-volatile-literal", stream.line + index, `"${found[0].trim()}" is a value the next run will not reproduce — write ${token}`));
217
+ break;
218
+ }
219
+ return findings;
220
+ }
221
+ const ISO8601 = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/;
222
+ const UUID = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/;
223
+ /** Everything the document itself FIXED: what it feeds in, and what its fixtures hold. */
224
+ function fixedData(document, dir) {
225
+ const parts = document.runs.map((run) => run.stdin ?? "");
226
+ for (const fixture of document.fixtures) {
227
+ let source;
228
+ try {
229
+ source = resolveFixtureSource(fixture.path, dir);
230
+ } catch {
231
+ continue;
232
+ }
233
+ const visit = (path) => {
234
+ let stat;
235
+ try {
236
+ stat = statSync(path);
237
+ } catch {
238
+ return;
239
+ }
240
+ if (!stat.isDirectory()) {
241
+ parts.push(readText$1(path));
242
+ return;
243
+ }
244
+ for (const entry of readdirSync(path)) visit(join(path, entry));
245
+ };
246
+ visit(source);
247
+ }
248
+ return parts.join("\n");
249
+ }
250
+ /**
251
+ * A timestamp or a uuid spelled out in a golden is a value nothing fixed —
252
+ * unless the document DID fix it, by feeding it in through `stdin:` or shipping
253
+ * it in a `fixture:`. That carve-out is the whole rule: the defect is not the
254
+ * literal, it is the literal nobody pinned.
255
+ */
256
+ function checkPinnedValues(document, dir) {
257
+ const findings = [];
258
+ const streams = assertedStreams(document);
259
+ if (streams.length === 0) return findings;
260
+ let fixed = null;
261
+ for (const stream of streams) for (const [index, text] of stream.text.split("\n").entries()) for (const [pattern, token] of [[ISO8601, "{{iso8601}}"], [UUID, "{{uuid}}"]]) {
262
+ const found = pattern.exec(text);
263
+ if (found === null) continue;
264
+ fixed ??= fixedData(document, dir);
265
+ if (fixed.includes(found[0])) continue;
266
+ findings.push(finding("d5w-spec-pinned-value", stream.line + index, `"${found[0]}" is not fixed by any fixture: or stdin: of this document — write ${token}`, "warn"));
267
+ }
268
+ return findings;
269
+ }
270
+ /**
271
+ * A stream whose entire content is `{{any}}` states that the command printed
272
+ * something, or nothing, of any shape — which is what a stream does anyway. The
273
+ * run happens and proves nothing.
274
+ */
275
+ function checkEmptyAssertions(document) {
276
+ const findings = [];
277
+ for (const stream of assertedStreams(document)) if (stream.text.replaceAll("{{any}}", "").trim() === "" && stream.text.includes("{{any}}")) findings.push(finding("j3w-spec-empty-assertion", stream.line, "a stream that is only {{any}} asserts nothing — golden what the command actually printed", "warn"));
278
+ return findings;
279
+ }
280
+ /**
281
+ * A non-zero exit with neither stream is a CLI that failed without a word — or,
282
+ * far more often, a golden whose author forgot that the words went to stderr.
283
+ * Either way the document does not say WHY the command refused.
284
+ */
285
+ function checkSilentRefusals(document) {
286
+ return document.runs.filter((run) => run.exitCode !== 0 && run.stdout === null && run.stderr === null).map((run) => finding("d11w-spec-silent-refusal", run.commandLine, `exit: ${run.exitCode} with no stdout: and no stderr: — golden the words the refusal printed`, "warn"));
287
+ }
288
+ /** The top-level keys of an object literal, read by depth, not by parsing. */
289
+ function objectKeys(source, from) {
290
+ const keys = [];
291
+ let depth = 0;
292
+ let quote = "";
293
+ for (let index = from; index < source.length; index += 1) {
294
+ const char = source[index];
295
+ if (quote !== "") {
296
+ if (char === quote && source[index - 1] !== "\\") quote = "";
297
+ continue;
298
+ }
299
+ if (char === "'" || char === "\"" || char === "`") {
300
+ quote = char;
301
+ continue;
302
+ }
303
+ if (char === "{" || char === "[" || char === "(") {
304
+ depth += 1;
305
+ continue;
306
+ }
307
+ if (char === "}" || char === "]" || char === ")") {
308
+ depth -= 1;
309
+ if (depth === 0) return {
310
+ end: index,
311
+ keys
312
+ };
313
+ continue;
314
+ }
315
+ if (depth === 1 && /[A-Za-z_$'"]/.test(char)) {
316
+ const rest = /^(?<quote>['"]?)(?<name>[A-Za-z_$][\w$]*)\k<quote>\s*:/.exec(source.slice(index));
317
+ if (rest?.groups) {
318
+ keys.push(rest.groups.name);
319
+ index += rest[0].length - 1;
320
+ }
321
+ }
322
+ }
323
+ return {
324
+ end: source.length,
325
+ keys
326
+ };
327
+ }
328
+ /**
329
+ * The `env` and `serve` names registered by the `specification.cli(…)` calls of
330
+ * the nearest directory holding a `*.specification.ts`, read as literals. A
331
+ * directory may declare several runners, and nothing in a document says which
332
+ * one runs it, so their registries are read as a UNION: a name known to any
333
+ * neighbouring runner passes, and only a name known to none is a typo.
334
+ */
335
+ function registeredNames(dir) {
336
+ let current = dir;
337
+ for (;;) {
338
+ let entries;
339
+ try {
340
+ entries = readdirSync(current).filter((name) => name.endsWith(".specification.ts"));
341
+ } catch {
342
+ entries = [];
343
+ }
344
+ if (entries.length > 0) {
345
+ const env = /* @__PURE__ */ new Set();
346
+ const serve = /* @__PURE__ */ new Set();
347
+ for (const entry of entries) {
348
+ const text = readText$1(join(current, entry));
349
+ for (const call of text.matchAll(/specification\.cli\s*\(/g)) {
350
+ const open = text.indexOf("{", call.index + call[0].length);
351
+ if (open === -1) continue;
352
+ const options = objectKeys(text, open);
353
+ for (const [key, into] of [["env", env], ["serve", serve]]) {
354
+ const marker = new RegExp(String.raw`\b${key}\s*:\s*\{`).exec(text.slice(open, options.end));
355
+ if (marker === null) continue;
356
+ const at = open + marker.index + marker[0].length - 1;
357
+ for (const name of objectKeys(text, at).keys) into.add(name);
358
+ }
359
+ }
360
+ }
361
+ return {
362
+ env,
363
+ serve
364
+ };
365
+ }
366
+ const parent = dirname(current);
367
+ if (parent === current) return null;
368
+ current = parent;
369
+ }
370
+ }
371
+ /**
372
+ * Every bare word of `env:` and every `serve:` name must be a key of a runner
373
+ * registered above the document. A typo (`serve: dashbord`) is a refusal at
374
+ * run time today, one test file into a suite; here it is a line in a lint run.
375
+ */
376
+ function checkRegisteredNames(document, dir) {
377
+ if (document.env.length === 0 && document.serve.length === 0) return [];
378
+ const registered = registeredNames(dir);
379
+ if (registered === null) return [];
380
+ const findings = [];
381
+ for (const token of document.env) if (token.kind === "set" && !registered.env.has(token.name)) findings.push(finding("c8-spec-registered-name", token.line, `env: "${token.name}" is not a registered env set${registered.env.size === 0 ? "" : ` — registered: ${[...registered.env].sort().join(", ")}`}`));
382
+ for (const entry of document.serve) if (!registered.serve.has(entry.name)) findings.push(finding("c8-spec-registered-name", entry.line, `serve: "${entry.name}" is not a registered server${registered.serve.size === 0 ? "" : ` — registered: ${[...registered.serve].sort().join(", ")}`}`));
383
+ return findings;
384
+ }
385
+ /**
386
+ * Run every document-conventions pass over one `<case>.spec.yaml`. A document
387
+ * the grammar refuses yields NOTHING here: `d4b-spec-shape` already reported
388
+ * it, and a second cascade of findings about a file nobody can parse only
389
+ * buries the one line that matters.
390
+ */
391
+ function checkSpecConventions(text, rel, path) {
392
+ let file;
393
+ try {
394
+ file = readSpecFile(text, rel);
395
+ } catch {
396
+ return [];
397
+ }
398
+ const lineAt = (node) => {
399
+ const range = "range" in node && Array.isArray(node.range) ? node.range : void 0;
400
+ const key = node.key;
401
+ const target = range ?? (isScalar(key) && Array.isArray(key.range) ? key.range : void 0);
402
+ return target === void 0 ? 1 : file.source.lineAt(target[0]);
403
+ };
404
+ const dir = dirname(path);
405
+ return [
406
+ ...checkKeyOrder(file, (pair) => lineAt(pair.key)),
407
+ ...checkBlockScalars(file, (scalar) => lineAt(scalar)),
408
+ ...checkFileName(rel),
409
+ ...checkDescription(file.document),
410
+ ...checkVolatileLiterals(file.document),
411
+ ...checkPinnedValues(file.document, dir),
412
+ ...checkEmptyAssertions(file.document),
413
+ ...checkSilentRefusals(file.document),
414
+ ...checkRegisteredNames(file.document, dir)
415
+ ].map(({ line, message, rule, severity }) => ({
416
+ file: rel,
417
+ line,
418
+ message: `${rel}:${line}: ${message} (${rule} — see docs/10-linting.md)`,
419
+ severity
420
+ }));
421
+ }
422
+ /**
423
+ * J4-spec-description-unique — the description is the test title, and two
424
+ * documents in one directory sharing it make a failing run ambiguous: the
425
+ * reporter names the title, and the reader has two files to open.
426
+ */
427
+ function checkSpecDescriptionsUnique(rootDir) {
428
+ const byDirectory = /* @__PURE__ */ new Map();
429
+ const violations = [];
430
+ for (const path of listSpecFiles(rootDir)) {
431
+ let document;
432
+ try {
433
+ document = readSpecFile(readText$1(path), path).document;
434
+ } catch {
435
+ continue;
436
+ }
437
+ const dir = dirname(path);
438
+ const seen = byDirectory.get(dir) ?? /* @__PURE__ */ new Map();
439
+ byDirectory.set(dir, seen);
440
+ const first = seen.get(document.description);
441
+ const rel = relative(rootDir, path);
442
+ if (first === void 0) {
443
+ seen.set(document.description, rel);
444
+ continue;
445
+ }
446
+ violations.push({
447
+ file: rel,
448
+ line: document.descriptionLine,
449
+ message: `${rel}:${document.descriptionLine}: "${document.description}" is already the description of ${first} — two documents of one directory cannot share a title (j4-spec-description-unique — see docs/10-linting.md)`,
450
+ severity: "error"
451
+ });
452
+ }
453
+ return violations;
454
+ }
455
+ /**
456
+ * Apply the two fixable passes to one document: reorder its keys, and re-style
457
+ * every stream as a block scalar. Returns `null` when nothing moved — the
458
+ * caller writes only what changed, so a clean tree keeps its mtimes.
459
+ */
460
+ function fixSpecDocument(text, rel) {
461
+ let file;
462
+ try {
463
+ file = readSpecFile(text, rel);
464
+ } catch {
465
+ return null;
466
+ }
467
+ const reorder = (map, order) => {
468
+ const sorted = [...pairsOf(map).filter((pair) => order.includes(keyName(pair) ?? ""))].sort((a, b) => order.indexOf(keyName(a)) - order.indexOf(keyName(b)));
469
+ let cursor = 0;
470
+ map.items = pairsOf(map).map((pair) => order.includes(keyName(pair) ?? "") ? sorted[cursor++] : pair);
471
+ };
472
+ const map = documentMap(file);
473
+ if (map === null) return null;
474
+ reorder(map, DOCUMENT_KEYS);
475
+ for (const run of runMaps(file)) {
476
+ reorder(run, RUN_KEYS);
477
+ for (const { scalar } of streamScalars(run)) if (!isBlock(scalar) && scalar.value !== "") scalar.type = Scalar.BLOCK_LITERAL;
478
+ }
479
+ const next = renderYamlSource(file.source);
480
+ return next === text ? null : next;
481
+ }
482
+ /** Fix every `<case>.spec.yaml` under `rootDir`; returns the paths rewritten. */
483
+ function fixSpecFiles(rootDir) {
484
+ const written = [];
485
+ for (const path of listSpecFiles(rootDir)) {
486
+ const next = fixSpecDocument(readText$1(path), relative(rootDir, path));
487
+ if (next !== null) {
488
+ writeFileSync(path, next);
489
+ written.push(relative(rootDir, path));
490
+ }
491
+ }
492
+ return written;
493
+ }
494
+ //#endregion
5
495
  //#region src/lint/checker-crossfile.ts
6
496
  /**
7
497
  * The cross-file checker passes — the analyses oxlint cannot express because
@@ -17,9 +507,9 @@ import { basename, dirname, join, relative, resolve } from "node:path";
17
507
  *
18
508
  * - **C9 dead fixtures** — per feature dir, a fixture file no test literal
19
509
  * references is dead weight; a feature dir with conventional subdirs but no
20
- * `<feature>.test.ts` is an orphan. A literate `<case>.cli` counts as a test
21
- * AND as a referrer: the paths on its `fixture:` lines keep those trees alive
22
- * exactly as a `.fixture('…')` literal does.
510
+ * `<feature>.test.ts` is an orphan. A `<case>.spec.yaml` counts as a test
511
+ * AND as a referrer: the paths of its `fixture:` entries keep those trees
512
+ * alive exactly as a `.fixture('…')` literal does.
23
513
  * - **B5 await-using (inference)** — the docker-aware runners of a spec file
24
514
  * are derived from its `docker:` option, then every `<runner>….exec()` bound
25
515
  * without `await using` in an importing test is flagged. The primary B5
@@ -163,21 +653,21 @@ function readSource(path) {
163
653
  return blankComments(readText(path));
164
654
  }
165
655
  /**
166
- * The fixture paths a literate `<case>.cli` names — its referrer literals. The
656
+ * The fixture paths a `<case>.spec.yaml` names — its referrer literals. The
167
657
  * runner's own parser reads them, so the two channels cannot disagree about
168
- * what the file says; an unparseable file vouches for nothing (the D4b/B4 pass
169
- * is what reports it).
658
+ * what the document says; an unparseable file vouches for nothing (the
659
+ * `d4b-spec-shape` pass is what reports it).
170
660
  */
171
- function collectLiterateLiterals(path) {
661
+ function collectSpecLiterals(path) {
172
662
  try {
173
- return new Set(parseLiterateFile(readText(path), path).header.fixtures);
663
+ return new Set(parseSpecDocument(readText(path), path).fixtures.map((fixture) => fixture.path));
174
664
  } catch {
175
665
  return /* @__PURE__ */ new Set();
176
666
  }
177
667
  }
178
668
  /** Is this a file a domain's assets may be referenced from? */
179
669
  function isReferrer(name) {
180
- return name.endsWith(".test.ts") || name.endsWith(".cli");
670
+ return name.endsWith(".test.ts") || name.endsWith(".spec.yaml");
181
671
  }
182
672
  function isDir(path) {
183
673
  try {
@@ -503,7 +993,7 @@ function checkDeadFixtures(rootDir) {
503
993
  violations.push({
504
994
  file: rel,
505
995
  line: 1,
506
- message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts or *.cli (C9 — see docs/10-linting.md)`,
996
+ message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/10-linting.md)`,
507
997
  severity: "error"
508
998
  });
509
999
  continue;
@@ -511,8 +1001,8 @@ function checkDeadFixtures(rootDir) {
511
1001
  const literals = /* @__PURE__ */ new Set();
512
1002
  let downgrade = false;
513
1003
  for (const testFile of testFiles) {
514
- if (testFile.endsWith(".cli")) {
515
- for (const literal of collectLiterateLiterals(testFile)) literals.add(literal);
1004
+ if (testFile.endsWith(".spec.yaml")) {
1005
+ for (const literal of collectSpecLiterals(testFile)) literals.add(literal);
516
1006
  continue;
517
1007
  }
518
1008
  const text = readSource(testFile);
@@ -544,9 +1034,9 @@ function checkPoolFixtures(rootDir) {
544
1034
  for (const pool of findPools(rootDir)) {
545
1035
  const specsRoot = dirname(pool);
546
1036
  const literals = /* @__PURE__ */ new Set();
547
- for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".cli"))) {
1037
+ for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml"))) {
548
1038
  if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
549
- const found = file.endsWith(".cli") ? collectLiterateLiterals(file) : collectLiterals(readSource(file));
1039
+ const found = file.endsWith(".spec.yaml") ? collectSpecLiterals(file) : collectLiterals(readSource(file));
550
1040
  for (const literal of found) literals.add(literal);
551
1041
  }
552
1042
  for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
@@ -576,9 +1066,11 @@ function checkPoolFixtures(rootDir) {
576
1066
  * and `expected/*.http` (a status line) (D4b);
577
1067
  * - **tokens leaking into `requests/`** — requests are inputs, never matched, so
578
1068
  * a `{{token}}` there is almost always a mistake (D10, warning);
579
- * - the **literate `.cli` grammar** wherever a scenario file sits: its narrative
580
- * (B4) and its shape (D4b), read through the runner's own parser, plus the
581
- * token vocabulary of its blocks (D4).
1069
+ * - the **`<case>.spec.yaml` grammar** wherever a scenario document sits: its
1070
+ * shape (D4b), read through the runner's own parser, plus the token
1071
+ * vocabulary of its streams (D4);
1072
+ * - the **document conventions** — key order, naming, description, block
1073
+ * scalars, pinned values, registered names — bundled in `checker-spec.ts`.
582
1074
  *
583
1075
  * It shares TOKEN_KINDS with the runtime matcher so the channels cannot drift.
584
1076
  */
@@ -659,37 +1151,38 @@ function firstLine(text) {
659
1151
  const REQUEST_LINE = /^(?<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/\S*/;
660
1152
  const STATUS_LINE = /^HTTP\/\d(?:\.\d)? \d{3}\b/;
661
1153
  /**
662
- * Check one literate `<case>.cli` file — B4's narrative and D4b's shape
663
- * through the SAME parser the runner uses (one grammar, no drift), then D4's
664
- * token vocabulary over its BODY only: a `{{token}}` in the header is text,
665
- * never a placeholder.
1154
+ * Check one `<case>.spec.yaml` — D4b's shape through the SAME parser the runner
1155
+ * uses (one grammar, no drift), then D4's token vocabulary over the STREAMS
1156
+ * only: a `{{token}}` in a description or a command is text, never a
1157
+ * placeholder, and `stdin:` is an input the comparison never reads.
666
1158
  *
667
1159
  * Precision over recall, as everywhere in this channel: the parser refuses at
668
- * the first defect, so one file reports one grammar violation per run.
1160
+ * the first defect, so one document reports one grammar violation per run.
669
1161
  */
670
- function checkLiterateFile(text, rel) {
671
- const violations = [];
672
- let bodyStart = 0;
1162
+ function checkSpecFile(text, rel) {
1163
+ let document;
673
1164
  try {
674
- bodyStart = parseLiterateFile(text, rel).headerText.split("\n").length - 1;
1165
+ document = readSpecFile(text, rel).document;
675
1166
  } catch (error) {
676
- if (!(error instanceof LiterateSyntaxError)) throw error;
677
- const rule = error.defect === "narrative" ? "B4" : "D4b";
1167
+ if (!(error instanceof SpecSyntaxError)) throw error;
678
1168
  return [{
679
1169
  file: rel,
680
1170
  line: error.line,
681
- message: `${error.message} (${rule} — see docs/10-linting.md)`,
1171
+ message: `${error.message} (D4b — see docs/10-linting.md)`,
682
1172
  severity: "error"
683
1173
  }];
684
1174
  }
685
- const body = text.split("\n").slice(bodyStart).join("\n");
686
- for (const { line, token } of findUnknownTokens(body)) violations.push({
687
- file: rel,
688
- line: line + bodyStart,
689
- message: `${rel}:${line + bodyStart}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
690
- severity: "error",
691
- token
692
- });
1175
+ const violations = [];
1176
+ for (const stream of assertedStreams(document)) for (const { line, token } of findUnknownTokens(stream.text)) {
1177
+ const at = stream.line + line - 1;
1178
+ violations.push({
1179
+ file: rel,
1180
+ line: at,
1181
+ message: `${rel}:${at}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
1182
+ severity: "error",
1183
+ token
1184
+ });
1185
+ }
693
1186
  return violations;
694
1187
  }
695
1188
  /**
@@ -714,9 +1207,12 @@ function checkConventionFiles(rootDir) {
714
1207
  visit(path, next);
715
1208
  continue;
716
1209
  }
717
- if (entry.name.endsWith(".cli")) {
1210
+ if (entry.name.endsWith(".spec.yaml")) {
718
1211
  const text = decodeText(path);
719
- if (text !== null) violations.push(...checkLiterateFile(text, rel));
1212
+ if (text !== null) {
1213
+ violations.push(...checkSpecFile(text, rel));
1214
+ violations.push(...checkSpecConventions(text, rel, path));
1215
+ }
720
1216
  continue;
721
1217
  }
722
1218
  if (inside === null) continue;
@@ -768,6 +1264,7 @@ function checkConventionFiles(rootDir) {
768
1264
  function runAllChecks(rootDir) {
769
1265
  return [
770
1266
  ...checkConventionFiles(rootDir),
1267
+ ...checkSpecDescriptionsUnique(rootDir),
771
1268
  ...checkDeadFixtures(rootDir),
772
1269
  ...checkDockerRunnerAwaitUsing(rootDir),
773
1270
  ...checkDatabaseProperty(rootDir)
@@ -782,18 +1279,27 @@ function formatViolations(violations) {
782
1279
  /**
783
1280
  * CLI entry for the conventions checker (bundled as `dist/checker.js`).
784
1281
  *
785
- * node dist/checker.js [rootDir] # default: cwd
1282
+ * node dist/checker.js [rootDir] [--fix] # default root: cwd
786
1283
  *
787
- * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10) and the
788
- * cross-file passes (C9 dead fixtures, B5 await-using inference, A7 database
789
- * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded
790
- * C9 feature) are printed but do not fail the run.
1284
+ * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10), the
1285
+ * `<case>.spec.yaml` document conventions, and the cross-file passes (C9 dead
1286
+ * fixtures, B5 await-using inference, A7 database property). Exit 1 on any
1287
+ * ERROR-level violation; warnings (D10, a downgraded C9 feature) are printed
1288
+ * but do not fail the run.
1289
+ *
1290
+ * `--fix` first applies the two rewritable document passes (key order and block
1291
+ * scalars), then checks what is left — so a run that fixes everything exits 0.
791
1292
  */
792
- const root = resolve(process.argv[2] ?? ".");
1293
+ const fix = process.argv.includes("--fix");
1294
+ const root = resolve(process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ".");
793
1295
  if (!existsSync(root) || !statSync(root).isDirectory()) {
794
1296
  console.error(`conventions checker: no such directory: ${root}`);
795
1297
  process.exit(1);
796
1298
  }
1299
+ if (fix) {
1300
+ const written = fixSpecFiles(root);
1301
+ if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
1302
+ }
797
1303
  const violations = runAllChecks(root);
798
1304
  const errors = violations.filter((violation) => violation.severity === "error");
799
1305
  if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
@@ -801,6 +1307,6 @@ if (errors.length > 0) {
801
1307
  console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
802
1308
  process.exit(1);
803
1309
  }
804
- console.log(`conventions checker: all passes clean under ${root} (D4/D4b/D10 grammar, C9 dead fixtures, B5 await-using, A7 database)${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
1310
+ console.log(`conventions checker: all passes clean under ${root} (D4/D4b/D10 grammar, spec documents, C9 dead fixtures, B5 await-using, A7 database)${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
805
1311
  //#endregion
806
1312
  export {};