@jterrazz/test 8.0.0 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -230
- package/dist/checker.d.ts +1 -0
- package/dist/checker.js +741 -0
- package/dist/index.d.ts +1249 -728
- package/dist/index.js +3133 -1538
- package/dist/intercept.js +129 -299
- package/dist/match.js +153 -0
- package/dist/oxlint.cjs +2931 -0
- package/dist/oxlint.d.cts +150 -0
- package/dist/oxlint.d.ts +150 -0
- package/dist/oxlint.js +2925 -0
- package/package.json +47 -41
- package/dist/chunk.cjs +0 -28
- package/dist/index.cjs +0 -2264
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -980
- package/dist/index.js.map +0 -1
- package/dist/intercept.cjs +0 -323
- package/dist/intercept.cjs.map +0 -1
- package/dist/intercept.d.cts +0 -115
- package/dist/intercept.d.ts +0 -115
- package/dist/intercept.js.map +0 -1
- package/dist/intercept2.cjs +0 -81
- package/dist/intercept2.cjs.map +0 -1
- package/dist/intercept2.js +0 -81
- package/dist/intercept2.js.map +0 -1
- package/dist/mock-of.cjs +0 -32
- package/dist/mock-of.cjs.map +0 -1
- package/dist/mock-of.d.cts +0 -27
- package/dist/mock-of.d.ts +0 -27
- package/dist/mock-of.js +0 -19
- package/dist/mock-of.js.map +0 -1
- package/dist/mock.cjs +0 -4
- package/dist/mock.d.cts +0 -2
- package/dist/mock.d.ts +0 -2
- package/dist/mock.js +0 -2
- package/dist/services.cjs +0 -5
- package/dist/services.d.cts +0 -2
- package/dist/services.d.ts +0 -2
- package/dist/services.js +0 -2
- package/dist/sqlite.cjs +0 -350
- package/dist/sqlite.cjs.map +0 -1
- package/dist/sqlite.d.cts +0 -209
- package/dist/sqlite.d.ts +0 -209
- package/dist/sqlite.js +0 -331
- package/dist/sqlite.js.map +0 -1
- package/dist/types.d.cts +0 -42
- package/dist/types.d.ts +0 -42
package/dist/checker.js
ADDED
|
@@ -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 (B5 — see docs/10-linting.md)`,
|
|
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 (A7 — see docs/10-linting.md)`,
|
|
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 (A7 — see docs/10-linting.md)`,
|
|
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 (C9 — see docs/10-linting.md)`,
|
|
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} (C9 — see docs/10-linting.md)`,
|
|
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} (C9 — see docs/10-linting.md)`,
|
|
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" (D4b — see docs/10-linting.md)`,
|
|
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 (D10 — see docs/10-linting.md)`,
|
|
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>" (D4b — see docs/10-linting.md)`,
|
|
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 {};
|