@jterrazz/test 13.0.2 → 14.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,25 +1,64 @@
1
1
  #!/usr/bin/env node
2
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";
3
+ import { c as GROUND_EXPECTED, l as GROUND_FIXTURES, o as resolveFixtureSource, r as TOKEN_KINDS, s as GROUND_DIRS, u as GROUND_REQUESTS } from "./match.js";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
5
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. */
6
+ //#region src/lint/checker-crossfile.ts
7
+ /**
8
+ * The cross-file checker passes — the analyses oxlint cannot express because
9
+ * they need to read TWO files at once (a `*.specification.ts` record and the
10
+ * `specs/` test files importing it) or a whole feature tree at once.
11
+ *
12
+ * They live in the checker channel (bundled as `dist/checker.js`) rather than
13
+ * the oxlint plugin, which only ever sees one source file. Each pass follows
14
+ * the checker doctrine: **precision over recall** — a heuristic that cannot
15
+ * decide stays silent (under-reports) so the pass never blocks a legitimate
16
+ * shape. All three are string/scan based (no TS parser in the light lint
17
+ * bundle), matching the token checker's aesthetic.
18
+ *
19
+ * - **C9 dead fixtures** — per feature dir, a fixture file no test literal
20
+ * references is dead weight; a feature dir with ground subdirs but no
21
+ * `<feature>.test.ts` is an orphan. A `<case>.spec.yaml` counts as a test
22
+ * AND as a referrer: the paths of its `fixture:` entries keep those trees
23
+ * alive exactly as a `.fixture('…')` literal does.
24
+ * - **B5 await-using (inference)** — the docker-aware runners of a spec file
25
+ * are derived from its `docker:` option, then every `<runner>….exec()` bound
26
+ * without `await using` in an importing test is flagged. The primary B5
27
+ * channel now (the oxlint rule needs the runner names spelled out by hand).
28
+ * - **A7 database property** — a spec's `services:` record fixes how many SQL
29
+ * databases exist; importing tests must (≥2) or must not (==1) pass
30
+ * `{ database }` to every `.seed()` / `.table()`.
31
+ * - **C14 / C15 where a fixture lives** — a pool entry only ONE spec directory
32
+ * reaches for belongs beside that leaf (autofixed), and a leaf's own fixture
33
+ * path may not escape its own `_fixtures/`.
34
+ */
35
+ /** Directories the cross-file walks never descend into. */
16
36
  const PRUNED$1 = /* @__PURE__ */ new Set([
17
37
  ".git",
18
38
  "dist",
19
- "fixtures",
20
39
  "node_modules"
21
40
  ]);
22
- function listSpecFiles(dir) {
41
+ /** The ground directories of a leaf — the files a spec stands on. */
42
+ const GROUND_SUBDIRS = new Set(GROUND_DIRS);
43
+ /** Destructured names that are never a runner (so never the A7/B5 subject). */
44
+ const NON_RUNNER_BINDINGS = /* @__PURE__ */ new Set([
45
+ "cleanup",
46
+ "docker",
47
+ "orchestrator"
48
+ ]);
49
+ /** The specification constructors whose records/options the passes read. */
50
+ const CONSTRUCTORS = "api|cli|jobs";
51
+ /**
52
+ * A `$FIXTURES` pool (`.../specs/_fixtures`) is verbatim fixture material — the
53
+ * reusable apps AND the lint-violation trees that fail on purpose — never live
54
+ * specs. The cross-file walks prune it (like the token checker skips
55
+ * `_fixtures/`); {@link checkPoolFixtures} inspects its top level separately.
56
+ */
57
+ function isPool(dir) {
58
+ return basename(dir) === "_fixtures" && basename(dirname(dir)) === "specs";
59
+ }
60
+ /** Recursively list every file under `dir`, skipping pruned dirs and the pool. */
61
+ function listFiles(dir, predicate) {
23
62
  const out = [];
24
63
  const visit = (current) => {
25
64
  let entries;
@@ -31,13 +70,75 @@ function listSpecFiles(dir) {
31
70
  for (const entry of entries) {
32
71
  const path = join(current, entry.name);
33
72
  if (entry.isDirectory()) {
34
- if (!PRUNED$1.has(entry.name)) visit(path);
35
- } else if (entry.name.endsWith(".spec.yaml")) out.push(path);
73
+ if (!PRUNED$1.has(entry.name) && !isPool(path)) visit(path);
74
+ } else if (predicate(path)) out.push(path);
75
+ }
76
+ };
77
+ visit(dir);
78
+ return out;
79
+ }
80
+ /** Recursively list every directory under `dir` (inclusive), pool pruned. */
81
+ function listDirs(dir) {
82
+ const out = [];
83
+ const visit = (current) => {
84
+ out.push(current);
85
+ let entries;
86
+ try {
87
+ entries = readdirSync(current, { withFileTypes: true });
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const entry of entries) {
92
+ const path = join(current, entry.name);
93
+ if (entry.isDirectory() && !PRUNED$1.has(entry.name) && !isPool(path)) visit(path);
36
94
  }
37
95
  };
38
96
  visit(dir);
39
97
  return out;
40
98
  }
99
+ /**
100
+ * Locate every `specs/` root under `dir` — the anchor a fixture path is
101
+ * measured from. A nested one (a fixture project's own tree) sits inside a pool,
102
+ * which the walk does not enter; the pool's contents are inputs, not live specs.
103
+ */
104
+ function findSpecsRoots(dir) {
105
+ const roots = [];
106
+ const visit = (current) => {
107
+ if (basename(current) === "specs") {
108
+ roots.push(current);
109
+ return;
110
+ }
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(current, { withFileTypes: true });
114
+ } catch {
115
+ return;
116
+ }
117
+ for (const entry of entries) if (entry.isDirectory() && !PRUNED$1.has(entry.name)) visit(join(current, entry.name));
118
+ };
119
+ visit(dir);
120
+ return roots;
121
+ }
122
+ /** Locate `$FIXTURES` pools (unlike {@link listDirs}, does not prune them). */
123
+ function findPools(dir) {
124
+ const pools = [];
125
+ const visit = (current) => {
126
+ let entries;
127
+ try {
128
+ entries = readdirSync(current, { withFileTypes: true });
129
+ } catch {
130
+ return;
131
+ }
132
+ for (const entry of entries) {
133
+ if (!entry.isDirectory() || PRUNED$1.has(entry.name)) continue;
134
+ const path = join(current, entry.name);
135
+ if (isPool(path)) pools.push(path);
136
+ else visit(path);
137
+ }
138
+ };
139
+ visit(dir);
140
+ return pools;
141
+ }
41
142
  function readText$1(path) {
42
143
  try {
43
144
  return readFileSync(path, "utf8");
@@ -45,511 +146,617 @@ function readText$1(path) {
45
146
  return "";
46
147
  }
47
148
  }
48
- function documentMap(file) {
49
- const root = file.source.document.contents;
50
- return isMap(root) ? root : null;
149
+ /**
150
+ * Blank out `//` and block comments (replacing them with spaces, newlines
151
+ * preserved so offsets and line numbers stay intact) while leaving string
152
+ * literals untouched. The regex/scan passes read this so a commented-out
153
+ * `.exec()` or a comma inside a `services:` comment never confuses them.
154
+ */
155
+ function blankComments(text) {
156
+ const out = [...text];
157
+ let state = "";
158
+ for (let i = 0; i < text.length; i += 1) {
159
+ const char = text[i];
160
+ const next = text[i + 1];
161
+ if (state === "line") if (char === "\n") state = "";
162
+ else out[i] = " ";
163
+ else if (state === "block") {
164
+ if (char === "*" && next === "/") {
165
+ out[i] = " ";
166
+ out[i + 1] = " ";
167
+ i += 1;
168
+ state = "";
169
+ } else if (char !== "\n") out[i] = " ";
170
+ } else if (state === "\"" || state === "'" || state === "`") {
171
+ if (char === "\\") i += 1;
172
+ else if (char === state) state = "";
173
+ } else if (char === "/" && next === "/") {
174
+ out[i] = " ";
175
+ state = "line";
176
+ } else if (char === "/" && next === "*") {
177
+ out[i] = " ";
178
+ state = "block";
179
+ } else if (char === "\"" || char === "'" || char === "`") state = char;
180
+ }
181
+ return out.join("");
51
182
  }
52
- function pairsOf(map) {
53
- return map.items;
183
+ /** File contents with comments blanked — the parse view for the scan passes. */
184
+ function readSource(path) {
185
+ return blankComments(readText$1(path));
54
186
  }
55
- function keyName(pair) {
56
- return isScalar(pair.key) && typeof pair.key.value === "string" ? pair.key.value : null;
187
+ /**
188
+ * The fixture paths a `<case>.spec.yaml` names — its referrer literals. The
189
+ * runner's own parser reads them, so the two channels cannot disagree about
190
+ * what the document says; an unparseable file vouches for nothing (the
191
+ * `d4b-spec-shape` pass is what reports it).
192
+ */
193
+ function collectSpecLiterals(path) {
194
+ try {
195
+ return new Set(parseSpecDocument(readText$1(path), path).fixtures.map((fixture) => fixture.path));
196
+ } catch {
197
+ return /* @__PURE__ */ new Set();
198
+ }
57
199
  }
58
- function runMaps(file) {
59
- const runs = documentMap(file)?.get("runs", true);
60
- return isSeq(runs) ? runs.items.filter((item) => isMap(item)) : [];
200
+ /** Is this a file a leaf's ground may be referenced from? */
201
+ function isReferrer(name) {
202
+ return name.endsWith(".test.ts") || name.endsWith(".spec.yaml");
61
203
  }
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
- }
204
+ function isDir(path) {
205
+ try {
206
+ return statSync(path).isDirectory();
207
+ } catch {
208
+ return false;
87
209
  }
88
- return found;
89
210
  }
90
- function isBlock(scalar) {
91
- return String(scalar.type ?? "").startsWith("BLOCK");
211
+ /** The 1-based line number of a source offset. */
212
+ function lineAt(text, index) {
213
+ let line = 1;
214
+ for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === "\n") line += 1;
215
+ return line;
92
216
  }
93
217
  /**
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.
218
+ * Every quoted string and simple template literal in a source. Over-collection
219
+ * only ever *hides* a dead-fixture finding, so a greedy regex is safe here.
99
220
  */
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;
221
+ const STRING_LITERAL = /'(?<single>(?:[^'\\\n]|\\.)*)'|"(?<double>(?:[^"\\\n]|\\.)*)"|`(?<template>(?:[^`$\\]|\\.)*)`/g;
222
+ function collectLiterals(text) {
223
+ const found = /* @__PURE__ */ new Set();
224
+ for (const match of text.matchAll(STRING_LITERAL)) {
225
+ const value = match.groups?.single ?? match.groups?.double ?? match.groups?.template;
226
+ if (value !== void 0 && value.length > 0) found.add(value);
227
+ }
228
+ return found;
121
229
  }
122
230
  /**
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.
231
+ * Does the text pass a NON-literal argument to a fixture-ish verb
232
+ * (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
233
+ * variable makes the reference set incomplete, so the feature is reported at
234
+ * warn level instead of error (a false positive would be worse than a miss).
126
235
  */
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\`)`));
236
+ const FIXTURE_VERB_ARG = /(?:\.(?:seed|request|fixture)|\btoMatch)\s*\(\s*(?<arg>[^,)]*)/g;
237
+ function hasNonLiteralFixtureArg(text) {
238
+ for (const match of text.matchAll(FIXTURE_VERB_ARG)) {
239
+ const arg = (match.groups?.arg ?? "").trim();
240
+ if (arg.length === 0) continue;
241
+ if (!(/^'(?:[^'\\\n]|\\.)*'$/.test(arg) || /^"(?:[^"\\\n]|\\.)*"$/.test(arg) || /^`(?:[^`$\\]|\\.)*`$/.test(arg))) return true;
132
242
  }
133
- return findings;
243
+ return false;
134
244
  }
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
245
  /**
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.
246
+ * `// checker-disable-next-line <id>[,<id>] -- reason` suppresses the passes
247
+ * named by id (`a7`, `b5`, `c9`, or `*`) on the next non-blank line;
248
+ * `checker-disable-line` suppresses its own line. Mirrors the oxlint-disable
249
+ * idiom the repo already uses for negative specs.
147
250
  */
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;
251
+ function suppressedLines(text, id) {
252
+ const suppressed = /* @__PURE__ */ new Set();
253
+ const lines = text.split("\n");
254
+ const applies = (raw) => {
255
+ const ids = raw.replace(/--.*$/, "").trim().split(/[\s,]+/);
256
+ return ids.includes(id) || ids.includes("*");
257
+ };
258
+ for (const [index, line] of lines.entries()) {
259
+ let match = /checker-disable-next-line\s+(?<ids>.*)$/i.exec(line);
260
+ if (match !== null && applies(match.groups?.ids ?? "")) {
261
+ for (let next = index + 1; next < lines.length; next += 1) if (lines[next].trim().length > 0) {
262
+ suppressed.add(next + 1);
263
+ break;
264
+ }
265
+ }
266
+ match = /checker-disable-line\s+(?<ids>.*)$/i.exec(line);
267
+ if (match !== null && applies(match.groups?.ids ?? "")) suppressed.add(index + 1);
155
268
  }
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;
269
+ return suppressed;
161
270
  }
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;
271
+ /** From the `(` at `openIndex`, the substring inside the matching `)`. */
272
+ function balancedParens(text, openIndex) {
273
+ let depth = 0;
274
+ for (let i = openIndex; i < text.length; i += 1) {
275
+ const char = text[i];
276
+ if (char === "(") depth += 1;
277
+ else if (char === ")") {
278
+ depth -= 1;
279
+ if (depth === 0) return text.slice(openIndex + 1, i);
280
+ }
178
281
  }
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;
282
+ return text.slice(openIndex + 1);
184
283
  }
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;
284
+ /** From the `{` at `openIndex`, the substring inside the matching `}`. */
285
+ function balancedBraces(text, openIndex) {
286
+ let depth = 0;
287
+ for (let i = openIndex; i < text.length; i += 1) {
288
+ const char = text[i];
289
+ if (char === "{") depth += 1;
290
+ else if (char === "}") {
291
+ depth -= 1;
292
+ if (depth === 0) return text.slice(openIndex + 1, i);
293
+ }
218
294
  }
219
- return findings;
295
+ return null;
220
296
  }
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;
297
+ /** Split an object/argument body on its depth-0 commas. */
298
+ function splitTopLevel(body) {
299
+ const parts = [];
300
+ let depth = 0;
301
+ let start = 0;
302
+ for (let i = 0; i < body.length; i += 1) {
303
+ const char = body[i];
304
+ if (char === "{" || char === "(" || char === "[") depth += 1;
305
+ else if (char === "}" || char === ")" || char === "]") depth -= 1;
306
+ else if (char === "," && depth === 0) {
307
+ parts.push(body.slice(start, i));
308
+ start = i + 1;
232
309
  }
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
310
  }
248
- return parts.join("\n");
311
+ parts.push(body.slice(start));
312
+ return parts;
249
313
  }
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"));
314
+ /** The destructured export names of a `= await specification.<ctor>(` binding. */
315
+ function runnerExportsOf(text) {
316
+ const names = /* @__PURE__ */ new Set();
317
+ const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
318
+ for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
319
+ const key = raw.split(":")[0].trim();
320
+ if (key.length > 0 && !NON_RUNNER_BINDINGS.has(key)) names.add(key);
267
321
  }
268
- return findings;
322
+ return names;
269
323
  }
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;
324
+ /** The leading callee identifier of an expression (`postgres()` → `postgres`). */
325
+ function leadingCallee(value) {
326
+ const match = /^(?<callee>[A-Za-z_$][\w$]*)\s*\(/.exec(value.trim());
327
+ return match === null ? match : match.groups?.callee ?? null;
279
328
  }
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"));
329
+ /** SQL factory count of a `services:` record, or null if non-literal/absent. */
330
+ function sqlDatabaseCount(text) {
331
+ const keyword = /\bservices\s*:/.exec(text);
332
+ if (keyword === null) return null;
333
+ const braceIndex = text.indexOf("{", keyword.index + keyword[0].length);
334
+ if (braceIndex === -1 || text.slice(keyword.index + keyword[0].length, braceIndex).trim() !== "") return null;
335
+ const body = balancedBraces(text, braceIndex);
336
+ if (body === null) return null;
337
+ let count = 0;
338
+ for (const pair of splitTopLevel(body)) {
339
+ if (pair.trim().length === 0) continue;
340
+ const colon = pair.indexOf(":");
341
+ if (colon === -1) return null;
342
+ const callee = leadingCallee(pair.slice(colon + 1).trim());
343
+ if (callee === null) return null;
344
+ if (callee === "postgres" || callee === "sqlite" || callee === "mysql") count += 1;
345
+ }
346
+ return count;
287
347
  }
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
- }
348
+ function modelSpecFile(file) {
349
+ const text = readSource(file);
350
+ let dockerAware = false;
351
+ for (const match of text.matchAll(/specification\.cli\s*\(/g)) {
352
+ const argsOpen = text.indexOf("(", match.index);
353
+ if (argsOpen !== -1 && /\bdocker\s*:/.test(balancedParens(text, argsOpen))) dockerAware = true;
322
354
  }
323
355
  return {
324
- end: source.length,
325
- keys
356
+ dockerAware,
357
+ file,
358
+ runnerExports: runnerExportsOf(text),
359
+ sqlDatabaseCount: sqlDatabaseCount(text)
326
360
  };
327
361
  }
362
+ /** Resolve a relative import specifier to a `.ts` path (else undefined). */
363
+ function resolveRelativeImport(fromFile, specifier) {
364
+ if (!specifier.startsWith(".")) return;
365
+ return resolve(dirname(fromFile), specifier).replace(/\.js$/, ".ts");
366
+ }
367
+ /** Every `import { a, b as c } from '…'` binding in a file. */
368
+ function namedImports(text) {
369
+ const bindings = [];
370
+ for (const match of text.matchAll(/import\s+(?:type\s+)?\{(?<names>[^}]*)\}\s+from\s+['"](?<source>[^'"]+)['"]/g)) {
371
+ const source = match.groups?.source ?? "";
372
+ for (const raw of (match.groups?.names ?? "").split(",")) {
373
+ const part = raw.trim();
374
+ if (part.length === 0) continue;
375
+ const [name, local] = part.split(/\s+as\s+/).map((token) => token.trim());
376
+ bindings.push({
377
+ local: local ?? name,
378
+ name,
379
+ source
380
+ });
381
+ }
382
+ }
383
+ return bindings;
384
+ }
328
385
  /**
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.
386
+ * Docker-aware runner results must be bound with `await using`. The runner
387
+ * identifiers are inferred from the `docker:` option of imported spec files —
388
+ * no hand-maintained runner list, unlike the oxlint rule.
334
389
  */
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 = [];
390
+ function checkDockerRunnerAwaitUsing(rootDir) {
391
+ const violations = [];
392
+ const specFiles = listFiles(rootDir, (path) => path.endsWith(".specification.ts"));
393
+ const dockerSpecs = /* @__PURE__ */ new Map();
394
+ for (const file of specFiles) {
395
+ const model = modelSpecFile(file);
396
+ if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
397
+ }
398
+ if (dockerSpecs.size === 0) return violations;
399
+ for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
400
+ const text = readSource(testFile);
401
+ const runners = /* @__PURE__ */ new Set();
402
+ for (const binding of namedImports(text)) {
403
+ const target = resolveRelativeImport(testFile, binding.source);
404
+ if ((target === void 0 ? void 0 : dockerSpecs.get(target))?.runnerExports.has(binding.name) === true) runners.add(binding.local);
343
405
  }
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
- };
406
+ if (runners.size === 0) continue;
407
+ const suppressed = suppressedLines(readText$1(testFile), "b5");
408
+ for (const match of text.matchAll(/\b(?<kind>await\s+using|const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*await\s+(?<runner>[A-Za-z_$][\w$]*)\b[^;]*?\.exec\s*\(/g)) {
409
+ const kind = match.groups?.kind ?? "";
410
+ const runner = match.groups?.runner ?? "";
411
+ if (kind.startsWith("await using") || !runners.has(runner)) continue;
412
+ const line = lineAt(text, match.index);
413
+ if (suppressed.has(line)) continue;
414
+ const rel = relative(rootDir, testFile);
415
+ violations.push({
416
+ file: rel,
417
+ line,
418
+ message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/10-linting.md)`,
419
+ severity: "error"
420
+ });
365
421
  }
366
- const parent = dirname(current);
367
- if (parent === current) return null;
368
- current = parent;
369
422
  }
423
+ return violations;
370
424
  }
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;
425
+ /** Local inline runners a test declares itself (`specification.api(…)`). */
426
+ function localRunnerBindings(text) {
427
+ const locals = /* @__PURE__ */ new Set();
428
+ const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
429
+ for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
430
+ const part = raw.trim();
431
+ const local = (part.includes(":") ? part.split(":")[1] : part).trim();
432
+ if (local.length > 0 && !NON_RUNNER_BINDINGS.has(part.split(":")[0].trim())) locals.add(local);
433
+ }
434
+ return locals;
384
435
  }
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;
436
+ /** The identifier immediately before a `.seed(`/`.table(` member, if any. */
437
+ function memberObjectIdentifier(text, dotIndex) {
438
+ const before = text.slice(0, dotIndex);
439
+ const match = /(?<name>[A-Za-z_$][\w$]*)\s*$/.exec(before);
440
+ return match === null ? match : match.groups?.name ?? null;
441
+ }
442
+ function checkDatabaseProperty(rootDir) {
443
+ const violations = [];
444
+ const enforced = /* @__PURE__ */ new Map();
445
+ for (const file of listFiles(rootDir, (path) => path.endsWith(".specification.ts"))) {
446
+ const model = modelSpecFile(file);
447
+ if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
448
+ }
449
+ if (enforced.size === 0) return violations;
450
+ for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
451
+ const text = readSource(testFile);
452
+ const applicable = /* @__PURE__ */ new Set();
453
+ for (const binding of namedImports(text)) {
454
+ const target = resolveRelativeImport(testFile, binding.source);
455
+ const model = target === void 0 ? void 0 : enforced.get(target);
456
+ if (model?.runnerExports.has(binding.name) === true) applicable.add(model);
457
+ }
458
+ if (applicable.size !== 1) continue;
459
+ const count = [...applicable][0].sqlDatabaseCount ?? 0;
460
+ const locals = localRunnerBindings(text);
461
+ const suppressed = suppressedLines(readText$1(testFile), "a7");
462
+ const rel = relative(rootDir, testFile);
463
+ for (const match of text.matchAll(/\.(?<verb>seed|table)\s*\(/g)) {
464
+ const verb = match.groups?.verb ?? "";
465
+ const object = memberObjectIdentifier(text, match.index);
466
+ if (object !== null && locals.has(object)) continue;
467
+ const args = balancedParens(text, text.indexOf("(", match.index));
468
+ const hasDatabase = /\bdatabase\s*:/.test(args);
469
+ const line = lineAt(text, match.index);
470
+ if (suppressed.has(line)) continue;
471
+ if (count >= 2 && !hasDatabase) violations.push({
472
+ file: rel,
473
+ line,
474
+ message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/10-linting.md)`,
475
+ severity: "error"
476
+ });
477
+ else if (count === 1 && hasDatabase) violations.push({
478
+ file: rel,
479
+ line,
480
+ message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/10-linting.md)`,
481
+ severity: "error"
482
+ });
483
+ }
484
+ }
485
+ return violations;
486
+ }
487
+ /** Is a top-level ground-subdir entry referenced by any test literal? */
488
+ function entryReferenced(entry, entryIsDir, literals) {
489
+ for (const literal of literals) {
490
+ if (entryIsDir) {
491
+ if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
492
+ }
493
+ if (literal === entry || literal.includes(entry)) return true;
494
+ }
495
+ return false;
496
+ }
497
+ /** A child named like a ground subdir but actually a fixture container. */
498
+ function isConventionalSubdir(path) {
499
+ if (!isDir(path)) return false;
500
+ if (listFiles(path, (candidate) => candidate.endsWith(".test.ts")).length > 0) return false;
501
+ let children;
393
502
  try {
394
- file = readSpecFile(text, rel);
503
+ children = readdirSync(path, { withFileTypes: true });
395
504
  } catch {
396
- return [];
505
+ return false;
397
506
  }
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
- }));
507
+ return !children.some((child) => child.isDirectory() && GROUND_SUBDIRS.has(child.name));
421
508
  }
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();
509
+ function checkDeadFixtures(rootDir) {
429
510
  const violations = [];
430
- for (const path of listSpecFiles(rootDir)) {
431
- let document;
511
+ for (const dir of listDirs(rootDir)) {
512
+ if (basename(dir) === "specs") continue;
513
+ let entries;
432
514
  try {
433
- document = readSpecFile(readText$1(path), path).document;
515
+ entries = readdirSync(dir, { withFileTypes: true });
434
516
  } catch {
435
517
  continue;
436
518
  }
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
- });
519
+ const groundSubdirs = entries.filter((entry) => entry.isDirectory() && GROUND_SUBDIRS.has(entry.name) && isConventionalSubdir(join(dir, entry.name))).map((entry) => entry.name);
520
+ if (groundSubdirs.length === 0) continue;
521
+ const feature = basename(dir);
522
+ const testFiles = entries.filter((entry) => entry.isFile() && isReferrer(entry.name)).map((entry) => join(dir, entry.name));
523
+ const rel = relative(rootDir, dir);
524
+ if (testFiles.length === 0) {
525
+ violations.push({
526
+ file: rel,
527
+ line: 1,
528
+ message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/10-linting.md)`,
529
+ severity: "error"
530
+ });
531
+ continue;
532
+ }
533
+ const literals = /* @__PURE__ */ new Set();
534
+ let downgrade = false;
535
+ for (const testFile of testFiles) {
536
+ if (testFile.endsWith(".spec.yaml")) {
537
+ for (const literal of collectSpecLiterals(testFile)) literals.add(literal);
538
+ continue;
539
+ }
540
+ const text = readSource(testFile);
541
+ for (const literal of collectLiterals(text)) literals.add(literal);
542
+ downgrade ||= hasNonLiteralFixtureArg(text);
543
+ }
544
+ const severity = downgrade ? "warn" : "error";
545
+ for (const sub of groundSubdirs) {
546
+ const subPath = join(dir, sub);
547
+ for (const entry of readdirSync(subPath, { withFileTypes: true })) {
548
+ const entryIsDir = entry.isDirectory();
549
+ if (entryReferenced(entry.name, entryIsDir, literals)) continue;
550
+ const relEntry = relative(rootDir, join(subPath, entry.name));
551
+ violations.push({
552
+ file: relEntry,
553
+ line: 1,
554
+ message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/10-linting.md)`,
555
+ severity
556
+ });
557
+ }
558
+ }
559
+ }
560
+ violations.push(...checkPoolFixtures(rootDir));
561
+ return violations;
562
+ }
563
+ /** Unreferenced top-level entries of a `$FIXTURES` pool (`specs/_fixtures`). */
564
+ function checkPoolFixtures(rootDir) {
565
+ const violations = [];
566
+ for (const pool of findPools(rootDir)) {
567
+ const specsRoot = dirname(pool);
568
+ const literals = /* @__PURE__ */ new Set();
569
+ for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml"))) {
570
+ if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
571
+ const found = file.endsWith(".spec.yaml") ? collectSpecLiterals(file) : collectLiterals(readSource(file));
572
+ for (const literal of found) literals.add(literal);
573
+ }
574
+ for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
575
+ const relEntry = relative(rootDir, join(pool, entry.name));
576
+ violations.push({
577
+ file: relEntry,
578
+ line: 1,
579
+ message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/10-linting.md)`,
580
+ severity: "error"
581
+ });
582
+ }
452
583
  }
453
584
  return violations;
454
585
  }
586
+ /** A `.fixture('…')` call with a plain string argument. */
587
+ const FIXTURE_CALL = /\.fixture\(\s*(?<quote>['"`])(?<path>(?:[^'"`\\]|\\.)*)\k<quote>/g;
455
588
  /**
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.
589
+ * Every fixture path a spec file names, with the line it names it on. A
590
+ * `<case>.spec.yaml` is read through the runner's own parser; a `.ts` file
591
+ * through its literal `.fixture()` arguments — a non-literal argument is out of
592
+ * static reach and stays silent (the checker doctrine: precision over recall).
459
593
  */
460
- function fixSpecDocument(text, rel) {
461
- let file;
462
- try {
463
- file = readSpecFile(text, rel);
594
+ function collectFixtureReferences(file) {
595
+ if (file.endsWith(".spec.yaml")) try {
596
+ return parseSpecDocument(readText$1(file), file).fixtures.map((fixture) => ({
597
+ file,
598
+ line: fixture.line,
599
+ path: fixture.path
600
+ }));
464
601
  } catch {
465
- return null;
602
+ return [];
466
603
  }
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;
604
+ const text = readSource(file);
605
+ const references = [];
606
+ for (const match of text.matchAll(FIXTURE_CALL)) {
607
+ const path = match.groups?.path;
608
+ if (path !== void 0 && path.length > 0) references.push({
609
+ file,
610
+ line: lineAt(text, match.index),
611
+ path
612
+ });
478
613
  }
479
- const next = renderYamlSource(file.source);
480
- return next === text ? null : next;
614
+ return references;
615
+ }
616
+ /** Every spec file of a tree that may name a fixture, the pool excluded. */
617
+ function listReferrers(specsRoot, pool) {
618
+ return listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml")).filter((file) => !file.startsWith(`${pool}/`) && !file.startsWith(`${pool}\\`));
619
+ }
620
+ /** The pool entry a `$FIXTURES/…` path names — its FIRST segment. */
621
+ function poolEntryOf(path) {
622
+ if (!path.startsWith("$FIXTURES/")) return;
623
+ const entry = path.slice(10).split("/")[0];
624
+ return entry.length > 0 ? entry : void 0;
625
+ }
626
+ /** Pool entry → the spec directories that reference it, each with its referrers. */
627
+ function poolUsage(specsRoot, pool) {
628
+ const usage = /* @__PURE__ */ new Map();
629
+ for (const file of listReferrers(specsRoot, pool)) for (const { path } of collectFixtureReferences(file)) {
630
+ const entry = poolEntryOf(path);
631
+ if (entry === void 0) continue;
632
+ const leaves = usage.get(entry) ?? /* @__PURE__ */ new Map();
633
+ const referrers = leaves.get(dirname(file)) ?? [];
634
+ if (!referrers.includes(file)) referrers.push(file);
635
+ leaves.set(dirname(file), referrers);
636
+ usage.set(entry, leaves);
637
+ }
638
+ return usage;
481
639
  }
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));
640
+ /**
641
+ * Pool entries reached from exactly ONE spec directory. Two documents of the
642
+ * same leaf count as one — the unit is the folder, not the file. An entry no
643
+ * `$FIXTURES` reference names at all is C9's dead-fixture case, not this one.
644
+ */
645
+ function misplacedPoolFixtures(rootDir) {
646
+ const misplaced = [];
647
+ for (const pool of findPools(rootDir)) {
648
+ const usage = poolUsage(dirname(pool), pool);
649
+ for (const [entry, leaves] of usage) {
650
+ if (leaves.size !== 1 || !isDir(join(pool, entry))) continue;
651
+ const [leaf, referrers] = [...leaves][0];
652
+ misplaced.push({
653
+ entry,
654
+ leaf,
655
+ pool,
656
+ referrers
657
+ });
490
658
  }
491
659
  }
492
- return written;
660
+ return misplaced;
493
661
  }
494
- //#endregion
495
- //#region src/lint/checker-crossfile.ts
496
662
  /**
497
- * The cross-file checker passes — the analyses oxlint cannot express because
498
- * they need to read TWO files at once (a `*.specification.ts` record and the
499
- * `specs/` test files importing it) or a whole feature tree at once.
663
+ * CONVENTIONS C14 — a pool fixture is SHARED, or it is local. A directory of the
664
+ * specs-root `_fixtures/` pool that exactly one spec directory reaches for is
665
+ * misplaced: it belongs beside that leaf, as `<leaf>/_fixtures/<name>/`, named by
666
+ * the plain relative form.
500
667
  *
501
- * They live in the checker channel (bundled as `dist/checker.js`) rather than
502
- * the oxlint plugin, which only ever sees one source file. Each pass follows
503
- * the checker doctrine: **precision over recall** — a heuristic that cannot
504
- * decide stays silent (under-reports) so the pass never blocks a legitimate
505
- * shape. All three are string/scan based (no TS parser in the light lint
506
- * bundle), matching the token checker's aesthetic.
668
+ * The catch: a fixture built for one scenario and parked in the pool reads as
669
+ * shared ground. Every later author assumes some other spec depends on it, so
670
+ * nobody dares change it — and the one spec that actually owns it is a directory
671
+ * away, invisible. Fixed by `--fix`, which moves the tree and rewrites the
672
+ * referrers.
673
+ */
674
+ function checkPoolFixtureSharing(rootDir) {
675
+ return misplacedPoolFixtures(rootDir).map(({ entry, leaf, pool }) => {
676
+ const relEntry = relative(rootDir, join(pool, entry));
677
+ const relLeaf = relative(rootDir, leaf);
678
+ return {
679
+ file: relEntry,
680
+ line: 1,
681
+ message: `${relEntry}: only ${relLeaf} reaches for this pool fixture — the pool is what SEVERAL leaves share; move it to ${relLeaf}/${GROUND_FIXTURES}/${entry} and reference it as '${entry}' (C14 — see docs/10-linting.md, or run the checker with --fix)`,
682
+ severity: "error"
683
+ };
684
+ });
685
+ }
686
+ /**
687
+ * Apply C14: move each single-reader pool entry beside its leaf and rewrite the
688
+ * `$FIXTURES/<name>` literals that named it. The move is a plain rename — the
689
+ * checker never runs git, so the working tree sees it and the author stages it.
690
+ * Returns what moved, relative to `rootDir`.
691
+ */
692
+ function fixPoolFixtures(rootDir) {
693
+ const moved = [];
694
+ for (const { entry, leaf, pool, referrers } of misplacedPoolFixtures(rootDir)) {
695
+ const destination = join(leaf, GROUND_FIXTURES, entry);
696
+ if (existsSync(destination)) continue;
697
+ mkdirSync(dirname(destination), { recursive: true });
698
+ renameSync(join(pool, entry), destination);
699
+ for (const file of referrers) {
700
+ const text = readText$1(file);
701
+ const next = text.replaceAll(`$FIXTURES/${entry}`, entry);
702
+ if (next !== text) writeFileSync(file, next);
703
+ }
704
+ moved.push(`${relative(rootDir, join(pool, entry))} → ${relative(rootDir, destination)}`);
705
+ }
706
+ return moved;
707
+ }
708
+ /**
709
+ * CONVENTIONS C15 — a local fixture is reached only from its own folder. A
710
+ * marker-less `.fixture()` / `fixture:` path resolves under the referring spec's
711
+ * own `_fixtures/`; a `..` segment or an absolute path escapes it, and reaching
712
+ * into a sibling's ground is the pool by another door — with none of the pool's
713
+ * visibility.
507
714
  *
508
- * - **C9 dead fixtures** — per feature dir, a fixture file no test literal
509
- * references is dead weight; a feature dir with conventional subdirs but no
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.
513
- * - **B5 await-using (inference)** — the docker-aware runners of a spec file
514
- * are derived from its `docker:` option, then every `<runner>….exec()` bound
515
- * without `await using` in an importing test is flagged. The primary B5
516
- * channel now (the oxlint rule needs the runner names spelled out by hand).
517
- * - **A7 database property** — a spec's `services:` record fixes how many SQL
518
- * databases exist; importing tests must (≥2) or must not (==1) pass
519
- * `{ database }` to every `.seed()` / `.table()`.
715
+ * The catch: a leaf quietly depending on a neighbour's fixtures. The neighbour's
716
+ * author has no way to know, edits their own ground, and breaks a spec two
717
+ * folders away. Ground several leaves share belongs in the pool, where its
718
+ * sharing is declared.
520
719
  */
521
- /** Directories the cross-file walks never descend into. */
720
+ function checkLocalFixtureReach(rootDir) {
721
+ const violations = [];
722
+ for (const specsRoot of findSpecsRoots(rootDir)) {
723
+ const pool = join(specsRoot, GROUND_FIXTURES);
724
+ for (const file of listReferrers(specsRoot, pool)) {
725
+ const ground = join(dirname(file), GROUND_FIXTURES);
726
+ for (const { line, path } of collectFixtureReferences(file)) {
727
+ if (path.startsWith("$")) continue;
728
+ const target = resolve(ground, path);
729
+ if (target === ground || target.startsWith(`${ground}/`)) continue;
730
+ const rel = relative(rootDir, file);
731
+ violations.push({
732
+ file: rel,
733
+ line,
734
+ message: `${rel}:${line}: fixture "${path}" reaches outside ${relative(rootDir, ground)} — a leaf's ground sits beside it; ground several leaves share goes to the $FIXTURES pool at ${relative(rootDir, pool)} (C15 — see docs/10-linting.md)`,
735
+ severity: "error"
736
+ });
737
+ }
738
+ }
739
+ }
740
+ return violations;
741
+ }
742
+ //#endregion
743
+ //#region src/lint/checker-spec.ts
744
+ function finding(rule, line, message, severity = "error") {
745
+ return {
746
+ line,
747
+ message,
748
+ rule,
749
+ severity
750
+ };
751
+ }
752
+ /** Directories the walks never enter — `_fixtures/` holds INPUTS, never scenarios. */
522
753
  const PRUNED = /* @__PURE__ */ new Set([
523
754
  ".git",
524
755
  "dist",
756
+ GROUND_FIXTURES,
525
757
  "node_modules"
526
758
  ]);
527
- /** Conventional per-feature subdirectories whose files are assertion fixtures. */
528
- const CONVENTIONAL_SUBDIRS = /* @__PURE__ */ new Set([
529
- "expected",
530
- "fixtures",
531
- "requests",
532
- "seeds"
533
- ]);
534
- /** Destructured names that are never a runner (so never the A7/B5 subject). */
535
- const NON_RUNNER_BINDINGS = /* @__PURE__ */ new Set([
536
- "cleanup",
537
- "docker",
538
- "orchestrator"
539
- ]);
540
- /** The specification constructors whose records/options the passes read. */
541
- const CONSTRUCTORS = "api|cli|jobs";
542
- /**
543
- * A `$FIXTURES` pool (`.../specs/fixtures`) is verbatim fixture material — the
544
- * reusable apps AND the lint-violation trees that fail on purpose — never live
545
- * specs. The cross-file walks prune it (like the token checker skips
546
- * `fixtures/`); {@link checkPoolFixtures} inspects its top level separately.
547
- */
548
- function isPool(dir) {
549
- return basename(dir) === "fixtures" && basename(dirname(dir)) === "specs";
550
- }
551
- /** Recursively list every file under `dir`, skipping pruned dirs and the pool. */
552
- function listFiles(dir, predicate) {
759
+ function listSpecFiles(dir) {
553
760
  const out = [];
554
761
  const visit = (current) => {
555
762
  let entries;
@@ -561,52 +768,13 @@ function listFiles(dir, predicate) {
561
768
  for (const entry of entries) {
562
769
  const path = join(current, entry.name);
563
770
  if (entry.isDirectory()) {
564
- if (!PRUNED.has(entry.name) && !isPool(path)) visit(path);
565
- } else if (predicate(path)) out.push(path);
566
- }
567
- };
568
- visit(dir);
569
- return out;
570
- }
571
- /** Recursively list every directory under `dir` (inclusive), pool pruned. */
572
- function listDirs(dir) {
573
- const out = [];
574
- const visit = (current) => {
575
- out.push(current);
576
- let entries;
577
- try {
578
- entries = readdirSync(current, { withFileTypes: true });
579
- } catch {
580
- return;
581
- }
582
- for (const entry of entries) {
583
- const path = join(current, entry.name);
584
- if (entry.isDirectory() && !PRUNED.has(entry.name) && !isPool(path)) visit(path);
771
+ if (!PRUNED.has(entry.name)) visit(path);
772
+ } else if (entry.name.endsWith(".spec.yaml")) out.push(path);
585
773
  }
586
774
  };
587
775
  visit(dir);
588
776
  return out;
589
777
  }
590
- /** Locate `$FIXTURES` pools (unlike {@link listDirs}, does not prune them). */
591
- function findPools(dir) {
592
- const pools = [];
593
- const visit = (current) => {
594
- let entries;
595
- try {
596
- entries = readdirSync(current, { withFileTypes: true });
597
- } catch {
598
- return;
599
- }
600
- for (const entry of entries) {
601
- if (!entry.isDirectory() || PRUNED.has(entry.name)) continue;
602
- const path = join(current, entry.name);
603
- if (isPool(path)) pools.push(path);
604
- else visit(path);
605
- }
606
- };
607
- visit(dir);
608
- return pools;
609
- }
610
778
  function readText(path) {
611
779
  try {
612
780
  return readFileSync(path, "utf8");
@@ -614,442 +782,451 @@ function readText(path) {
614
782
  return "";
615
783
  }
616
784
  }
617
- /**
618
- * Blank out `//` and block comments (replacing them with spaces, newlines
619
- * preserved so offsets and line numbers stay intact) while leaving string
620
- * literals untouched. The regex/scan passes read this so a commented-out
621
- * `.exec()` or a comma inside a `services:` comment never confuses them.
622
- */
623
- function blankComments(text) {
624
- const out = [...text];
625
- let state = "";
626
- for (let i = 0; i < text.length; i += 1) {
627
- const char = text[i];
628
- const next = text[i + 1];
629
- if (state === "line") if (char === "\n") state = "";
630
- else out[i] = " ";
631
- else if (state === "block") {
632
- if (char === "*" && next === "/") {
633
- out[i] = " ";
634
- out[i + 1] = " ";
635
- i += 1;
636
- state = "";
637
- } else if (char !== "\n") out[i] = " ";
638
- } else if (state === "\"" || state === "'" || state === "`") {
639
- if (char === "\\") i += 1;
640
- else if (char === state) state = "";
641
- } else if (char === "/" && next === "/") {
642
- out[i] = " ";
643
- state = "line";
644
- } else if (char === "/" && next === "*") {
645
- out[i] = " ";
646
- state = "block";
647
- } else if (char === "\"" || char === "'" || char === "`") state = char;
648
- }
649
- return out.join("");
785
+ function documentMap(file) {
786
+ const root = file.source.document.contents;
787
+ return isMap(root) ? root : null;
650
788
  }
651
- /** File contents with comments blanked — the parse view for the scan passes. */
652
- function readSource(path) {
653
- return blankComments(readText(path));
789
+ function pairsOf(map) {
790
+ return map.items;
654
791
  }
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
- }
792
+ function keyName(pair) {
793
+ return isScalar(pair.key) && typeof pair.key.value === "string" ? pair.key.value : null;
667
794
  }
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");
795
+ function runMaps(file) {
796
+ const runs = documentMap(file)?.get("runs", true);
797
+ return isSeq(runs) ? runs.items.filter((item) => isMap(item)) : [];
671
798
  }
672
- function isDir(path) {
673
- try {
674
- return statSync(path).isDirectory();
675
- } catch {
676
- return false;
799
+ /** The stream scalars of one run — the values the block-scalar rule judges. */
800
+ function streamScalars(run) {
801
+ const found = [];
802
+ for (const pair of pairsOf(run)) {
803
+ const key = keyName(pair);
804
+ if (key === null) continue;
805
+ if ([
806
+ "stderr",
807
+ "stdin",
808
+ "stdout"
809
+ ].includes(key) && isScalar(pair.value)) {
810
+ found.push({
811
+ key,
812
+ scalar: pair.value
813
+ });
814
+ continue;
815
+ }
816
+ if (key !== "files" || !isMap(pair.value)) continue;
817
+ for (const entry of pairsOf(pair.value)) {
818
+ if (!isMap(entry.value)) continue;
819
+ for (const assertion of pairsOf(entry.value)) if (keyName(assertion) === "equals" && isScalar(assertion.value)) found.push({
820
+ key: `files.${keyName(entry) ?? "?"}.equals`,
821
+ scalar: assertion.value
822
+ });
823
+ }
677
824
  }
825
+ return found;
678
826
  }
679
- /** The 1-based line number of a source offset. */
680
- function lineAt(text, index) {
681
- let line = 1;
682
- for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === "\n") line += 1;
683
- return line;
827
+ function isBlock(scalar) {
828
+ return String(scalar.type ?? "").startsWith("BLOCK");
684
829
  }
685
830
  /**
686
- * Every quoted string and simple template literal in a source. Over-collection
687
- * only ever *hides* a dead-fixture finding, so a greedy regex is safe here.
831
+ * The canonical order, top level and inside a run: the ground a session stands
832
+ * on before the session, what was given to a command before what came back.
833
+ * Two authors ordering differently make an unreadable diff out of an unchanged
834
+ * document; a `runs:` above `env:` makes a reader meet the session before its
835
+ * setup.
688
836
  */
689
- const STRING_LITERAL = /'(?<single>(?:[^'\\\n]|\\.)*)'|"(?<double>(?:[^"\\\n]|\\.)*)"|`(?<template>(?:[^`$\\]|\\.)*)`/g;
690
- function collectLiterals(text) {
691
- const found = /* @__PURE__ */ new Set();
692
- for (const match of text.matchAll(STRING_LITERAL)) {
693
- const value = match.groups?.single ?? match.groups?.double ?? match.groups?.template;
694
- if (value !== void 0 && value.length > 0) found.add(value);
695
- }
696
- return found;
837
+ function checkKeyOrder(file, lineAt) {
838
+ const findings = [];
839
+ const inspect = (map, order, where) => {
840
+ let highest = -1;
841
+ let highestKey = "";
842
+ for (const pair of pairsOf(map)) {
843
+ const key = keyName(pair);
844
+ const rank = key === null ? -1 : order.indexOf(key);
845
+ if (rank === -1) continue;
846
+ if (rank < highest) {
847
+ 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\`)`));
848
+ return;
849
+ }
850
+ highest = rank;
851
+ highestKey = key ?? "";
852
+ }
853
+ };
854
+ const map = documentMap(file);
855
+ if (map !== null) inspect(map, DOCUMENT_KEYS, "a spec document");
856
+ for (const run of runMaps(file)) inspect(run, RUN_KEYS, "a run");
857
+ return findings;
697
858
  }
698
859
  /**
699
- * Does the text pass a NON-literal argument to a fixture-ish verb
700
- * (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
701
- * variable makes the reference set incomplete, so the feature is reported at
702
- * warn level instead of error (a false positive would be worse than a miss).
860
+ * A stream is written as a block scalar (`|` or `|-`), never as a quoted string
861
+ * with `\n` escapes. A one-line golden is unreadable, and no diff can show it:
862
+ * the whole point of the format is that the expected output looks like output.
703
863
  */
704
- const FIXTURE_VERB_ARG = /(?:\.(?:seed|request|fixture)|\btoMatch)\s*\(\s*(?<arg>[^,)]*)/g;
705
- function hasNonLiteralFixtureArg(text) {
706
- for (const match of text.matchAll(FIXTURE_VERB_ARG)) {
707
- const arg = (match.groups?.arg ?? "").trim();
708
- if (arg.length === 0) continue;
709
- if (!(/^'(?:[^'\\\n]|\\.)*'$/.test(arg) || /^"(?:[^"\\\n]|\\.)*"$/.test(arg) || /^`(?:[^`$\\]|\\.)*`$/.test(arg))) return true;
864
+ function checkBlockScalars(file, lineOf) {
865
+ const findings = [];
866
+ for (const run of runMaps(file)) for (const { key, scalar } of streamScalars(run)) {
867
+ if (isBlock(scalar) || scalar.value === "") continue;
868
+ 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\`)`));
710
869
  }
711
- return false;
870
+ return findings;
712
871
  }
872
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
873
+ /** Words the `.spec.yaml` suffix already says — a case name repeating one says nothing. */
874
+ const REDUNDANT_WORDS = /* @__PURE__ */ new Set([
875
+ "cli",
876
+ "spec",
877
+ "test"
878
+ ]);
713
879
  /**
714
- * `// checker-disable-next-line <id>[,<id>] -- reason` suppresses the passes
715
- * named by id (`a7`, `b5`, `c9`, or `*`) on the next non-blank line;
716
- * `checker-disable-line` suppresses its own line. Mirrors the oxlint-disable
717
- * idiom the repo already uses for negative specs.
880
+ * The file is `<case>.spec.yaml`, and `<case>` NAMES the case: kebab-case, no
881
+ * word the suffix already carries, and never the bare name of its directory —
882
+ * a `rm/` folder holding `rm.spec.yaml` says nothing twice, where `removed`,
883
+ * `unattended` and `missing` each say one thing.
718
884
  */
719
- function suppressedLines(text, id) {
720
- const suppressed = /* @__PURE__ */ new Set();
721
- const lines = text.split("\n");
722
- const applies = (raw) => {
723
- const ids = raw.replace(/--.*$/, "").trim().split(/[\s,]+/);
724
- return ids.includes(id) || ids.includes("*");
725
- };
726
- for (const [index, line] of lines.entries()) {
727
- let match = /checker-disable-next-line\s+(?<ids>.*)$/i.exec(line);
728
- if (match !== null && applies(match.groups?.ids ?? "")) {
729
- for (let next = index + 1; next < lines.length; next += 1) if (lines[next].trim().length > 0) {
730
- suppressed.add(next + 1);
731
- break;
732
- }
733
- }
734
- match = /checker-disable-line\s+(?<ids>.*)$/i.exec(line);
735
- if (match !== null && applies(match.groups?.ids ?? "")) suppressed.add(index + 1);
736
- }
737
- return suppressed;
738
- }
739
- /** From the `(` at `openIndex`, the substring inside the matching `)`. */
740
- function balancedParens(text, openIndex) {
741
- let depth = 0;
742
- for (let i = openIndex; i < text.length; i += 1) {
743
- const char = text[i];
744
- if (char === "(") depth += 1;
745
- else if (char === ")") {
746
- depth -= 1;
747
- if (depth === 0) return text.slice(openIndex + 1, i);
748
- }
749
- }
750
- return text.slice(openIndex + 1);
751
- }
752
- /** From the `{` at `openIndex`, the substring inside the matching `}`. */
753
- function balancedBraces(text, openIndex) {
754
- let depth = 0;
755
- for (let i = openIndex; i < text.length; i += 1) {
756
- const char = text[i];
757
- if (char === "{") depth += 1;
758
- else if (char === "}") {
759
- depth -= 1;
760
- if (depth === 0) return text.slice(openIndex + 1, i);
761
- }
762
- }
763
- return null;
764
- }
765
- /** Split an object/argument body on its depth-0 commas. */
766
- function splitTopLevel(body) {
767
- const parts = [];
768
- let depth = 0;
769
- let start = 0;
770
- for (let i = 0; i < body.length; i += 1) {
771
- const char = body[i];
772
- if (char === "{" || char === "(" || char === "[") depth += 1;
773
- else if (char === "}" || char === ")" || char === "]") depth -= 1;
774
- else if (char === "," && depth === 0) {
775
- parts.push(body.slice(start, i));
776
- start = i + 1;
777
- }
885
+ function checkFileName(rel) {
886
+ const name = basename(rel);
887
+ const caseName = name.slice(0, -SPEC_EXTENSION.length);
888
+ const findings = [];
889
+ if (!KEBAB.test(caseName)) {
890
+ findings.push(finding("c12-spec-file-name", 1, `"${name}" — <case> is kebab-case: lowercase words joined by single hyphens`));
891
+ return findings;
778
892
  }
779
- parts.push(body.slice(start));
780
- return parts;
893
+ const redundant = caseName.split("-").filter((word) => REDUNDANT_WORDS.has(word));
894
+ 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`));
895
+ const folder = basename(dirname(rel));
896
+ 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`));
897
+ return findings;
781
898
  }
782
- /** The destructured export names of a `= await specification.<ctor>(` binding. */
783
- function runnerExportsOf(text) {
784
- const names = /* @__PURE__ */ new Set();
785
- const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
786
- for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
787
- const key = raw.split(":")[0].trim();
788
- if (key.length > 0 && !NON_RUNNER_BINDINGS.has(key)) names.add(key);
899
+ const MAX_DESCRIPTION = 100;
900
+ /** A first word that is an identifier, not prose — exempt from the lowercase rule. */
901
+ const SYMBOL_WORD = /^[A-Z0-9_]+$/;
902
+ /**
903
+ * The description IS the vitest test title, and it is held to the title rules:
904
+ * one lowercase line of prose, no trailing period, short enough to read in a
905
+ * runner's output. A title that is a paragraph is a comment in the wrong place.
906
+ */
907
+ function checkDescription(document) {
908
+ const findings = [];
909
+ const text = document.description;
910
+ const line = document.descriptionLine;
911
+ const at = (message) => finding("j5-spec-description", line, message);
912
+ if (text.includes("\n")) {
913
+ findings.push(at("description: is one line — the vitest title, not a paragraph"));
914
+ return findings;
789
915
  }
790
- return names;
791
- }
792
- /** The leading callee identifier of an expression (`postgres()` → `postgres`). */
793
- function leadingCallee(value) {
794
- const match = /^(?<callee>[A-Za-z_$][\w$]*)\s*\(/.exec(value.trim());
795
- return match === null ? match : match.groups?.callee ?? null;
916
+ if (text.length > MAX_DESCRIPTION) findings.push(at(`description: is ${text.length} characters — keep it under ${MAX_DESCRIPTION}`));
917
+ if (text.endsWith(".")) findings.push(at("description: ends with a period — a title is a fragment, not a sentence"));
918
+ const first = text.split(/\s+/)[0] ?? "";
919
+ if (/^[A-Z]/.test(text) && !SYMBOL_WORD.test(first)) findings.push(at("description: starts with a capital — a title is lowercase prose"));
920
+ return findings;
796
921
  }
797
- /** SQL factory count of a `services:` record, or null if non-literal/absent. */
798
- function sqlDatabaseCount(text) {
799
- const keyword = /\bservices\s*:/.exec(text);
800
- if (keyword === null) return null;
801
- const braceIndex = text.indexOf("{", keyword.index + keyword[0].length);
802
- if (braceIndex === -1 || text.slice(keyword.index + keyword[0].length, braceIndex).trim() !== "") return null;
803
- const body = balancedBraces(text, braceIndex);
804
- if (body === null) return null;
805
- let count = 0;
806
- for (const pair of splitTopLevel(body)) {
807
- if (pair.trim().length === 0) continue;
808
- const colon = pair.indexOf(":");
809
- if (colon === -1) return null;
810
- const callee = leadingCallee(pair.slice(colon + 1).trim());
811
- if (callee === null) return null;
812
- if (callee === "postgres" || callee === "sqlite" || callee === "mysql") count += 1;
922
+ /** The literals the update writer must have failed to tokenise, and their token. */
923
+ const VOLATILE = [
924
+ {
925
+ pattern: /\b(?:127\.0\.0\.1|localhost|0\.0\.0\.0):\d{2,5}\b/,
926
+ token: "{{url}} (or {{port}})"
927
+ },
928
+ {
929
+ pattern: /(?:^|[\s"'(=])(?:\/private)?\/tmp\//,
930
+ token: "{{workdir}}"
931
+ },
932
+ {
933
+ pattern: /(?:^|[\s"'(=])\/var\/folders\//,
934
+ token: "{{workdir}}"
935
+ },
936
+ {
937
+ pattern: /(?:^|[\s"'(=])\/(?:Users|home)\/[^\s"'/]+\//,
938
+ token: "{{workdir}} or {{path}}"
813
939
  }
814
- return count;
815
- }
816
- function modelSpecFile(file) {
817
- const text = readSource(file);
818
- let dockerAware = false;
819
- for (const match of text.matchAll(/specification\.cli\s*\(/g)) {
820
- const argsOpen = text.indexOf("(", match.index);
821
- if (argsOpen !== -1 && /\bdocker\s*:/.test(balancedParens(text, argsOpen))) dockerAware = true;
940
+ ];
941
+ /**
942
+ * A stream that pins a loopback port, a temp directory or somebody's home is a
943
+ * golden that passed once, on one machine: the port was free that second, the
944
+ * temp path belonged to that run. It is the exact shape of a value the update
945
+ * writer would have tokenised, which is why finding one means a token was
946
+ * overwritten by hand.
947
+ */
948
+ function checkVolatileLiterals(document) {
949
+ const findings = [];
950
+ for (const stream of assertedStreams(document)) for (const [index, text] of stream.text.split("\n").entries()) for (const { pattern, token } of VOLATILE) {
951
+ const found = pattern.exec(text);
952
+ if (found === null) continue;
953
+ findings.push(finding("d5-spec-volatile-literal", stream.line + index, `"${found[0].trim()}" is a value the next run will not reproduce — write ${token}`));
954
+ break;
822
955
  }
823
- return {
824
- dockerAware,
825
- file,
826
- runnerExports: runnerExportsOf(text),
827
- sqlDatabaseCount: sqlDatabaseCount(text)
828
- };
829
- }
830
- /** Resolve a relative import specifier to a `.ts` path (else undefined). */
831
- function resolveRelativeImport(fromFile, specifier) {
832
- if (!specifier.startsWith(".")) return;
833
- return resolve(dirname(fromFile), specifier).replace(/\.js$/, ".ts");
956
+ return findings;
834
957
  }
835
- /** Every `import { a, b as c } from '…'` binding in a file. */
836
- function namedImports(text) {
837
- const bindings = [];
838
- for (const match of text.matchAll(/import\s+(?:type\s+)?\{(?<names>[^}]*)\}\s+from\s+['"](?<source>[^'"]+)['"]/g)) {
839
- const source = match.groups?.source ?? "";
840
- for (const raw of (match.groups?.names ?? "").split(",")) {
841
- const part = raw.trim();
842
- if (part.length === 0) continue;
843
- const [name, local] = part.split(/\s+as\s+/).map((token) => token.trim());
844
- bindings.push({
845
- local: local ?? name,
846
- name,
847
- source
848
- });
958
+ const ISO8601 = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/;
959
+ 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}/;
960
+ /** Everything the document itself FIXED: what it feeds in, and what its fixtures hold. */
961
+ function fixedData(document, dir) {
962
+ const parts = document.runs.map((run) => run.stdin ?? "");
963
+ for (const fixture of document.fixtures) {
964
+ let source;
965
+ try {
966
+ source = resolveFixtureSource(fixture.path, dir);
967
+ } catch {
968
+ continue;
849
969
  }
970
+ const visit = (path) => {
971
+ let stat;
972
+ try {
973
+ stat = statSync(path);
974
+ } catch {
975
+ return;
976
+ }
977
+ if (!stat.isDirectory()) {
978
+ parts.push(readText(path));
979
+ return;
980
+ }
981
+ for (const entry of readdirSync(path)) visit(join(path, entry));
982
+ };
983
+ visit(source);
850
984
  }
851
- return bindings;
985
+ return parts.join("\n");
852
986
  }
853
987
  /**
854
- * Docker-aware runner results must be bound with `await using`. The runner
855
- * identifiers are inferred from the `docker:` option of imported spec files —
856
- * no hand-maintained runner list, unlike the oxlint rule.
988
+ * A timestamp or a uuid spelled out in a golden is a value nothing fixed —
989
+ * unless the document DID fix it, by feeding it in through `stdin:` or shipping
990
+ * it in a `fixture:`. That carve-out is the whole rule: the defect is not the
991
+ * literal, it is the literal nobody pinned.
857
992
  */
858
- function checkDockerRunnerAwaitUsing(rootDir) {
859
- const violations = [];
860
- const specFiles = listFiles(rootDir, (path) => path.endsWith(".specification.ts"));
861
- const dockerSpecs = /* @__PURE__ */ new Map();
862
- for (const file of specFiles) {
863
- const model = modelSpecFile(file);
864
- if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
865
- }
866
- if (dockerSpecs.size === 0) return violations;
867
- for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
868
- const text = readSource(testFile);
869
- const runners = /* @__PURE__ */ new Set();
870
- for (const binding of namedImports(text)) {
871
- const target = resolveRelativeImport(testFile, binding.source);
872
- if ((target === void 0 ? void 0 : dockerSpecs.get(target))?.runnerExports.has(binding.name) === true) runners.add(binding.local);
873
- }
874
- if (runners.size === 0) continue;
875
- const suppressed = suppressedLines(readText(testFile), "b5");
876
- for (const match of text.matchAll(/\b(?<kind>await\s+using|const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*await\s+(?<runner>[A-Za-z_$][\w$]*)\b[^;]*?\.exec\s*\(/g)) {
877
- const kind = match.groups?.kind ?? "";
878
- const runner = match.groups?.runner ?? "";
879
- if (kind.startsWith("await using") || !runners.has(runner)) continue;
880
- const line = lineAt(text, match.index);
881
- if (suppressed.has(line)) continue;
882
- const rel = relative(rootDir, testFile);
883
- violations.push({
884
- file: rel,
885
- line,
886
- message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/10-linting.md)`,
887
- severity: "error"
888
- });
889
- }
993
+ function checkPinnedValues(document, dir) {
994
+ const findings = [];
995
+ const streams = assertedStreams(document);
996
+ if (streams.length === 0) return findings;
997
+ let fixed = null;
998
+ for (const stream of streams) for (const [index, text] of stream.text.split("\n").entries()) for (const [pattern, token] of [[ISO8601, "{{iso8601}}"], [UUID, "{{uuid}}"]]) {
999
+ const found = pattern.exec(text);
1000
+ if (found === null) continue;
1001
+ fixed ??= fixedData(document, dir);
1002
+ if (fixed.includes(found[0])) continue;
1003
+ 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"));
890
1004
  }
891
- return violations;
1005
+ return findings;
892
1006
  }
893
- /** Local inline runners a test declares itself (`specification.api(…)`). */
894
- function localRunnerBindings(text) {
895
- const locals = /* @__PURE__ */ new Set();
896
- const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
897
- for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
898
- const part = raw.trim();
899
- const local = (part.includes(":") ? part.split(":")[1] : part).trim();
900
- if (local.length > 0 && !NON_RUNNER_BINDINGS.has(part.split(":")[0].trim())) locals.add(local);
901
- }
902
- return locals;
1007
+ /**
1008
+ * A stream whose entire content is `{{any}}` states that the command printed
1009
+ * something, or nothing, of any shape — which is what a stream does anyway. The
1010
+ * run happens and proves nothing.
1011
+ */
1012
+ function checkEmptyAssertions(document) {
1013
+ const findings = [];
1014
+ 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"));
1015
+ return findings;
903
1016
  }
904
- /** The identifier immediately before a `.seed(`/`.table(` member, if any. */
905
- function memberObjectIdentifier(text, dotIndex) {
906
- const before = text.slice(0, dotIndex);
907
- const match = /(?<name>[A-Za-z_$][\w$]*)\s*$/.exec(before);
908
- return match === null ? match : match.groups?.name ?? null;
1017
+ /**
1018
+ * A non-zero exit with neither stream is a CLI that failed without a word — or,
1019
+ * far more often, a golden whose author forgot that the words went to stderr.
1020
+ * Either way the document does not say WHY the command refused.
1021
+ */
1022
+ function checkSilentRefusals(document) {
1023
+ 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"));
909
1024
  }
910
- function checkDatabaseProperty(rootDir) {
911
- const violations = [];
912
- const enforced = /* @__PURE__ */ new Map();
913
- for (const file of listFiles(rootDir, (path) => path.endsWith(".specification.ts"))) {
914
- const model = modelSpecFile(file);
915
- if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
916
- }
917
- if (enforced.size === 0) return violations;
918
- for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
919
- const text = readSource(testFile);
920
- const applicable = /* @__PURE__ */ new Set();
921
- for (const binding of namedImports(text)) {
922
- const target = resolveRelativeImport(testFile, binding.source);
923
- const model = target === void 0 ? void 0 : enforced.get(target);
924
- if (model?.runnerExports.has(binding.name) === true) applicable.add(model);
1025
+ /** The top-level keys of an object literal, read by depth, not by parsing. */
1026
+ function objectKeys(source, from) {
1027
+ const keys = [];
1028
+ let depth = 0;
1029
+ let quote = "";
1030
+ for (let index = from; index < source.length; index += 1) {
1031
+ const char = source[index];
1032
+ if (quote !== "") {
1033
+ if (char === quote && source[index - 1] !== "\\") quote = "";
1034
+ continue;
1035
+ }
1036
+ if (char === "'" || char === "\"" || char === "`") {
1037
+ quote = char;
1038
+ continue;
1039
+ }
1040
+ if (char === "{" || char === "[" || char === "(") {
1041
+ depth += 1;
1042
+ continue;
1043
+ }
1044
+ if (char === "}" || char === "]" || char === ")") {
1045
+ depth -= 1;
1046
+ if (depth === 0) return {
1047
+ end: index,
1048
+ keys
1049
+ };
1050
+ continue;
925
1051
  }
926
- if (applicable.size !== 1) continue;
927
- const count = [...applicable][0].sqlDatabaseCount ?? 0;
928
- const locals = localRunnerBindings(text);
929
- const suppressed = suppressedLines(readText(testFile), "a7");
930
- const rel = relative(rootDir, testFile);
931
- for (const match of text.matchAll(/\.(?<verb>seed|table)\s*\(/g)) {
932
- const verb = match.groups?.verb ?? "";
933
- const object = memberObjectIdentifier(text, match.index);
934
- if (object !== null && locals.has(object)) continue;
935
- const args = balancedParens(text, text.indexOf("(", match.index));
936
- const hasDatabase = /\bdatabase\s*:/.test(args);
937
- const line = lineAt(text, match.index);
938
- if (suppressed.has(line)) continue;
939
- if (count >= 2 && !hasDatabase) violations.push({
940
- file: rel,
941
- line,
942
- message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/10-linting.md)`,
943
- severity: "error"
944
- });
945
- else if (count === 1 && hasDatabase) violations.push({
946
- file: rel,
947
- line,
948
- message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/10-linting.md)`,
949
- severity: "error"
950
- });
1052
+ if (depth === 1 && /[A-Za-z_$'"]/.test(char)) {
1053
+ const rest = /^(?<quote>['"]?)(?<name>[A-Za-z_$][\w$]*)\k<quote>\s*:/.exec(source.slice(index));
1054
+ if (rest?.groups) {
1055
+ keys.push(rest.groups.name);
1056
+ index += rest[0].length - 1;
1057
+ }
951
1058
  }
952
1059
  }
953
- return violations;
1060
+ return {
1061
+ end: source.length,
1062
+ keys
1063
+ };
954
1064
  }
955
- /** Is a top-level conventional-subdir entry referenced by any test literal? */
956
- function entryReferenced(entry, entryIsDir, literals) {
957
- for (const literal of literals) {
958
- if (entryIsDir) {
959
- if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
1065
+ /**
1066
+ * The `env` and `serve` names registered by the `specification.cli(…)` calls of
1067
+ * the nearest directory holding a `*.specification.ts`, read as literals. A
1068
+ * directory may declare several runners, and nothing in a document says which
1069
+ * one runs it, so their registries are read as a UNION: a name known to any
1070
+ * neighbouring runner passes, and only a name known to none is a typo.
1071
+ */
1072
+ function registeredNames(dir) {
1073
+ let current = dir;
1074
+ for (;;) {
1075
+ let entries;
1076
+ try {
1077
+ entries = readdirSync(current).filter((name) => name.endsWith(".specification.ts"));
1078
+ } catch {
1079
+ entries = [];
960
1080
  }
961
- if (literal === entry || literal.includes(entry)) return true;
1081
+ if (entries.length > 0) {
1082
+ const env = /* @__PURE__ */ new Set();
1083
+ const serve = /* @__PURE__ */ new Set();
1084
+ for (const entry of entries) {
1085
+ const text = readText(join(current, entry));
1086
+ for (const call of text.matchAll(/specification\.cli\s*\(/g)) {
1087
+ const open = text.indexOf("{", call.index + call[0].length);
1088
+ if (open === -1) continue;
1089
+ const options = objectKeys(text, open);
1090
+ for (const [key, into] of [["env", env], ["serve", serve]]) {
1091
+ const marker = new RegExp(String.raw`\b${key}\s*:\s*\{`).exec(text.slice(open, options.end));
1092
+ if (marker === null) continue;
1093
+ const at = open + marker.index + marker[0].length - 1;
1094
+ for (const name of objectKeys(text, at).keys) into.add(name);
1095
+ }
1096
+ }
1097
+ }
1098
+ return {
1099
+ env,
1100
+ serve
1101
+ };
1102
+ }
1103
+ const parent = dirname(current);
1104
+ if (parent === current) return null;
1105
+ current = parent;
962
1106
  }
963
- return false;
964
1107
  }
965
- /** A child named like a conventional subdir but actually a fixture container. */
966
- function isConventionalSubdir(path) {
967
- if (!isDir(path)) return false;
968
- if (listFiles(path, (candidate) => candidate.endsWith(".test.ts")).length > 0) return false;
969
- let children;
1108
+ /**
1109
+ * Every bare word of `env:` and every `serve:` name must be a key of a runner
1110
+ * registered above the document. A typo (`serve: dashbord`) is a refusal at
1111
+ * run time today, one test file into a suite; here it is a line in a lint run.
1112
+ */
1113
+ function checkRegisteredNames(document, dir) {
1114
+ if (document.env.length === 0 && document.serve.length === 0) return [];
1115
+ const registered = registeredNames(dir);
1116
+ if (registered === null) return [];
1117
+ const findings = [];
1118
+ 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(", ")}`}`));
1119
+ 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(", ")}`}`));
1120
+ return findings;
1121
+ }
1122
+ /**
1123
+ * Run every document-conventions pass over one `<case>.spec.yaml`. A document
1124
+ * the grammar refuses yields NOTHING here: `d4b-spec-shape` already reported
1125
+ * it, and a second cascade of findings about a file nobody can parse only
1126
+ * buries the one line that matters.
1127
+ */
1128
+ function checkSpecConventions(text, rel, path) {
1129
+ let file;
970
1130
  try {
971
- children = readdirSync(path, { withFileTypes: true });
1131
+ file = readSpecFile(text, rel);
972
1132
  } catch {
973
- return false;
1133
+ return [];
974
1134
  }
975
- return !children.some((child) => child.isDirectory() && CONVENTIONAL_SUBDIRS.has(child.name));
1135
+ const lineAt = (node) => {
1136
+ const range = "range" in node && Array.isArray(node.range) ? node.range : void 0;
1137
+ const key = node.key;
1138
+ const target = range ?? (isScalar(key) && Array.isArray(key.range) ? key.range : void 0);
1139
+ return target === void 0 ? 1 : file.source.lineAt(target[0]);
1140
+ };
1141
+ const dir = dirname(path);
1142
+ return [
1143
+ ...checkKeyOrder(file, (pair) => lineAt(pair.key)),
1144
+ ...checkBlockScalars(file, (scalar) => lineAt(scalar)),
1145
+ ...checkFileName(rel),
1146
+ ...checkDescription(file.document),
1147
+ ...checkVolatileLiterals(file.document),
1148
+ ...checkPinnedValues(file.document, dir),
1149
+ ...checkEmptyAssertions(file.document),
1150
+ ...checkSilentRefusals(file.document),
1151
+ ...checkRegisteredNames(file.document, dir)
1152
+ ].map(({ line, message, rule, severity }) => ({
1153
+ file: rel,
1154
+ line,
1155
+ message: `${rel}:${line}: ${message} (${rule} — see docs/10-linting.md)`,
1156
+ severity
1157
+ }));
976
1158
  }
977
- function checkDeadFixtures(rootDir) {
1159
+ /**
1160
+ * J4-spec-description-unique — the description is the test title, and two
1161
+ * documents in one directory sharing it make a failing run ambiguous: the
1162
+ * reporter names the title, and the reader has two files to open.
1163
+ */
1164
+ function checkSpecDescriptionsUnique(rootDir) {
1165
+ const byDirectory = /* @__PURE__ */ new Map();
978
1166
  const violations = [];
979
- for (const dir of listDirs(rootDir)) {
980
- if (basename(dir) === "specs") continue;
981
- let entries;
1167
+ for (const path of listSpecFiles(rootDir)) {
1168
+ let document;
982
1169
  try {
983
- entries = readdirSync(dir, { withFileTypes: true });
1170
+ document = readSpecFile(readText(path), path).document;
984
1171
  } catch {
985
1172
  continue;
986
1173
  }
987
- const convSubdirs = entries.filter((entry) => entry.isDirectory() && CONVENTIONAL_SUBDIRS.has(entry.name) && isConventionalSubdir(join(dir, entry.name))).map((entry) => entry.name);
988
- if (convSubdirs.length === 0) continue;
989
- const feature = basename(dir);
990
- const testFiles = entries.filter((entry) => entry.isFile() && isReferrer(entry.name)).map((entry) => join(dir, entry.name));
991
- const rel = relative(rootDir, dir);
992
- if (testFiles.length === 0) {
993
- violations.push({
994
- file: rel,
995
- line: 1,
996
- message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/10-linting.md)`,
997
- severity: "error"
998
- });
1174
+ const dir = dirname(path);
1175
+ const seen = byDirectory.get(dir) ?? /* @__PURE__ */ new Map();
1176
+ byDirectory.set(dir, seen);
1177
+ const first = seen.get(document.description);
1178
+ const rel = relative(rootDir, path);
1179
+ if (first === void 0) {
1180
+ seen.set(document.description, rel);
999
1181
  continue;
1000
1182
  }
1001
- const literals = /* @__PURE__ */ new Set();
1002
- let downgrade = false;
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
- }
1008
- const text = readSource(testFile);
1009
- for (const literal of collectLiterals(text)) literals.add(literal);
1010
- downgrade ||= hasNonLiteralFixtureArg(text);
1011
- }
1012
- const severity = downgrade ? "warn" : "error";
1013
- for (const sub of convSubdirs) {
1014
- const subPath = join(dir, sub);
1015
- for (const entry of readdirSync(subPath, { withFileTypes: true })) {
1016
- const entryIsDir = entry.isDirectory();
1017
- if (entryReferenced(entry.name, entryIsDir, literals)) continue;
1018
- const relEntry = relative(rootDir, join(subPath, entry.name));
1019
- violations.push({
1020
- file: relEntry,
1021
- line: 1,
1022
- message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/10-linting.md)`,
1023
- severity
1024
- });
1025
- }
1026
- }
1183
+ violations.push({
1184
+ file: rel,
1185
+ line: document.descriptionLine,
1186
+ 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)`,
1187
+ severity: "error"
1188
+ });
1027
1189
  }
1028
- violations.push(...checkPoolFixtures(rootDir));
1029
1190
  return violations;
1030
1191
  }
1031
- /** Unreferenced top-level entries of a `$FIXTURES` pool (`specs/fixtures`). */
1032
- function checkPoolFixtures(rootDir) {
1033
- const violations = [];
1034
- for (const pool of findPools(rootDir)) {
1035
- const specsRoot = dirname(pool);
1036
- const literals = /* @__PURE__ */ new Set();
1037
- for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml"))) {
1038
- if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
1039
- const found = file.endsWith(".spec.yaml") ? collectSpecLiterals(file) : collectLiterals(readSource(file));
1040
- for (const literal of found) literals.add(literal);
1041
- }
1042
- for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
1043
- const relEntry = relative(rootDir, join(pool, entry.name));
1044
- violations.push({
1045
- file: relEntry,
1046
- line: 1,
1047
- message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/10-linting.md)`,
1048
- severity: "error"
1049
- });
1192
+ /**
1193
+ * Apply the two fixable passes to one document: reorder its keys, and re-style
1194
+ * every stream as a block scalar. Returns `null` when nothing moved — the
1195
+ * caller writes only what changed, so a clean tree keeps its mtimes.
1196
+ */
1197
+ function fixSpecDocument(text, rel) {
1198
+ let file;
1199
+ try {
1200
+ file = readSpecFile(text, rel);
1201
+ } catch {
1202
+ return null;
1203
+ }
1204
+ const reorder = (map, order) => {
1205
+ const sorted = [...pairsOf(map).filter((pair) => order.includes(keyName(pair) ?? ""))].sort((a, b) => order.indexOf(keyName(a)) - order.indexOf(keyName(b)));
1206
+ let cursor = 0;
1207
+ map.items = pairsOf(map).map((pair) => order.includes(keyName(pair) ?? "") ? sorted[cursor++] : pair);
1208
+ };
1209
+ const map = documentMap(file);
1210
+ if (map === null) return null;
1211
+ reorder(map, DOCUMENT_KEYS);
1212
+ for (const run of runMaps(file)) {
1213
+ reorder(run, RUN_KEYS);
1214
+ for (const { scalar } of streamScalars(run)) if (!isBlock(scalar) && scalar.value !== "") scalar.type = Scalar.BLOCK_LITERAL;
1215
+ }
1216
+ const next = renderYamlSource(file.source);
1217
+ return next === text ? null : next;
1218
+ }
1219
+ /** Fix every `<case>.spec.yaml` under `rootDir`; returns the paths rewritten. */
1220
+ function fixSpecFiles(rootDir) {
1221
+ const written = [];
1222
+ for (const path of listSpecFiles(rootDir)) {
1223
+ const next = fixSpecDocument(readText(path), relative(rootDir, path));
1224
+ if (next !== null) {
1225
+ writeFileSync(path, next);
1226
+ written.push(relative(rootDir, path));
1050
1227
  }
1051
1228
  }
1052
- return violations;
1229
+ return written;
1053
1230
  }
1054
1231
  //#endregion
1055
1232
  //#region src/lint/checker.ts
@@ -1057,14 +1234,14 @@ function checkPoolFixtures(rootDir) {
1057
1234
  * The conventions checker — the non-oxlint static channel.
1058
1235
  *
1059
1236
  * Oxlint only visits JS/TS sources; the D4 token grammar also constrains the
1060
- * DATA fixtures under `expected/**` and `requests/**`. This module walks a specs
1237
+ * DATA fixtures under `_expected/**` and `_requests/**`. This module walks a specs
1061
1238
  * tree and reports:
1062
1239
  *
1063
- * - **unknown / malformed tokens** in `expected/` fixtures — any text file, not
1240
+ * - **unknown / malformed tokens** in `_expected/` fixtures — any text file, not
1064
1241
  * just `.http`/`.json`/`.txt` (D4);
1065
- * - the **HTTP first-line grammar** of depth-1 `requests/*.http` (a request line)
1066
- * and `expected/*.http` (a status line) (D4b);
1067
- * - **tokens leaking into `requests/`** — requests are inputs, never matched, so
1242
+ * - the **HTTP first-line grammar** of depth-1 `_requests/*.http` (a request line)
1243
+ * and `_expected/*.http` (a status line) (D4b);
1244
+ * - **tokens leaking into `_requests/`** — requests are inputs, never matched, so
1068
1245
  * a `{{token}}` there is almost always a mistake (D10, warning);
1069
1246
  * - the **`<case>.spec.yaml` grammar** wherever a scenario document sits: its
1070
1247
  * shape (D4b), read through the runner's own parser, plus the token
@@ -1082,16 +1259,16 @@ const BRACE_BLOCK = /\{\{(?<inner>[^{}]*)\}\}/g;
1082
1259
  const LEADING_WORD = /^(?<kind>[A-Za-z][A-Za-z0-9]*)/;
1083
1260
  const KNOWN = new Set(TOKEN_KINDS);
1084
1261
  /** Directories whose files carry the token grammar (D4). */
1085
- const FIXTURE_DIRS = /* @__PURE__ */ new Set(["expected", "requests"]);
1262
+ const FIXTURE_DIRS = /* @__PURE__ */ new Set([GROUND_EXPECTED, GROUND_REQUESTS]);
1086
1263
  /**
1087
- * Directories the walk never enters. `fixtures/` trees (the shared pool and
1088
- * feature-local ones) are verbatim `.fixture()` cwd material — file STATE, not
1264
+ * Directories the walk never enters. `_fixtures/` trees (the shared pool and
1265
+ * the leaf-local ones) are verbatim `.fixture()` cwd material — file STATE, not
1089
1266
  * assertion fixtures — so the token grammar has no meaning inside them.
1090
1267
  */
1091
1268
  const SKIPPED_DIRS = /* @__PURE__ */ new Set([
1092
1269
  ".git",
1093
1270
  "dist",
1094
- "fixtures",
1271
+ GROUND_FIXTURES,
1095
1272
  "node_modules"
1096
1273
  ]);
1097
1274
  /**
@@ -1120,7 +1297,7 @@ function findUnknownTokens(text) {
1120
1297
  }
1121
1298
  return violations;
1122
1299
  }
1123
- /** Known tokens present in a text — for the `requests/` leak warning (D10). */
1300
+ /** Known tokens present in a text — for the `_requests/` leak warning (D10). */
1124
1301
  function findKnownTokens(text) {
1125
1302
  const found = [];
1126
1303
  const lines = text.split("\n");
@@ -1217,20 +1394,20 @@ function checkConventionFiles(rootDir) {
1217
1394
  }
1218
1395
  if (inside === null) continue;
1219
1396
  const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
1220
- if (inside === "requests") {
1397
+ if (inside === "_requests") {
1221
1398
  if (!entry.name.endsWith(".http")) continue;
1222
1399
  const text = decodeText(path);
1223
1400
  if (text === null) continue;
1224
1401
  if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
1225
1402
  file: rel,
1226
1403
  line: 1,
1227
- message: `${rel}:1: a requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/10-linting.md)`,
1404
+ message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/10-linting.md)`,
1228
1405
  severity: "error"
1229
1406
  });
1230
1407
  for (const { line, token } of findKnownTokens(text)) violations.push({
1231
1408
  file: rel,
1232
1409
  line,
1233
- message: `${rel}:${line}: token ${token} in a requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/10-linting.md)`,
1410
+ message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/10-linting.md)`,
1234
1411
  severity: "warn",
1235
1412
  token
1236
1413
  });
@@ -1241,7 +1418,7 @@ function checkConventionFiles(rootDir) {
1241
1418
  if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
1242
1419
  file: rel,
1243
1420
  line: 1,
1244
- message: `${rel}:1: an expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/10-linting.md)`,
1421
+ message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/10-linting.md)`,
1245
1422
  severity: "error"
1246
1423
  });
1247
1424
  for (const { line, token } of findUnknownTokens(text)) violations.push({
@@ -1258,14 +1435,17 @@ function checkConventionFiles(rootDir) {
1258
1435
  }
1259
1436
  /**
1260
1437
  * Run every checker pass over `rootDir`: the token/HTTP grammar passes (D4 /
1261
- * D4b / D10) plus the cross-file passes (C9 dead fixtures, B5 await-using
1262
- * inference, A7 database property). This is the entry the bundled bin drives.
1438
+ * D4b / D10) plus the cross-file passes (C9 dead fixtures, C14/C15 fixture
1439
+ * placement, B5 await-using inference, A7 database property). This is the entry
1440
+ * the bundled bin drives.
1263
1441
  */
1264
1442
  function runAllChecks(rootDir) {
1265
1443
  return [
1266
1444
  ...checkConventionFiles(rootDir),
1267
1445
  ...checkSpecDescriptionsUnique(rootDir),
1268
1446
  ...checkDeadFixtures(rootDir),
1447
+ ...checkPoolFixtureSharing(rootDir),
1448
+ ...checkLocalFixtureReach(rootDir),
1269
1449
  ...checkDockerRunnerAwaitUsing(rootDir),
1270
1450
  ...checkDatabaseProperty(rootDir)
1271
1451
  ];
@@ -1283,12 +1463,15 @@ function formatViolations(violations) {
1283
1463
  *
1284
1464
  * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10), the
1285
1465
  * `<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.
1466
+ * fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
1467
+ * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded C9
1468
+ * feature) are printed but do not fail the run.
1289
1469
  *
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.
1470
+ * `--fix` applies the rewritable passes — the two document ones (key order and
1471
+ * block scalars) and C14, which MOVES a single-reader pool fixture beside its
1472
+ * leaf and rewrites the literals that named it — then checks what is left, so a
1473
+ * run that fixes everything exits 0. The move is a plain rename: the checker
1474
+ * never runs git, and the author stages what the working tree now shows.
1292
1475
  */
1293
1476
  const fix = process.argv.includes("--fix");
1294
1477
  const root = resolve(process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ".");
@@ -1299,6 +1482,7 @@ if (!existsSync(root) || !statSync(root).isDirectory()) {
1299
1482
  if (fix) {
1300
1483
  const written = fixSpecFiles(root);
1301
1484
  if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
1485
+ for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
1302
1486
  }
1303
1487
  const violations = runAllChecks(root);
1304
1488
  const errors = violations.filter((violation) => violation.severity === "error");
@@ -1307,6 +1491,6 @@ if (errors.length > 0) {
1307
1491
  console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
1308
1492
  process.exit(1);
1309
1493
  }
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)` : ""}`);
1494
+ console.log(`conventions checker: all passes clean under ${root} (D4/D4b/D10 grammar, spec documents, C9 dead fixtures, C14/C15 fixture placement, B5 await-using, A7 database)${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
1311
1495
  //#endregion
1312
1496
  export {};