@jterrazz/test 12.2.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 +12 -14
- package/dist/checker.js +554 -48
- package/dist/index.d.ts +102 -88
- package/dist/index.js +16 -14
- package/dist/literate.js +156 -164
- package/dist/match.js +60 -180
- package/dist/oxlint.cjs +83 -11
- package/dist/oxlint.js +83 -11
- package/dist/queue.js +1 -1
- package/dist/spec-document.js +536 -0
- package/dist/vitest.d.ts +3 -3
- package/dist/vitest.js +16 -15
- package/package.json +6 -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 { a 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,9 +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. A
|
|
21
|
-
* AND as a referrer: the paths
|
|
22
|
-
* exactly as a `.fixture('…')` literal does.
|
|
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.
|
|
23
513
|
* - **B5 await-using (inference)** — the docker-aware runners of a spec file
|
|
24
514
|
* are derived from its `docker:` option, then every `<runner>….exec()` bound
|
|
25
515
|
* without `await using` in an importing test is flagged. The primary B5
|
|
@@ -163,21 +653,21 @@ function readSource(path) {
|
|
|
163
653
|
return blankComments(readText(path));
|
|
164
654
|
}
|
|
165
655
|
/**
|
|
166
|
-
* The fixture paths a
|
|
656
|
+
* The fixture paths a `<case>.spec.yaml` names — its referrer literals. The
|
|
167
657
|
* runner's own parser reads them, so the two channels cannot disagree about
|
|
168
|
-
* what the
|
|
169
|
-
* is what reports it).
|
|
658
|
+
* what the document says; an unparseable file vouches for nothing (the
|
|
659
|
+
* `d4b-spec-shape` pass is what reports it).
|
|
170
660
|
*/
|
|
171
|
-
function
|
|
661
|
+
function collectSpecLiterals(path) {
|
|
172
662
|
try {
|
|
173
|
-
return new Set(
|
|
663
|
+
return new Set(parseSpecDocument(readText(path), path).fixtures.map((fixture) => fixture.path));
|
|
174
664
|
} catch {
|
|
175
665
|
return /* @__PURE__ */ new Set();
|
|
176
666
|
}
|
|
177
667
|
}
|
|
178
668
|
/** Is this a file a domain's assets may be referenced from? */
|
|
179
669
|
function isReferrer(name) {
|
|
180
|
-
return name.endsWith(".test.ts") || name.endsWith(".
|
|
670
|
+
return name.endsWith(".test.ts") || name.endsWith(".spec.yaml");
|
|
181
671
|
}
|
|
182
672
|
function isDir(path) {
|
|
183
673
|
try {
|
|
@@ -503,7 +993,7 @@ function checkDeadFixtures(rootDir) {
|
|
|
503
993
|
violations.push({
|
|
504
994
|
file: rel,
|
|
505
995
|
line: 1,
|
|
506
|
-
message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts or *.
|
|
996
|
+
message: `${rel}: domain directory has conventional subdirs (${convSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/10-linting.md)`,
|
|
507
997
|
severity: "error"
|
|
508
998
|
});
|
|
509
999
|
continue;
|
|
@@ -511,8 +1001,8 @@ function checkDeadFixtures(rootDir) {
|
|
|
511
1001
|
const literals = /* @__PURE__ */ new Set();
|
|
512
1002
|
let downgrade = false;
|
|
513
1003
|
for (const testFile of testFiles) {
|
|
514
|
-
if (testFile.endsWith(".
|
|
515
|
-
for (const literal of
|
|
1004
|
+
if (testFile.endsWith(".spec.yaml")) {
|
|
1005
|
+
for (const literal of collectSpecLiterals(testFile)) literals.add(literal);
|
|
516
1006
|
continue;
|
|
517
1007
|
}
|
|
518
1008
|
const text = readSource(testFile);
|
|
@@ -544,9 +1034,9 @@ function checkPoolFixtures(rootDir) {
|
|
|
544
1034
|
for (const pool of findPools(rootDir)) {
|
|
545
1035
|
const specsRoot = dirname(pool);
|
|
546
1036
|
const literals = /* @__PURE__ */ new Set();
|
|
547
|
-
for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".
|
|
1037
|
+
for (const file of listFiles(specsRoot, (path) => path.endsWith(".ts") || path.endsWith(".spec.yaml"))) {
|
|
548
1038
|
if (file.startsWith(`${pool}/`) || file.startsWith(`${pool}\\`)) continue;
|
|
549
|
-
const found = file.endsWith(".
|
|
1039
|
+
const found = file.endsWith(".spec.yaml") ? collectSpecLiterals(file) : collectLiterals(readSource(file));
|
|
550
1040
|
for (const literal of found) literals.add(literal);
|
|
551
1041
|
}
|
|
552
1042
|
for (const entry of readdirSync(pool, { withFileTypes: true })) if (![...literals].some((literal) => literal.includes(entry.name))) {
|
|
@@ -576,9 +1066,11 @@ function checkPoolFixtures(rootDir) {
|
|
|
576
1066
|
* and `expected/*.http` (a status line) (D4b);
|
|
577
1067
|
* - **tokens leaking into `requests/`** — requests are inputs, never matched, so
|
|
578
1068
|
* a `{{token}}` there is almost always a mistake (D10, warning);
|
|
579
|
-
* - the
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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`.
|
|
582
1074
|
*
|
|
583
1075
|
* It shares TOKEN_KINDS with the runtime matcher so the channels cannot drift.
|
|
584
1076
|
*/
|
|
@@ -659,37 +1151,38 @@ function firstLine(text) {
|
|
|
659
1151
|
const REQUEST_LINE = /^(?<method>GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/\S*/;
|
|
660
1152
|
const STATUS_LINE = /^HTTP\/\d(?:\.\d)? \d{3}\b/;
|
|
661
1153
|
/**
|
|
662
|
-
* Check one
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* never
|
|
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.
|
|
666
1158
|
*
|
|
667
1159
|
* Precision over recall, as everywhere in this channel: the parser refuses at
|
|
668
|
-
* the first defect, so one
|
|
1160
|
+
* the first defect, so one document reports one grammar violation per run.
|
|
669
1161
|
*/
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
let bodyStart = 0;
|
|
1162
|
+
function checkSpecFile(text, rel) {
|
|
1163
|
+
let document;
|
|
673
1164
|
try {
|
|
674
|
-
|
|
1165
|
+
document = readSpecFile(text, rel).document;
|
|
675
1166
|
} catch (error) {
|
|
676
|
-
if (!(error instanceof
|
|
677
|
-
const rule = error.defect === "narrative" ? "B4" : "D4b";
|
|
1167
|
+
if (!(error instanceof SpecSyntaxError)) throw error;
|
|
678
1168
|
return [{
|
|
679
1169
|
file: rel,
|
|
680
1170
|
line: error.line,
|
|
681
|
-
message: `${error.message} (
|
|
1171
|
+
message: `${error.message} (D4b — see docs/10-linting.md)`,
|
|
682
1172
|
severity: "error"
|
|
683
1173
|
}];
|
|
684
1174
|
}
|
|
685
|
-
const
|
|
686
|
-
for (const { line, token } of findUnknownTokens(
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
+
}
|
|
693
1186
|
return violations;
|
|
694
1187
|
}
|
|
695
1188
|
/**
|
|
@@ -714,9 +1207,12 @@ function checkConventionFiles(rootDir) {
|
|
|
714
1207
|
visit(path, next);
|
|
715
1208
|
continue;
|
|
716
1209
|
}
|
|
717
|
-
if (entry.name.endsWith(".
|
|
1210
|
+
if (entry.name.endsWith(".spec.yaml")) {
|
|
718
1211
|
const text = decodeText(path);
|
|
719
|
-
if (text !== null)
|
|
1212
|
+
if (text !== null) {
|
|
1213
|
+
violations.push(...checkSpecFile(text, rel));
|
|
1214
|
+
violations.push(...checkSpecConventions(text, rel, path));
|
|
1215
|
+
}
|
|
720
1216
|
continue;
|
|
721
1217
|
}
|
|
722
1218
|
if (inside === null) continue;
|
|
@@ -768,6 +1264,7 @@ function checkConventionFiles(rootDir) {
|
|
|
768
1264
|
function runAllChecks(rootDir) {
|
|
769
1265
|
return [
|
|
770
1266
|
...checkConventionFiles(rootDir),
|
|
1267
|
+
...checkSpecDescriptionsUnique(rootDir),
|
|
771
1268
|
...checkDeadFixtures(rootDir),
|
|
772
1269
|
...checkDockerRunnerAwaitUsing(rootDir),
|
|
773
1270
|
...checkDatabaseProperty(rootDir)
|
|
@@ -782,18 +1279,27 @@ function formatViolations(violations) {
|
|
|
782
1279
|
/**
|
|
783
1280
|
* CLI entry for the conventions checker (bundled as `dist/checker.js`).
|
|
784
1281
|
*
|
|
785
|
-
* node dist/checker.js [rootDir] # default: cwd
|
|
1282
|
+
* node dist/checker.js [rootDir] [--fix] # default root: cwd
|
|
786
1283
|
*
|
|
787
|
-
* Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10)
|
|
788
|
-
* cross-file passes (C9 dead
|
|
789
|
-
* property). Exit 1 on any
|
|
790
|
-
*
|
|
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.
|
|
791
1292
|
*/
|
|
792
|
-
const
|
|
1293
|
+
const fix = process.argv.includes("--fix");
|
|
1294
|
+
const root = resolve(process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ".");
|
|
793
1295
|
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
794
1296
|
console.error(`conventions checker: no such directory: ${root}`);
|
|
795
1297
|
process.exit(1);
|
|
796
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
|
+
}
|
|
797
1303
|
const violations = runAllChecks(root);
|
|
798
1304
|
const errors = violations.filter((violation) => violation.severity === "error");
|
|
799
1305
|
if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
|
|
@@ -801,6 +1307,6 @@ if (errors.length > 0) {
|
|
|
801
1307
|
console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
|
|
802
1308
|
process.exit(1);
|
|
803
1309
|
}
|
|
804
|
-
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)` : ""}`);
|
|
805
1311
|
//#endregion
|
|
806
1312
|
export {};
|