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