@jterrazz/test 8.0.0 → 9.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.
Files changed (48) hide show
  1. package/README.md +209 -230
  2. package/dist/checker.d.ts +1 -0
  3. package/dist/checker.js +741 -0
  4. package/dist/index.d.ts +1249 -728
  5. package/dist/index.js +3133 -1538
  6. package/dist/intercept.js +129 -299
  7. package/dist/match.js +153 -0
  8. package/dist/oxlint.cjs +2931 -0
  9. package/dist/oxlint.d.cts +150 -0
  10. package/dist/oxlint.d.ts +150 -0
  11. package/dist/oxlint.js +2925 -0
  12. package/package.json +47 -41
  13. package/dist/chunk.cjs +0 -28
  14. package/dist/index.cjs +0 -2264
  15. package/dist/index.cjs.map +0 -1
  16. package/dist/index.d.cts +0 -980
  17. package/dist/index.js.map +0 -1
  18. package/dist/intercept.cjs +0 -323
  19. package/dist/intercept.cjs.map +0 -1
  20. package/dist/intercept.d.cts +0 -115
  21. package/dist/intercept.d.ts +0 -115
  22. package/dist/intercept.js.map +0 -1
  23. package/dist/intercept2.cjs +0 -81
  24. package/dist/intercept2.cjs.map +0 -1
  25. package/dist/intercept2.js +0 -81
  26. package/dist/intercept2.js.map +0 -1
  27. package/dist/mock-of.cjs +0 -32
  28. package/dist/mock-of.cjs.map +0 -1
  29. package/dist/mock-of.d.cts +0 -27
  30. package/dist/mock-of.d.ts +0 -27
  31. package/dist/mock-of.js +0 -19
  32. package/dist/mock-of.js.map +0 -1
  33. package/dist/mock.cjs +0 -4
  34. package/dist/mock.d.cts +0 -2
  35. package/dist/mock.d.ts +0 -2
  36. package/dist/mock.js +0 -2
  37. package/dist/services.cjs +0 -5
  38. package/dist/services.d.cts +0 -2
  39. package/dist/services.d.ts +0 -2
  40. package/dist/services.js +0 -2
  41. package/dist/sqlite.cjs +0 -350
  42. package/dist/sqlite.cjs.map +0 -1
  43. package/dist/sqlite.d.cts +0 -209
  44. package/dist/sqlite.d.ts +0 -209
  45. package/dist/sqlite.js +0 -331
  46. package/dist/sqlite.js.map +0 -1
  47. package/dist/types.d.cts +0 -42
  48. package/dist/types.d.ts +0 -42
@@ -0,0 +1,741 @@
1
+ #!/usr/bin/env node
2
+ import { r as TOKEN_KINDS } from "./match.js";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
+ //#region src/lint/checker-crossfile.ts
6
+ /**
7
+ * The cross-file checker passes — the analyses oxlint cannot express because
8
+ * they need to read TWO files at once (a `*.specification.ts` record and the
9
+ * `specs/` test files importing it) or a whole feature tree at once.
10
+ *
11
+ * They live in the checker channel (bundled as `dist/checker.js`) rather than
12
+ * the oxlint plugin, which only ever sees one source file. Each pass follows
13
+ * the checker doctrine: **precision over recall** — a heuristic that cannot
14
+ * decide stays silent (under-reports) so the pass never blocks a legitimate
15
+ * shape. All three are string/scan based (no TS parser in the light lint
16
+ * bundle), matching the token checker's aesthetic.
17
+ *
18
+ * - **C9 dead fixtures** — per feature dir, a fixture file no test literal
19
+ * references is dead weight; a feature dir with conventional subdirs but no
20
+ * `<feature>.test.ts` is an orphan.
21
+ * - **B5 await-using (inference)** — the docker-aware runners of a spec file
22
+ * are derived from its `docker:` option, then every `<runner>….exec()` bound
23
+ * without `await using` in an importing test is flagged. The primary B5
24
+ * channel now (the oxlint rule needs the runner names spelled out by hand).
25
+ * - **A7 database property** — a spec's `services:` record fixes how many SQL
26
+ * databases exist; importing tests must (≥2) or must not (==1) pass
27
+ * `{ database }` to every `.seed()` / `.table()`.
28
+ */
29
+ /** Directories the cross-file walks never descend into. */
30
+ const PRUNED = /* @__PURE__ */ new Set([
31
+ ".git",
32
+ "dist",
33
+ "node_modules"
34
+ ]);
35
+ /** Conventional per-feature subdirectories whose files are assertion fixtures. */
36
+ const CONVENTIONAL_SUBDIRS = /* @__PURE__ */ new Set([
37
+ "expected",
38
+ "fixtures",
39
+ "intercepts",
40
+ "requests",
41
+ "seeds"
42
+ ]);
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) {
62
+ const out = [];
63
+ const visit = (current) => {
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(current, { withFileTypes: true });
67
+ } catch {
68
+ return;
69
+ }
70
+ for (const entry of entries) {
71
+ const path = join(current, entry.name);
72
+ if (entry.isDirectory()) {
73
+ if (!PRUNED.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.has(entry.name) && !isPool(path)) visit(path);
94
+ }
95
+ };
96
+ visit(dir);
97
+ return out;
98
+ }
99
+ /** Locate `$FIXTURES` pools (unlike {@link listDirs}, does not prune them). */
100
+ function findPools(dir) {
101
+ const pools = [];
102
+ const visit = (current) => {
103
+ let entries;
104
+ try {
105
+ entries = readdirSync(current, { withFileTypes: true });
106
+ } catch {
107
+ return;
108
+ }
109
+ for (const entry of entries) {
110
+ if (!entry.isDirectory() || PRUNED.has(entry.name)) continue;
111
+ const path = join(current, entry.name);
112
+ if (isPool(path)) pools.push(path);
113
+ else visit(path);
114
+ }
115
+ };
116
+ visit(dir);
117
+ return pools;
118
+ }
119
+ function readText(path) {
120
+ try {
121
+ return readFileSync(path, "utf8");
122
+ } catch {
123
+ return "";
124
+ }
125
+ }
126
+ /**
127
+ * Blank out `//` and block comments (replacing them with spaces, newlines
128
+ * preserved so offsets and line numbers stay intact) while leaving string
129
+ * literals untouched. The regex/scan passes read this so a commented-out
130
+ * `.exec()` or a comma inside a `services:` comment never confuses them.
131
+ */
132
+ function blankComments(text) {
133
+ const out = [...text];
134
+ let state = "";
135
+ for (let i = 0; i < text.length; i += 1) {
136
+ const char = text[i];
137
+ const next = text[i + 1];
138
+ if (state === "line") if (char === "\n") state = "";
139
+ else out[i] = " ";
140
+ else if (state === "block") {
141
+ if (char === "*" && next === "/") {
142
+ out[i] = " ";
143
+ out[i + 1] = " ";
144
+ i += 1;
145
+ state = "";
146
+ } else if (char !== "\n") out[i] = " ";
147
+ } else if (state === "\"" || state === "'" || state === "`") {
148
+ if (char === "\\") i += 1;
149
+ else if (char === state) state = "";
150
+ } else if (char === "/" && next === "/") {
151
+ out[i] = " ";
152
+ state = "line";
153
+ } else if (char === "/" && next === "*") {
154
+ out[i] = " ";
155
+ state = "block";
156
+ } else if (char === "\"" || char === "'" || char === "`") state = char;
157
+ }
158
+ return out.join("");
159
+ }
160
+ /** File contents with comments blanked — the parse view for the scan passes. */
161
+ function readSource(path) {
162
+ return blankComments(readText(path));
163
+ }
164
+ function isDir(path) {
165
+ try {
166
+ return statSync(path).isDirectory();
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ /** The 1-based line number of a source offset. */
172
+ function lineAt(text, index) {
173
+ let line = 1;
174
+ for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === "\n") line += 1;
175
+ return line;
176
+ }
177
+ /**
178
+ * Every quoted string and simple template literal in a source. Over-collection
179
+ * only ever *hides* a dead-fixture finding, so a greedy regex is safe here.
180
+ */
181
+ const STRING_LITERAL = /'(?<single>(?:[^'\\\n]|\\.)*)'|"(?<double>(?:[^"\\\n]|\\.)*)"|`(?<template>(?:[^`$\\]|\\.)*)`/g;
182
+ function collectLiterals(text) {
183
+ const found = /* @__PURE__ */ new Set();
184
+ for (const match of text.matchAll(STRING_LITERAL)) {
185
+ const value = match.groups?.single ?? match.groups?.double ?? match.groups?.template;
186
+ if (value !== void 0 && value.length > 0) found.add(value);
187
+ }
188
+ return found;
189
+ }
190
+ /**
191
+ * Does the text pass a NON-literal argument to a fixture-ish verb
192
+ * (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
193
+ * variable makes the reference set incomplete, so the feature is reported at
194
+ * warn level instead of error (a false positive would be worse than a miss).
195
+ */
196
+ const FIXTURE_VERB_ARG = /(?:\.(?:seed|request|fixture)|\btoMatch)\s*\(\s*(?<arg>[^,)]*)/g;
197
+ function hasNonLiteralFixtureArg(text) {
198
+ for (const match of text.matchAll(FIXTURE_VERB_ARG)) {
199
+ const arg = (match.groups?.arg ?? "").trim();
200
+ if (arg.length === 0) continue;
201
+ if (!(/^'(?:[^'\\\n]|\\.)*'$/.test(arg) || /^"(?:[^"\\\n]|\\.)*"$/.test(arg) || /^`(?:[^`$\\]|\\.)*`$/.test(arg))) return true;
202
+ }
203
+ return false;
204
+ }
205
+ /**
206
+ * `// checker-disable-next-line <id>[,<id>] -- reason` suppresses the passes
207
+ * named by id (`a7`, `b5`, `c9`, or `*`) on the next non-blank line;
208
+ * `checker-disable-line` suppresses its own line. Mirrors the oxlint-disable
209
+ * idiom the repo already uses for negative specs.
210
+ */
211
+ function suppressedLines(text, id) {
212
+ const suppressed = /* @__PURE__ */ new Set();
213
+ const lines = text.split("\n");
214
+ const applies = (raw) => {
215
+ const ids = raw.replace(/--.*$/, "").trim().split(/[\s,]+/);
216
+ return ids.includes(id) || ids.includes("*");
217
+ };
218
+ for (const [index, line] of lines.entries()) {
219
+ let match = /checker-disable-next-line\s+(?<ids>.*)$/i.exec(line);
220
+ if (match !== null && applies(match.groups?.ids ?? "")) {
221
+ for (let next = index + 1; next < lines.length; next += 1) if (lines[next].trim().length > 0) {
222
+ suppressed.add(next + 1);
223
+ break;
224
+ }
225
+ }
226
+ match = /checker-disable-line\s+(?<ids>.*)$/i.exec(line);
227
+ if (match !== null && applies(match.groups?.ids ?? "")) suppressed.add(index + 1);
228
+ }
229
+ return suppressed;
230
+ }
231
+ /** From the `(` at `openIndex`, the substring inside the matching `)`. */
232
+ function balancedParens(text, openIndex) {
233
+ let depth = 0;
234
+ for (let i = openIndex; i < text.length; i += 1) {
235
+ const char = text[i];
236
+ if (char === "(") depth += 1;
237
+ else if (char === ")") {
238
+ depth -= 1;
239
+ if (depth === 0) return text.slice(openIndex + 1, i);
240
+ }
241
+ }
242
+ return text.slice(openIndex + 1);
243
+ }
244
+ /** From the `{` at `openIndex`, the substring inside the matching `}`. */
245
+ function balancedBraces(text, openIndex) {
246
+ let depth = 0;
247
+ for (let i = openIndex; i < text.length; i += 1) {
248
+ const char = text[i];
249
+ if (char === "{") depth += 1;
250
+ else if (char === "}") {
251
+ depth -= 1;
252
+ if (depth === 0) return text.slice(openIndex + 1, i);
253
+ }
254
+ }
255
+ return null;
256
+ }
257
+ /** Split an object/argument body on its depth-0 commas. */
258
+ function splitTopLevel(body) {
259
+ const parts = [];
260
+ let depth = 0;
261
+ let start = 0;
262
+ for (let i = 0; i < body.length; i += 1) {
263
+ const char = body[i];
264
+ if (char === "{" || char === "(" || char === "[") depth += 1;
265
+ else if (char === "}" || char === ")" || char === "]") depth -= 1;
266
+ else if (char === "," && depth === 0) {
267
+ parts.push(body.slice(start, i));
268
+ start = i + 1;
269
+ }
270
+ }
271
+ parts.push(body.slice(start));
272
+ return parts;
273
+ }
274
+ /** The destructured export names of a `= await specification.<ctor>(` binding. */
275
+ function runnerExportsOf(text) {
276
+ const names = /* @__PURE__ */ new Set();
277
+ const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
278
+ for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
279
+ const key = raw.split(":")[0].trim();
280
+ if (key.length > 0 && !NON_RUNNER_BINDINGS.has(key)) names.add(key);
281
+ }
282
+ return names;
283
+ }
284
+ /** The leading callee identifier of an expression (`postgres()` → `postgres`). */
285
+ function leadingCallee(value) {
286
+ const match = /^(?<callee>[A-Za-z_$][\w$]*)\s*\(/.exec(value.trim());
287
+ return match === null ? match : match.groups?.callee ?? null;
288
+ }
289
+ /** SQL factory count of a `services:` record, or null if non-literal/absent. */
290
+ function sqlDatabaseCount(text) {
291
+ const keyword = /\bservices\s*:/.exec(text);
292
+ if (keyword === null) return null;
293
+ const braceIndex = text.indexOf("{", keyword.index + keyword[0].length);
294
+ if (braceIndex === -1 || text.slice(keyword.index + keyword[0].length, braceIndex).trim() !== "") return null;
295
+ const body = balancedBraces(text, braceIndex);
296
+ if (body === null) return null;
297
+ let count = 0;
298
+ for (const pair of splitTopLevel(body)) {
299
+ if (pair.trim().length === 0) continue;
300
+ const colon = pair.indexOf(":");
301
+ if (colon === -1) return null;
302
+ const callee = leadingCallee(pair.slice(colon + 1).trim());
303
+ if (callee === null) return null;
304
+ if (callee === "postgres" || callee === "sqlite" || callee === "mysql") count += 1;
305
+ }
306
+ return count;
307
+ }
308
+ function modelSpecFile(file) {
309
+ const text = readSource(file);
310
+ let dockerAware = false;
311
+ for (const match of text.matchAll(/specification\.cli\s*\(/g)) {
312
+ const argsOpen = text.indexOf("(", match.index);
313
+ if (argsOpen !== -1 && /\bdocker\s*:/.test(balancedParens(text, argsOpen))) dockerAware = true;
314
+ }
315
+ return {
316
+ dockerAware,
317
+ file,
318
+ runnerExports: runnerExportsOf(text),
319
+ sqlDatabaseCount: sqlDatabaseCount(text)
320
+ };
321
+ }
322
+ /** Resolve a relative import specifier to a `.ts` path (else undefined). */
323
+ function resolveRelativeImport(fromFile, specifier) {
324
+ if (!specifier.startsWith(".")) return;
325
+ return resolve(dirname(fromFile), specifier).replace(/\.js$/, ".ts");
326
+ }
327
+ /** Every `import { a, b as c } from '…'` binding in a file. */
328
+ function namedImports(text) {
329
+ const bindings = [];
330
+ for (const match of text.matchAll(/import\s+(?:type\s+)?\{(?<names>[^}]*)\}\s+from\s+['"](?<source>[^'"]+)['"]/g)) {
331
+ const source = match.groups?.source ?? "";
332
+ for (const raw of (match.groups?.names ?? "").split(",")) {
333
+ const part = raw.trim();
334
+ if (part.length === 0) continue;
335
+ const [name, local] = part.split(/\s+as\s+/).map((token) => token.trim());
336
+ bindings.push({
337
+ local: local ?? name,
338
+ name,
339
+ source
340
+ });
341
+ }
342
+ }
343
+ return bindings;
344
+ }
345
+ /**
346
+ * Docker-aware runner results must be bound with `await using`. The runner
347
+ * identifiers are inferred from the `docker:` option of imported spec files —
348
+ * no hand-maintained runner list, unlike the oxlint rule.
349
+ */
350
+ function checkDockerRunnerAwaitUsing(rootDir) {
351
+ const violations = [];
352
+ const specFiles = listFiles(rootDir, (path) => path.endsWith(".specification.ts"));
353
+ const dockerSpecs = /* @__PURE__ */ new Map();
354
+ for (const file of specFiles) {
355
+ const model = modelSpecFile(file);
356
+ if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
357
+ }
358
+ if (dockerSpecs.size === 0) return violations;
359
+ for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
360
+ const text = readSource(testFile);
361
+ const runners = /* @__PURE__ */ new Set();
362
+ for (const binding of namedImports(text)) {
363
+ const target = resolveRelativeImport(testFile, binding.source);
364
+ if ((target === void 0 ? void 0 : dockerSpecs.get(target))?.runnerExports.has(binding.name) === true) runners.add(binding.local);
365
+ }
366
+ if (runners.size === 0) continue;
367
+ const suppressed = suppressedLines(readText(testFile), "b5");
368
+ 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)) {
369
+ const kind = match.groups?.kind ?? "";
370
+ const runner = match.groups?.runner ?? "";
371
+ if (kind.startsWith("await using") || !runners.has(runner)) continue;
372
+ const line = lineAt(text, match.index);
373
+ if (suppressed.has(line)) continue;
374
+ const rel = relative(rootDir, testFile);
375
+ violations.push({
376
+ file: rel,
377
+ line,
378
+ message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (CONVENTIONS B5)`,
379
+ severity: "error"
380
+ });
381
+ }
382
+ }
383
+ return violations;
384
+ }
385
+ /** Local inline runners a test declares itself (`specification.api(…)`). */
386
+ function localRunnerBindings(text) {
387
+ const locals = /* @__PURE__ */ new Set();
388
+ const decl = new RegExp(String.raw`\{(?<names>[^}]*)\}\s*=\s*await\s+specification\.(?:${CONSTRUCTORS})\s*\(`, "g");
389
+ for (const match of text.matchAll(decl)) for (const raw of (match.groups?.names ?? "").split(",")) {
390
+ const part = raw.trim();
391
+ const local = (part.includes(":") ? part.split(":")[1] : part).trim();
392
+ if (local.length > 0 && !NON_RUNNER_BINDINGS.has(part.split(":")[0].trim())) locals.add(local);
393
+ }
394
+ return locals;
395
+ }
396
+ /** The identifier immediately before a `.seed(`/`.table(` member, if any. */
397
+ function memberObjectIdentifier(text, dotIndex) {
398
+ const before = text.slice(0, dotIndex);
399
+ const match = /(?<name>[A-Za-z_$][\w$]*)\s*$/.exec(before);
400
+ return match === null ? match : match.groups?.name ?? null;
401
+ }
402
+ function checkDatabaseProperty(rootDir) {
403
+ const violations = [];
404
+ const enforced = /* @__PURE__ */ new Map();
405
+ for (const file of listFiles(rootDir, (path) => path.endsWith(".specification.ts"))) {
406
+ const model = modelSpecFile(file);
407
+ if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
408
+ }
409
+ if (enforced.size === 0) return violations;
410
+ for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
411
+ const text = readSource(testFile);
412
+ const applicable = /* @__PURE__ */ new Set();
413
+ for (const binding of namedImports(text)) {
414
+ const target = resolveRelativeImport(testFile, binding.source);
415
+ const model = target === void 0 ? void 0 : enforced.get(target);
416
+ if (model?.runnerExports.has(binding.name) === true) applicable.add(model);
417
+ }
418
+ if (applicable.size !== 1) continue;
419
+ const count = [...applicable][0].sqlDatabaseCount ?? 0;
420
+ const locals = localRunnerBindings(text);
421
+ const suppressed = suppressedLines(readText(testFile), "a7");
422
+ const rel = relative(rootDir, testFile);
423
+ for (const match of text.matchAll(/\.(?<verb>seed|table)\s*\(/g)) {
424
+ const verb = match.groups?.verb ?? "";
425
+ const object = memberObjectIdentifier(text, match.index);
426
+ if (object !== null && locals.has(object)) continue;
427
+ const args = balancedParens(text, text.indexOf("(", match.index));
428
+ const hasDatabase = /\bdatabase\s*:/.test(args);
429
+ const line = lineAt(text, match.index);
430
+ if (suppressed.has(line)) continue;
431
+ if (count >= 2 && !hasDatabase) violations.push({
432
+ file: rel,
433
+ line,
434
+ message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (CONVENTIONS A7)`,
435
+ severity: "error"
436
+ });
437
+ else if (count === 1 && hasDatabase) violations.push({
438
+ file: rel,
439
+ line,
440
+ message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (CONVENTIONS A7)`,
441
+ severity: "error"
442
+ });
443
+ }
444
+ }
445
+ return violations;
446
+ }
447
+ /** Is a top-level conventional-subdir entry referenced by any test literal? */
448
+ function entryReferenced(entry, entryIsDir, literals) {
449
+ for (const literal of literals) {
450
+ if (entryIsDir) {
451
+ if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
452
+ }
453
+ if (literal === entry || literal.includes(entry)) return true;
454
+ }
455
+ return false;
456
+ }
457
+ /** A child named like a conventional subdir but actually a fixture container. */
458
+ function isConventionalSubdir(path) {
459
+ if (!isDir(path)) return false;
460
+ if (listFiles(path, (candidate) => candidate.endsWith(".test.ts")).length > 0) return false;
461
+ let children;
462
+ try {
463
+ children = readdirSync(path, { withFileTypes: true });
464
+ } catch {
465
+ return false;
466
+ }
467
+ return !children.some((child) => child.isDirectory() && CONVENTIONAL_SUBDIRS.has(child.name));
468
+ }
469
+ function checkDeadFixtures(rootDir) {
470
+ const violations = [];
471
+ for (const dir of listDirs(rootDir)) {
472
+ if (basename(dir) === "specs") continue;
473
+ let entries;
474
+ try {
475
+ entries = readdirSync(dir, { withFileTypes: true });
476
+ } catch {
477
+ continue;
478
+ }
479
+ const convSubdirs = entries.filter((entry) => entry.isDirectory() && CONVENTIONAL_SUBDIRS.has(entry.name) && isConventionalSubdir(join(dir, entry.name))).map((entry) => entry.name);
480
+ if (convSubdirs.length === 0) continue;
481
+ const feature = basename(dir);
482
+ const testFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")).map((entry) => join(dir, entry.name));
483
+ const rel = relative(rootDir, dir);
484
+ if (testFiles.length === 0) {
485
+ violations.push({
486
+ file: rel,
487
+ line: 1,
488
+ message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts (CONVENTIONS C9)`,
489
+ severity: "error"
490
+ });
491
+ continue;
492
+ }
493
+ const literals = /* @__PURE__ */ new Set();
494
+ let downgrade = false;
495
+ for (const testFile of testFiles) {
496
+ const text = readSource(testFile);
497
+ for (const literal of collectLiterals(text)) literals.add(literal);
498
+ downgrade ||= hasNonLiteralFixtureArg(text);
499
+ }
500
+ const severity = downgrade ? "warn" : "error";
501
+ for (const sub of convSubdirs) {
502
+ const subPath = join(dir, sub);
503
+ for (const entry of readdirSync(subPath, { withFileTypes: true })) {
504
+ const entryIsDir = entry.isDirectory();
505
+ if (entryReferenced(entry.name, entryIsDir, literals)) continue;
506
+ const relEntry = relative(rootDir, join(subPath, entry.name));
507
+ violations.push({
508
+ file: relEntry,
509
+ line: 1,
510
+ message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (CONVENTIONS C9)`,
511
+ severity
512
+ });
513
+ }
514
+ }
515
+ }
516
+ violations.push(...checkPoolFixtures(rootDir));
517
+ return violations;
518
+ }
519
+ /** Unreferenced top-level entries of a `$FIXTURES` pool (`specs/fixtures`). */
520
+ function checkPoolFixtures(rootDir) {
521
+ const violations = [];
522
+ for (const pool of findPools(rootDir)) {
523
+ const specsRoot = dirname(pool);
524
+ const literals = /* @__PURE__ */ new Set();
525
+ for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts"))) {
526
+ if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
527
+ for (const literal of collectLiterals(readSource(file))) literals.add(literal);
528
+ }
529
+ for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
530
+ const relEntry = relative(rootDir, join(pool, entry.name));
531
+ violations.push({
532
+ file: relEntry,
533
+ line: 1,
534
+ message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (CONVENTIONS C9)`,
535
+ severity: "error"
536
+ });
537
+ }
538
+ }
539
+ return violations;
540
+ }
541
+ //#endregion
542
+ //#region src/lint/checker.ts
543
+ /**
544
+ * The conventions checker — the non-oxlint static channel.
545
+ *
546
+ * Oxlint only visits JS/TS sources; the D4 token grammar also constrains the
547
+ * DATA fixtures under `expected/**` and `requests/**`. This module walks a specs
548
+ * tree and reports:
549
+ *
550
+ * - **unknown / malformed tokens** in `expected/` fixtures — any text file, not
551
+ * just `.http`/`.json`/`.txt` (D4);
552
+ * - the **HTTP first-line grammar** of depth-1 `requests/*.http` (a request line)
553
+ * and `expected/*.http` (a status line) (D4b);
554
+ * - **tokens leaking into `requests/`** — requests are inputs, never matched, so
555
+ * a `{{token}}` there is almost always a mistake (D10, warning).
556
+ *
557
+ * It shares TOKEN_KINDS with the runtime matcher so the channels cannot drift.
558
+ */
559
+ /** A well-formed token: `{{word}}` / `{{word#ref}}`. */
560
+ const VALID_TOKEN = /^[A-Za-z][A-Za-z0-9]*(?:#[\w.-]+)?$/;
561
+ /** Any `{{ … }}` block (no nested braces) — classified by the scanner below. */
562
+ const BRACE_BLOCK = /\{\{(?<inner>[^{}]*)\}\}/g;
563
+ /** The leading identifier of a brace block, for malformed-ref classification. */
564
+ const LEADING_WORD = /^(?<kind>[A-Za-z][A-Za-z0-9]*)/;
565
+ const KNOWN = new Set(TOKEN_KINDS);
566
+ /** Directories whose files carry the token grammar (D4). */
567
+ const FIXTURE_DIRS = /* @__PURE__ */ new Set(["expected", "requests"]);
568
+ /**
569
+ * Directories the walk never enters. `fixtures/` trees (the shared pool and
570
+ * feature-local ones) are verbatim `.fixture()` cwd material — file STATE, not
571
+ * assertion fixtures — so the token grammar has no meaning inside them.
572
+ */
573
+ const SKIPPED_DIRS = /* @__PURE__ */ new Set([
574
+ ".git",
575
+ "dist",
576
+ "fixtures",
577
+ "node_modules"
578
+ ]);
579
+ /**
580
+ * Scan one fixture text for tokens outside the grammar: unknown kinds
581
+ * (`{{userid}}`) and malformed captures of a known kind (`{{iso8601#}}`,
582
+ * `{{uuid #id}}`). Well-formed template noise (`{{.Server.Version}}`,
583
+ * `{{ spaced }}`, `{{123}}`) is structurally out of the grammar and ignored.
584
+ */
585
+ function findUnknownTokens(text) {
586
+ const violations = [];
587
+ const lines = text.split("\n");
588
+ for (const [index, lineText] of lines.entries()) for (const match of lineText.matchAll(BRACE_BLOCK)) {
589
+ const inner = match.groups?.inner ?? "";
590
+ if (VALID_TOKEN.test(inner)) {
591
+ if (!KNOWN.has(inner.split("#")[0])) violations.push({
592
+ line: index + 1,
593
+ token: match[0]
594
+ });
595
+ continue;
596
+ }
597
+ const kind = LEADING_WORD.exec(inner)?.groups?.kind;
598
+ if (kind !== void 0 && KNOWN.has(kind) && inner !== kind) violations.push({
599
+ line: index + 1,
600
+ token: match[0]
601
+ });
602
+ }
603
+ return violations;
604
+ }
605
+ /** Known tokens present in a text — for the `requests/` leak warning (D10). */
606
+ function findKnownTokens(text) {
607
+ const found = [];
608
+ const lines = text.split("\n");
609
+ for (const [index, lineText] of lines.entries()) for (const match of lineText.matchAll(BRACE_BLOCK)) {
610
+ const inner = match.groups?.inner ?? "";
611
+ if (VALID_TOKEN.test(inner) && KNOWN.has(inner.split("#")[0])) found.push({
612
+ line: index + 1,
613
+ token: match[0]
614
+ });
615
+ }
616
+ return found;
617
+ }
618
+ /** A binary file is anything that fails to decode cleanly as UTF-8. */
619
+ function decodeText(path) {
620
+ let text;
621
+ try {
622
+ text = readFileSync(path, "utf8");
623
+ } catch {
624
+ return null;
625
+ }
626
+ return text.includes("\0") || text.includes("�") ? null : text;
627
+ }
628
+ /** First non-empty line of a text (the HTTP first-line grammar target). */
629
+ function firstLine(text) {
630
+ for (const line of text.split("\n")) if (line.trim().length > 0) return line.trim();
631
+ return "";
632
+ }
633
+ const REQUEST_LINE = /^(?<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/\S*/;
634
+ const STATUS_LINE = /^HTTP\/\d(?:\.\d)? \d{3}\b/;
635
+ /**
636
+ * Walk `rootDir` and check every fixture file. Paths in the result are relative
637
+ * to `rootDir`. Errors fail the checker; warnings are advisory.
638
+ */
639
+ function checkConventionFiles(rootDir) {
640
+ const violations = [];
641
+ const visit = (dir, inside) => {
642
+ let entries;
643
+ try {
644
+ entries = readdirSync(dir, { withFileTypes: true });
645
+ } catch {
646
+ return;
647
+ }
648
+ for (const entry of entries) {
649
+ const path = join(dir, entry.name);
650
+ const rel = relative(rootDir, path);
651
+ if (entry.isDirectory()) {
652
+ if (SKIPPED_DIRS.has(entry.name)) continue;
653
+ const next = FIXTURE_DIRS.has(entry.name) ? entry.name : inside;
654
+ visit(path, next);
655
+ continue;
656
+ }
657
+ if (inside === null) continue;
658
+ const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
659
+ if (inside === "requests") {
660
+ if (!entry.name.endsWith(".http")) continue;
661
+ const text = decodeText(path);
662
+ if (text === null) continue;
663
+ if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
664
+ file: rel,
665
+ line: 1,
666
+ message: `${rel}:1: a requests/*.http file must start with a request line "METHOD /path" (CONVENTIONS D4b)`,
667
+ severity: "error"
668
+ });
669
+ for (const { line, token } of findKnownTokens(text)) violations.push({
670
+ file: rel,
671
+ line,
672
+ message: `${rel}:${line}: token ${token} in a requests/ file — requests are inputs, never matched; tokens are not validated here (CONVENTIONS D10)`,
673
+ severity: "warn",
674
+ token
675
+ });
676
+ continue;
677
+ }
678
+ const text = decodeText(path);
679
+ if (text === null) continue;
680
+ if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
681
+ file: rel,
682
+ line: 1,
683
+ message: `${rel}:1: an expected/*.http file must start with a status line "HTTP/1.1 <status>" (CONVENTIONS D4b)`,
684
+ severity: "error"
685
+ });
686
+ for (const { line, token } of findUnknownTokens(text)) violations.push({
687
+ file: rel,
688
+ line,
689
+ message: `${rel}:${line}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
690
+ severity: "error",
691
+ token
692
+ });
693
+ }
694
+ };
695
+ visit(rootDir, null);
696
+ return violations;
697
+ }
698
+ /**
699
+ * Run every checker pass over `rootDir`: the token/HTTP grammar passes (D4 /
700
+ * D4b / D10) plus the cross-file passes (C9 dead fixtures, B5 await-using
701
+ * inference, A7 database property). This is the entry the bundled bin drives.
702
+ */
703
+ function runAllChecks(rootDir) {
704
+ return [
705
+ ...checkConventionFiles(rootDir),
706
+ ...checkDeadFixtures(rootDir),
707
+ ...checkDockerRunnerAwaitUsing(rootDir),
708
+ ...checkDatabaseProperty(rootDir)
709
+ ];
710
+ }
711
+ /** Render violations the way the lint chain prints them. One line per finding. */
712
+ function formatViolations(violations) {
713
+ return violations.map(({ message, severity }) => `[${severity}] ${message}`).join("\n");
714
+ }
715
+ //#endregion
716
+ //#region src/lint/checker-cli.ts
717
+ /**
718
+ * CLI entry for the conventions checker (bundled as `dist/checker.js`).
719
+ *
720
+ * node dist/checker.js [rootDir] # default: cwd
721
+ *
722
+ * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10) and the
723
+ * cross-file passes (C9 dead fixtures, B5 await-using inference, A7 database
724
+ * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded
725
+ * C9 feature) are printed but do not fail the run.
726
+ */
727
+ const root = resolve(process.argv[2] ?? ".");
728
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
729
+ console.error(`conventions checker: no such directory: ${root}`);
730
+ process.exit(1);
731
+ }
732
+ const violations = runAllChecks(root);
733
+ const errors = violations.filter((violation) => violation.severity === "error");
734
+ if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
735
+ if (errors.length > 0) {
736
+ console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
737
+ process.exit(1);
738
+ }
739
+ console.log(`conventions checker: all passes clean under ${root} (D4/D4b/D10 grammar, C9 dead fixtures, B5 await-using, A7 database)${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
740
+ //#endregion
741
+ export {};