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