@jterrazz/test 12.1.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 { 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,7 +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.
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.
21
513
  * - **B5 await-using (inference)** — the docker-aware runners of a spec file
22
514
  * are derived from its `docker:` option, then every `<runner>….exec()` bound
23
515
  * without `await using` in an importing test is flagged. The primary B5
@@ -160,6 +652,23 @@ function blankComments(text) {
160
652
  function readSource(path) {
161
653
  return blankComments(readText(path));
162
654
  }
655
+ /**
656
+ * The fixture paths a `<case>.spec.yaml` names — its referrer literals. The
657
+ * runner's own parser reads them, so the two channels cannot disagree about
658
+ * what the document says; an unparseable file vouches for nothing (the
659
+ * `d4b-spec-shape` pass is what reports it).
660
+ */
661
+ function collectSpecLiterals(path) {
662
+ try {
663
+ return new Set(parseSpecDocument(readText(path), path).fixtures.map((fixture) => fixture.path));
664
+ } catch {
665
+ return /* @__PURE__ */ new Set();
666
+ }
667
+ }
668
+ /** Is this a file a domain's assets may be referenced from? */
669
+ function isReferrer(name) {
670
+ return name.endsWith(".test.ts") || name.endsWith(".spec.yaml");
671
+ }
163
672
  function isDir(path) {
164
673
  try {
165
674
  return statSync(path).isDirectory();
@@ -478,13 +987,13 @@ function checkDeadFixtures(rootDir) {
478
987
  const convSubdirs = entries.filter((entry) => entry.isDirectory() && CONVENTIONAL_SUBDIRS.has(entry.name) && isConventionalSubdir(join(dir, entry.name))).map((entry) => entry.name);
479
988
  if (convSubdirs.length === 0) continue;
480
989
  const feature = basename(dir);
481
- const testFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")).map((entry) => join(dir, entry.name));
990
+ const testFiles = entries.filter((entry) => entry.isFile() && isReferrer(entry.name)).map((entry) => join(dir, entry.name));
482
991
  const rel = relative(rootDir, dir);
483
992
  if (testFiles.length === 0) {
484
993
  violations.push({
485
994
  file: rel,
486
995
  line: 1,
487
- message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts (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)`,
488
997
  severity: "error"
489
998
  });
490
999
  continue;
@@ -492,6 +1001,10 @@ function checkDeadFixtures(rootDir) {
492
1001
  const literals = /* @__PURE__ */ new Set();
493
1002
  let downgrade = false;
494
1003
  for (const testFile of testFiles) {
1004
+ if (testFile.endsWith(".spec.yaml")) {
1005
+ for (const literal of collectSpecLiterals(testFile)) literals.add(literal);
1006
+ continue;
1007
+ }
495
1008
  const text = readSource(testFile);
496
1009
  for (const literal of collectLiterals(text)) literals.add(literal);
497
1010
  downgrade ||= hasNonLiteralFixtureArg(text);
@@ -521,9 +1034,10 @@ function checkPoolFixtures(rootDir) {
521
1034
  for (const pool of findPools(rootDir)) {
522
1035
  const specsRoot = dirname(pool);
523
1036
  const literals = /* @__PURE__ */ new Set();
524
- for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts"))) {
1037
+ for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml"))) {
525
1038
  if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
526
- for (const literal of collectLiterals(readSource(file))) literals.add(literal);
1039
+ const found = file.endsWith(".spec.yaml") ? collectSpecLiterals(file) : collectLiterals(readSource(file));
1040
+ for (const literal of found) literals.add(literal);
527
1041
  }
528
1042
  for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
529
1043
  const relEntry = relative(rootDir, join(pool, entry.name));
@@ -551,7 +1065,12 @@ function checkPoolFixtures(rootDir) {
551
1065
  * - the **HTTP first-line grammar** of depth-1 `requests/*.http` (a request line)
552
1066
  * and `expected/*.http` (a status line) (D4b);
553
1067
  * - **tokens leaking into `requests/`** — requests are inputs, never matched, so
554
- * a `{{token}}` there is almost always a mistake (D10, warning).
1068
+ * a `{{token}}` there is almost always a mistake (D10, warning);
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`.
555
1074
  *
556
1075
  * It shares TOKEN_KINDS with the runtime matcher so the channels cannot drift.
557
1076
  */
@@ -632,6 +1151,41 @@ function firstLine(text) {
632
1151
  const REQUEST_LINE = /^(?<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/\S*/;
633
1152
  const STATUS_LINE = /^HTTP\/\d(?:\.\d)? \d{3}\b/;
634
1153
  /**
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.
1158
+ *
1159
+ * Precision over recall, as everywhere in this channel: the parser refuses at
1160
+ * the first defect, so one document reports one grammar violation per run.
1161
+ */
1162
+ function checkSpecFile(text, rel) {
1163
+ let document;
1164
+ try {
1165
+ document = readSpecFile(text, rel).document;
1166
+ } catch (error) {
1167
+ if (!(error instanceof SpecSyntaxError)) throw error;
1168
+ return [{
1169
+ file: rel,
1170
+ line: error.line,
1171
+ message: `${error.message} (D4b — see docs/10-linting.md)`,
1172
+ severity: "error"
1173
+ }];
1174
+ }
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
+ }
1186
+ return violations;
1187
+ }
1188
+ /**
635
1189
  * Walk `rootDir` and check every fixture file. Paths in the result are relative
636
1190
  * to `rootDir`. Errors fail the checker; warnings are advisory.
637
1191
  */
@@ -653,6 +1207,14 @@ function checkConventionFiles(rootDir) {
653
1207
  visit(path, next);
654
1208
  continue;
655
1209
  }
1210
+ if (entry.name.endsWith(".spec.yaml")) {
1211
+ const text = decodeText(path);
1212
+ if (text !== null) {
1213
+ violations.push(...checkSpecFile(text, rel));
1214
+ violations.push(...checkSpecConventions(text, rel, path));
1215
+ }
1216
+ continue;
1217
+ }
656
1218
  if (inside === null) continue;
657
1219
  const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
658
1220
  if (inside === "requests") {
@@ -702,6 +1264,7 @@ function checkConventionFiles(rootDir) {
702
1264
  function runAllChecks(rootDir) {
703
1265
  return [
704
1266
  ...checkConventionFiles(rootDir),
1267
+ ...checkSpecDescriptionsUnique(rootDir),
705
1268
  ...checkDeadFixtures(rootDir),
706
1269
  ...checkDockerRunnerAwaitUsing(rootDir),
707
1270
  ...checkDatabaseProperty(rootDir)
@@ -716,18 +1279,27 @@ function formatViolations(violations) {
716
1279
  /**
717
1280
  * CLI entry for the conventions checker (bundled as `dist/checker.js`).
718
1281
  *
719
- * node dist/checker.js [rootDir] # default: cwd
1282
+ * node dist/checker.js [rootDir] [--fix] # default root: cwd
720
1283
  *
721
- * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10) and the
722
- * cross-file passes (C9 dead fixtures, B5 await-using inference, A7 database
723
- * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded
724
- * 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.
725
1292
  */
726
- 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("--")) ?? ".");
727
1295
  if (!existsSync(root) || !statSync(root).isDirectory()) {
728
1296
  console.error(`conventions checker: no such directory: ${root}`);
729
1297
  process.exit(1);
730
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
+ }
731
1303
  const violations = runAllChecks(root);
732
1304
  const errors = violations.filter((violation) => violation.severity === "error");
733
1305
  if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
@@ -735,6 +1307,6 @@ if (errors.length > 0) {
735
1307
  console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
736
1308
  process.exit(1);
737
1309
  }
738
- 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)` : ""}`);
739
1311
  //#endregion
740
1312
  export {};