@jterrazz/test 15.2.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -418
- package/dist/ambiguity.js +7 -6
- package/dist/appium.adapter.js +12 -11
- package/dist/artifacts.js +25 -1698
- package/dist/browser/index.js +1484 -1142
- package/dist/checker.d.ts +1 -1
- package/dist/checker.js +1308 -76
- package/dist/fixtures.js +110 -0
- package/dist/index.d.ts +1482 -1011
- package/dist/index.js +1715 -1173
- package/dist/literate.js +1747 -0
- package/dist/match.js +625 -84
- package/dist/oxlint.cjs +3534 -1469
- package/dist/oxlint.d.cts +60 -48
- package/dist/oxlint.d.ts +60 -48
- package/dist/oxlint.js +3534 -1469
- package/dist/playwright.adapter.js +101 -6
- package/dist/{intercept.js → server.js} +11 -3
- package/dist/{queue.js → stream.js} +63 -4
- package/dist/vitest.d.ts +85 -36
- package/dist/vitest.js +177 -43
- package/package.json +31 -11
- package/dist/spec-document.js +0 -651
package/dist/checker.js
CHANGED
|
@@ -1,8 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import {
|
|
2
|
+
import { _ as isSeq, a as DOCUMENT_KEYS, c as SpecSyntaxError, d as readSpecFile, g as isScalar, h as isMap, l as assertedStreams, m as Scalar, o as RUN_KEYS, r as TOKEN_KINDS, s as SPEC_EXTENSION, u as parseSpecDocument, v as renderYamlSource } from "./match.js";
|
|
3
|
+
import { a as GROUND_FIXTURES, i as GROUND_EXPECTED, n as resolveFixtureSource, o as GROUND_REQUESTS, r as GROUND_DIRS, s as GROUND_SEEDS } from "./fixtures.js";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
//#region src/lint/fs-cache.ts
|
|
8
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
9
|
+
function kindOf(path) {
|
|
10
|
+
const cached = kinds.get(path);
|
|
11
|
+
if (cached !== void 0) return cached;
|
|
12
|
+
let result;
|
|
13
|
+
try {
|
|
14
|
+
result = statSync(path).isDirectory() ? "dir" : "file";
|
|
15
|
+
} catch {
|
|
16
|
+
result = "missing";
|
|
17
|
+
}
|
|
18
|
+
kinds.set(path, result);
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function isFile(path) {
|
|
22
|
+
return kindOf(path) === "file";
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/lint/ast.ts
|
|
26
|
+
/**
|
|
27
|
+
* The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
|
|
28
|
+
* searched no higher than the nearest `package.json`. `undefined` when the file
|
|
29
|
+
* is not under one.
|
|
30
|
+
*
|
|
31
|
+
* One anchor for every specs-aware rule, because two searches are two
|
|
32
|
+
* answers: an innermost match and an outermost one read a nested tree as two
|
|
33
|
+
* different facets, and a bare "is `specs` a segment of this path" test
|
|
34
|
+
* answers about the machine — a checkout living under `~/specs/` would be one
|
|
35
|
+
* whole specs tree, with F2's protection of production code silently off. The
|
|
36
|
+
* package boundary makes the search stop where the project does.
|
|
37
|
+
*/
|
|
38
|
+
function specsAnchor(filename) {
|
|
39
|
+
const relative = [basename(filename)];
|
|
40
|
+
let directory = dirname(filename);
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (basename(directory) === "specs") return {
|
|
43
|
+
directory,
|
|
44
|
+
relative
|
|
45
|
+
};
|
|
46
|
+
if (isFile(join(directory, "package.json"))) return;
|
|
47
|
+
const parent = dirname(directory);
|
|
48
|
+
if (parent === directory) return;
|
|
49
|
+
relative.unshift(basename(directory));
|
|
50
|
+
directory = parent;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Is this file inside a `specs/` tree? — the predicate form of {@link specsAnchor}. */
|
|
54
|
+
function isUnderSpecs(filename) {
|
|
55
|
+
return specsAnchor(filename) !== void 0;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lint/role.ts
|
|
59
|
+
/**
|
|
60
|
+
* The three suffixes that DECLARE tests, as a name-level predicate.
|
|
61
|
+
*
|
|
62
|
+
* `roleOf` is the answer for a rule handed a file; the passes that WALK a tree
|
|
63
|
+
* only ever hold a name, and this is the same vocabulary for them — one place
|
|
64
|
+
* to read, so a tree walk and a rule never disagree on what a test is.
|
|
65
|
+
*/
|
|
66
|
+
function isTestFileName(name) {
|
|
67
|
+
return name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.ts");
|
|
68
|
+
}
|
|
69
|
+
/** A test that runs under node's project — a `.test.tsx` renders and is not one. */
|
|
70
|
+
function isNodeTestFileName(name) {
|
|
71
|
+
return name.endsWith(".test.ts") || name.endsWith(".spec.ts");
|
|
72
|
+
}
|
|
73
|
+
/** The nearest package root at or above a directory, when there is one. */
|
|
74
|
+
function packageRootOf(directory) {
|
|
75
|
+
let current = directory;
|
|
76
|
+
for (;;) {
|
|
77
|
+
if (isFile(join(current, "package.json"))) return current;
|
|
78
|
+
const parent = dirname(current);
|
|
79
|
+
if (parent === current) return;
|
|
80
|
+
current = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
6
84
|
//#region src/lint/checker-crossfile.ts
|
|
7
85
|
/**
|
|
8
86
|
* The cross-file checker passes — the analyses oxlint cannot express because
|
|
@@ -158,9 +236,10 @@ function blankComments(text) {
|
|
|
158
236
|
for (let i = 0; i < text.length; i += 1) {
|
|
159
237
|
const char = text[i];
|
|
160
238
|
const next = text[i + 1];
|
|
161
|
-
if (state === "line")
|
|
162
|
-
|
|
163
|
-
|
|
239
|
+
if (state === "line") {
|
|
240
|
+
if (char === "\n") state = "";
|
|
241
|
+
else out[i] = " ";
|
|
242
|
+
} else if (state === "block") {
|
|
164
243
|
if (char === "*" && next === "/") {
|
|
165
244
|
out[i] = " ";
|
|
166
245
|
out[i + 1] = " ";
|
|
@@ -200,12 +279,12 @@ function collectSpecLiterals(path) {
|
|
|
200
279
|
/**
|
|
201
280
|
* Is this a file a leaf's ground may be referenced from?
|
|
202
281
|
*
|
|
203
|
-
*
|
|
204
|
-
* tree — sits in the same `_expected
|
|
205
|
-
* apart.
|
|
282
|
+
* Every test suffix counts, and so does a document: a rendered unit's golden —
|
|
283
|
+
* its ARIA tree — sits in the same `_expected/` as a spec's, read by a file the
|
|
284
|
+
* suffix alone tells apart.
|
|
206
285
|
*/
|
|
207
286
|
function isReferrer(name) {
|
|
208
|
-
return
|
|
287
|
+
return isTestFileName(name) || name.endsWith(".spec.yaml");
|
|
209
288
|
}
|
|
210
289
|
function isDir(path) {
|
|
211
290
|
try {
|
|
@@ -233,6 +312,23 @@ function collectLiterals(text) {
|
|
|
233
312
|
}
|
|
234
313
|
return found;
|
|
235
314
|
}
|
|
315
|
+
const TEMPLATE_WITH_HOLE = /`(?<body>(?:[^`\\]|\\.)*\$\{(?:[^`\\]|\\.)*)`/gu;
|
|
316
|
+
function collectLiteralPatterns(text) {
|
|
317
|
+
const found = [];
|
|
318
|
+
for (const match of text.matchAll(TEMPLATE_WITH_HOLE)) {
|
|
319
|
+
const body = match.groups?.body ?? "";
|
|
320
|
+
const opens = body.indexOf("${");
|
|
321
|
+
const closes = body.lastIndexOf("}");
|
|
322
|
+
if (opens === -1 || closes === -1 || closes < opens) continue;
|
|
323
|
+
const prefix = body.slice(0, opens);
|
|
324
|
+
const suffix = body.slice(closes + 1);
|
|
325
|
+
if (prefix.length > 0 || suffix.length > 0) found.push({
|
|
326
|
+
prefix,
|
|
327
|
+
suffix
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return found;
|
|
331
|
+
}
|
|
236
332
|
/**
|
|
237
333
|
* Does the text pass a NON-literal argument to a fixture-ish verb
|
|
238
334
|
* (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
|
|
@@ -403,7 +499,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
|
|
|
403
499
|
if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
|
|
404
500
|
}
|
|
405
501
|
if (dockerSpecs.size === 0) return violations;
|
|
406
|
-
for (const testFile of listFiles(rootDir,
|
|
502
|
+
for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
|
|
407
503
|
const text = readSource(testFile);
|
|
408
504
|
const runners = /* @__PURE__ */ new Set();
|
|
409
505
|
for (const binding of namedImports(text)) {
|
|
@@ -422,7 +518,8 @@ function checkDockerRunnerAwaitUsing(rootDir) {
|
|
|
422
518
|
violations.push({
|
|
423
519
|
file: rel,
|
|
424
520
|
line,
|
|
425
|
-
message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/
|
|
521
|
+
message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/19-linting.md)`,
|
|
522
|
+
rule: "b5",
|
|
426
523
|
severity: "error"
|
|
427
524
|
});
|
|
428
525
|
}
|
|
@@ -454,7 +551,7 @@ function checkDatabaseProperty(rootDir) {
|
|
|
454
551
|
if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
|
|
455
552
|
}
|
|
456
553
|
if (enforced.size === 0) return violations;
|
|
457
|
-
for (const testFile of listFiles(rootDir,
|
|
554
|
+
for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
|
|
458
555
|
const text = readSource(testFile);
|
|
459
556
|
const applicable = /* @__PURE__ */ new Set();
|
|
460
557
|
for (const binding of namedImports(text)) {
|
|
@@ -479,13 +576,15 @@ function checkDatabaseProperty(rootDir) {
|
|
|
479
576
|
if (count >= 2 && !hasDatabase) violations.push({
|
|
480
577
|
file: rel,
|
|
481
578
|
line,
|
|
482
|
-
message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/
|
|
579
|
+
message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/19-linting.md)`,
|
|
580
|
+
rule: "a7",
|
|
483
581
|
severity: "error"
|
|
484
582
|
});
|
|
485
583
|
else if (count === 1 && hasDatabase) violations.push({
|
|
486
584
|
file: rel,
|
|
487
585
|
line,
|
|
488
|
-
message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/
|
|
586
|
+
message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/19-linting.md)`,
|
|
587
|
+
rule: "a7",
|
|
489
588
|
severity: "error"
|
|
490
589
|
});
|
|
491
590
|
}
|
|
@@ -493,7 +592,8 @@ function checkDatabaseProperty(rootDir) {
|
|
|
493
592
|
return violations;
|
|
494
593
|
}
|
|
495
594
|
/** Is a top-level ground-subdir entry referenced by any test literal? */
|
|
496
|
-
function entryReferenced(entry, entryIsDir, literals) {
|
|
595
|
+
function entryReferenced(entry, entryIsDir, literals, patterns = []) {
|
|
596
|
+
for (const pattern of patterns) if (entry.startsWith(pattern.prefix) && entry.endsWith(pattern.suffix)) return true;
|
|
497
597
|
for (const literal of literals) {
|
|
498
598
|
if (entryIsDir) {
|
|
499
599
|
if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
|
|
@@ -505,7 +605,7 @@ function entryReferenced(entry, entryIsDir, literals) {
|
|
|
505
605
|
/** A child named like a ground subdir but actually a fixture container. */
|
|
506
606
|
function isConventionalSubdir(path) {
|
|
507
607
|
if (!isDir(path)) return false;
|
|
508
|
-
if (listFiles(path,
|
|
608
|
+
if (listFiles(path, isTestFileName).length > 0) return false;
|
|
509
609
|
let children;
|
|
510
610
|
try {
|
|
511
611
|
children = readdirSync(path, { withFileTypes: true });
|
|
@@ -533,12 +633,14 @@ function checkDeadFixtures(rootDir) {
|
|
|
533
633
|
violations.push({
|
|
534
634
|
file: rel,
|
|
535
635
|
line: 1,
|
|
536
|
-
message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/
|
|
636
|
+
message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/19-linting.md)`,
|
|
637
|
+
rule: "c9",
|
|
537
638
|
severity: "error"
|
|
538
639
|
});
|
|
539
640
|
continue;
|
|
540
641
|
}
|
|
541
642
|
const literals = /* @__PURE__ */ new Set();
|
|
643
|
+
const patterns = [];
|
|
542
644
|
let downgrade = false;
|
|
543
645
|
for (const testFile of testFiles) {
|
|
544
646
|
if (testFile.endsWith(".spec.yaml")) {
|
|
@@ -547,6 +649,7 @@ function checkDeadFixtures(rootDir) {
|
|
|
547
649
|
}
|
|
548
650
|
const text = readSource(testFile);
|
|
549
651
|
for (const literal of collectLiterals(text)) literals.add(literal);
|
|
652
|
+
patterns.push(...collectLiteralPatterns(text));
|
|
550
653
|
downgrade ||= hasNonLiteralFixtureArg(text);
|
|
551
654
|
}
|
|
552
655
|
const severity = downgrade ? "warn" : "error";
|
|
@@ -554,12 +657,13 @@ function checkDeadFixtures(rootDir) {
|
|
|
554
657
|
const subPath = join(dir, sub);
|
|
555
658
|
for (const entry of readdirSync(subPath, { withFileTypes: true })) {
|
|
556
659
|
const entryIsDir = entry.isDirectory();
|
|
557
|
-
if (entryReferenced(entry.name, entryIsDir, literals)) continue;
|
|
660
|
+
if (entryReferenced(entry.name, entryIsDir, literals, patterns)) continue;
|
|
558
661
|
const relEntry = relative(rootDir, join(subPath, entry.name));
|
|
559
662
|
violations.push({
|
|
560
663
|
file: relEntry,
|
|
561
664
|
line: 1,
|
|
562
|
-
message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/
|
|
665
|
+
message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/19-linting.md)`,
|
|
666
|
+
rule: "c9",
|
|
563
667
|
severity
|
|
564
668
|
});
|
|
565
669
|
}
|
|
@@ -584,7 +688,8 @@ function checkPoolFixtures(rootDir) {
|
|
|
584
688
|
violations.push({
|
|
585
689
|
file: relEntry,
|
|
586
690
|
line: 1,
|
|
587
|
-
message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/
|
|
691
|
+
message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/19-linting.md)`,
|
|
692
|
+
rule: "c9",
|
|
588
693
|
severity: "error"
|
|
589
694
|
});
|
|
590
695
|
}
|
|
@@ -687,7 +792,8 @@ function checkPoolFixtureSharing(rootDir) {
|
|
|
687
792
|
return {
|
|
688
793
|
file: relEntry,
|
|
689
794
|
line: 1,
|
|
690
|
-
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/
|
|
795
|
+
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/19-linting.md, or run the checker with --fix)`,
|
|
796
|
+
rule: "c14",
|
|
691
797
|
severity: "error"
|
|
692
798
|
};
|
|
693
799
|
});
|
|
@@ -740,7 +846,8 @@ function checkLocalFixtureReach(rootDir) {
|
|
|
740
846
|
violations.push({
|
|
741
847
|
file: rel,
|
|
742
848
|
line,
|
|
743
|
-
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/
|
|
849
|
+
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/19-linting.md)`,
|
|
850
|
+
rule: "c15",
|
|
744
851
|
severity: "error"
|
|
745
852
|
});
|
|
746
853
|
}
|
|
@@ -748,6 +855,767 @@ function checkLocalFixtureReach(rootDir) {
|
|
|
748
855
|
}
|
|
749
856
|
return violations;
|
|
750
857
|
}
|
|
858
|
+
/** The directive, whatever precedes it on the line, and what follows the ids. */
|
|
859
|
+
const DIRECTIVE = /(?<lead>.*?)checker-disable(?:-next-line|-line)\s+(?<rest>.*)$/iu;
|
|
860
|
+
/**
|
|
861
|
+
* A directive is written in a COMMENT — the word inside a string is a test
|
|
862
|
+
* TALKING about suppressions, which is what this package's own suite does.
|
|
863
|
+
*/
|
|
864
|
+
const COMMENT_LEAD = /(?:^|\s)(?:\/\/|\/\*|\*|#)\s*$/u;
|
|
865
|
+
/** The files a directive can be written in — source, documents, goldens. */
|
|
866
|
+
const READABLE = /\.(?:[cm]?[jt]sx?|ya?ml|json|md|sql|http|txt)$/u;
|
|
867
|
+
/** Is this a file a human writes a directive in? */
|
|
868
|
+
function isReadableText(path) {
|
|
869
|
+
return READABLE.test(path);
|
|
870
|
+
}
|
|
871
|
+
/** A reason: the `--` separator with something after it. */
|
|
872
|
+
const REASON = /--\s*\S/u;
|
|
873
|
+
/**
|
|
874
|
+
* J9 — every checker suppression says WHY.
|
|
875
|
+
*
|
|
876
|
+
* A suppression with no reason is a rule turned off by someone who is no longer
|
|
877
|
+
* here: the next reader cannot tell whether the pass was wrong, the shape was
|
|
878
|
+
* exceptional, or it was simply in the way — so the line survives every review
|
|
879
|
+
* and the convention quietly stops holding. The toolchain's own
|
|
880
|
+
* `suppressions-reason` gate reads `oxlint-disable*` and nothing else, which is
|
|
881
|
+
* why this channel carries its own.
|
|
882
|
+
*/
|
|
883
|
+
function checkSuppressionReason(rootDir) {
|
|
884
|
+
const violations = [];
|
|
885
|
+
for (const file of listFiles(rootDir, isReadableText)) {
|
|
886
|
+
const text = readText$1(file);
|
|
887
|
+
if (!text.includes("checker-disable")) continue;
|
|
888
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
889
|
+
const match = DIRECTIVE.exec(line);
|
|
890
|
+
if (match === null || !COMMENT_LEAD.test(match.groups?.lead ?? "") || REASON.test(match.groups?.rest ?? "")) continue;
|
|
891
|
+
const rel = relative(rootDir, file);
|
|
892
|
+
violations.push({
|
|
893
|
+
file: rel,
|
|
894
|
+
line: index + 1,
|
|
895
|
+
message: `${rel}:${index + 1}: say why: \`checker-disable-next-line <id> -- <reason>\` (J9 — see docs/19-linting.md)`,
|
|
896
|
+
rule: "j9-checker-suppression-reason",
|
|
897
|
+
severity: "error"
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return violations;
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/lint/checker-facets.ts
|
|
905
|
+
/**
|
|
906
|
+
* The FACET passes — what a tree says about itself, read from the tree.
|
|
907
|
+
*
|
|
908
|
+
* A statique rule sees one file; these three need the shape around it: which
|
|
909
|
+
* folder a file sits under, what the specification at that folder's root
|
|
910
|
+
* constructs, which ground lives below it, and who reads that ground. The four
|
|
911
|
+
* hold the one law the fork rests on — **folder = constructor** — from both
|
|
912
|
+
* sides: a facet folder has the runner its name promises (C20), a spec under it
|
|
913
|
+
* reaches that runner (C18), and ground one spec alone reads belongs to that
|
|
914
|
+
* spec (C21w).
|
|
915
|
+
*
|
|
916
|
+
* A first-level folder that is NOT a facet is a repository suite — it covers a
|
|
917
|
+
* tree rather than a product, C1's declared depth judges its shape, and none of
|
|
918
|
+
* these passes reaches it.
|
|
919
|
+
*/
|
|
920
|
+
/** The six facet folders — a first-level name under `specs/` that IS a constructor. */
|
|
921
|
+
const FACETS$1 = [
|
|
922
|
+
"api",
|
|
923
|
+
"cli",
|
|
924
|
+
"integration",
|
|
925
|
+
"jobs",
|
|
926
|
+
"mobile",
|
|
927
|
+
"website"
|
|
928
|
+
];
|
|
929
|
+
/**
|
|
930
|
+
* Directories no facet walk enters.
|
|
931
|
+
*
|
|
932
|
+
* The `$FIXTURES` pool is verbatim material — the reusable apps AND the trees
|
|
933
|
+
* that fail on purpose — never live specs, so every pass prunes it exactly as
|
|
934
|
+
* the token checker does.
|
|
935
|
+
*/
|
|
936
|
+
const SKIPPED$2 = /* @__PURE__ */ new Set([
|
|
937
|
+
".git",
|
|
938
|
+
"dist",
|
|
939
|
+
GROUND_FIXTURES,
|
|
940
|
+
"node_modules"
|
|
941
|
+
]);
|
|
942
|
+
/** `readdirSync` that answers `[]` for anything it cannot read. */
|
|
943
|
+
function entriesOf(dir) {
|
|
944
|
+
try {
|
|
945
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
946
|
+
} catch {
|
|
947
|
+
return [];
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
/** The specification files at a directory's own level. */
|
|
951
|
+
function specificationsIn(dir) {
|
|
952
|
+
return entriesOf(dir).filter((entry) => entry.isFile() && entry.name.includes(".specification.")).map((entry) => join(dir, entry.name));
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Every import SOURCE a file names — static, dynamic, and re-export.
|
|
956
|
+
*
|
|
957
|
+
* The source, not a substring of the file: `../website.specification` carries
|
|
958
|
+
* no extension, so a scan for `.specification.js` read a spec that reaches its
|
|
959
|
+
* runner as a module test parked in a facet tree, and told the author to move
|
|
960
|
+
* the file that specifies the product.
|
|
961
|
+
*/
|
|
962
|
+
const IMPORT_SOURCE = /(?:\bfrom|\bimport|\brequire)\s*\(?\s*['"](?<source>[^'"]+)['"]/gu;
|
|
963
|
+
/** The framework's own entry — a spec may construct the runner itself. */
|
|
964
|
+
const CONSTRUCTS = /\bspecification\.[a-z]+\s*\(/u;
|
|
965
|
+
/**
|
|
966
|
+
* Does this file reach the runner of the facet it sits under?
|
|
967
|
+
*
|
|
968
|
+
* Two shapes answer yes: importing the facet's `*.specification` module
|
|
969
|
+
* (however the import spells the extension), and constructing the runner in
|
|
970
|
+
* the file itself — a refusal spec proves the constructor that must FAIL to
|
|
971
|
+
* start, and has no specification module to import.
|
|
972
|
+
*/
|
|
973
|
+
function reachesRunner(text) {
|
|
974
|
+
for (const match of text.matchAll(IMPORT_SOURCE)) {
|
|
975
|
+
const source = match.groups?.source ?? "";
|
|
976
|
+
if (source === "@jterrazz/test" || source.startsWith("@jterrazz/test/")) return true;
|
|
977
|
+
if (source.slice(source.lastIndexOf("/") + 1).includes(".specification")) return true;
|
|
978
|
+
}
|
|
979
|
+
return CONSTRUCTS.test(text);
|
|
980
|
+
}
|
|
981
|
+
/** Read a file as text, or `null`. */
|
|
982
|
+
function textOf$1(path) {
|
|
983
|
+
try {
|
|
984
|
+
return readFileSync(path, "utf8");
|
|
985
|
+
} catch {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Every file under `dir`, depth-first.
|
|
991
|
+
*
|
|
992
|
+
* @yields each file path.
|
|
993
|
+
*/
|
|
994
|
+
function* walkFiles(dir) {
|
|
995
|
+
for (const entry of entriesOf(dir)) {
|
|
996
|
+
const path = join(dir, entry.name);
|
|
997
|
+
if (entry.isDirectory()) {
|
|
998
|
+
if (!SKIPPED$2.has(entry.name)) yield* walkFiles(path);
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
yield path;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/** The facet folders a specs root actually holds. */
|
|
1005
|
+
function facetFoldersOf(specsRoot) {
|
|
1006
|
+
return entriesOf(specsRoot).filter((entry) => entry.isDirectory() && FACETS$1.includes(entry.name)).map((entry) => ({
|
|
1007
|
+
facet: entry.name,
|
|
1008
|
+
path: join(specsRoot, entry.name)
|
|
1009
|
+
}));
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* C20 — a folder named for a facet holds the specification that constructs it.
|
|
1013
|
+
*
|
|
1014
|
+
* Positive-only: `specs/api/` promises `specification.api()`, and a reader who
|
|
1015
|
+
* opens it expecting a runner and finding none has been told something false by
|
|
1016
|
+
* the tree itself. Any OTHER first-level name is a repository suite and is
|
|
1017
|
+
* judged by C1's declared depth alone — the folder never has to be a facet.
|
|
1018
|
+
*/
|
|
1019
|
+
function checkFacetFolder(specsRoot) {
|
|
1020
|
+
const violations = [];
|
|
1021
|
+
for (const { facet, path } of facetFoldersOf(specsRoot)) {
|
|
1022
|
+
if (specificationsIn(path).some((file) => (textOf$1(file) ?? "").includes(`specification.${facet}(`))) continue;
|
|
1023
|
+
const rel = relative(specsRoot, path);
|
|
1024
|
+
violations.push({
|
|
1025
|
+
file: rel,
|
|
1026
|
+
line: 1,
|
|
1027
|
+
message: `${rel}:1: \`specs/${facet}/\` is a facet folder with no \`${facet}.specification.ts\` constructing \`specification.${facet}()\` — create it, or rename the folder (C20 — see docs/19-linting.md)`,
|
|
1028
|
+
rule: "c20-facet-folder",
|
|
1029
|
+
severity: "error"
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return violations;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* C18 — a spec under a facet folder reaches the runner that folder promises.
|
|
1036
|
+
*
|
|
1037
|
+
* A test file that imports neither the facet's specification module nor the
|
|
1038
|
+
* framework is a MODULE test parked in a facet tree: it runs under the facet's
|
|
1039
|
+
* project, pays its budget and its services, and proves something that belongs
|
|
1040
|
+
* beside the module it covers.
|
|
1041
|
+
*/
|
|
1042
|
+
function checkModuleTestUnderFacet(specsRoot) {
|
|
1043
|
+
const violations = [];
|
|
1044
|
+
for (const { facet, path } of facetFoldersOf(specsRoot)) {
|
|
1045
|
+
if (specificationsIn(path).length === 0) continue;
|
|
1046
|
+
for (const file of walkFiles(path)) {
|
|
1047
|
+
if (!isTestFileName(file.slice(file.lastIndexOf("/") + 1))) continue;
|
|
1048
|
+
if (reachesRunner(textOf$1(file) ?? "")) continue;
|
|
1049
|
+
const rel = relative(specsRoot, file);
|
|
1050
|
+
violations.push({
|
|
1051
|
+
file: rel,
|
|
1052
|
+
line: 1,
|
|
1053
|
+
message: `${rel}:1: reaches no runner under the \`${facet}\` facet: import \`{ ${facet} }\` from \`${facet}.specification.js\`, or move a module test beside its module (C18 — see docs/19-linting.md)`,
|
|
1054
|
+
rule: "c18-module-test-under-facet",
|
|
1055
|
+
severity: "error"
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
return violations;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Every directory under `dir`, depth-first.
|
|
1063
|
+
*
|
|
1064
|
+
* @yields each directory path.
|
|
1065
|
+
*/
|
|
1066
|
+
function* walkDirectories(dir) {
|
|
1067
|
+
for (const entry of entriesOf(dir)) {
|
|
1068
|
+
if (!entry.isDirectory() || SKIPPED$2.has(entry.name)) continue;
|
|
1069
|
+
const path = join(dir, entry.name);
|
|
1070
|
+
yield path;
|
|
1071
|
+
yield* walkDirectories(path);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/** The ground names a leaf can hold. */
|
|
1075
|
+
const GROUND = new Set(GROUND_DIRS);
|
|
1076
|
+
/**
|
|
1077
|
+
* A golden named by a TEMPLATE rather than by a literal — `toMatch(`${x}.json`)`.
|
|
1078
|
+
*
|
|
1079
|
+
* Such a test reads whatever the table hands it, so the names under the ground
|
|
1080
|
+
* appear nowhere in its source. It is a reader of every golden in its leaf,
|
|
1081
|
+
* and counting it as none made a shared `_expected/` read as one spec's own.
|
|
1082
|
+
*/
|
|
1083
|
+
const COMPUTED_GOLDEN = /\.toMatch\(\s*(?:`|[A-Za-z_$])/u;
|
|
1084
|
+
/**
|
|
1085
|
+
* C21w (warning) — ground a single spec reads belongs to that spec.
|
|
1086
|
+
*
|
|
1087
|
+
* A leaf holding several specs and one `_expected/` that only one of them
|
|
1088
|
+
* names reads as shared material: the next author assumes someone else depends
|
|
1089
|
+
* on it and nobody dares touch it. The fix is C1's — the spec that stands on
|
|
1090
|
+
* its own ground earns its own domain folder.
|
|
1091
|
+
*/
|
|
1092
|
+
function checkGroundOwnedByOne(specsRoot) {
|
|
1093
|
+
const violations = [];
|
|
1094
|
+
for (const directory of [specsRoot, ...walkDirectories(specsRoot)]) {
|
|
1095
|
+
const entries = entriesOf(directory);
|
|
1096
|
+
const tests = entries.filter((entry) => entry.isFile() && isNodeTestFileName(entry.name)).map((entry) => join(directory, entry.name));
|
|
1097
|
+
if (tests.length < 2) continue;
|
|
1098
|
+
const grounds = entries.filter((entry) => entry.isDirectory() && GROUND.has(entry.name)).map((entry) => entry.name);
|
|
1099
|
+
for (const ground of grounds) {
|
|
1100
|
+
const names = [...walkFiles(join(directory, ground))].map((file) => file.slice(file.lastIndexOf("/") + 1));
|
|
1101
|
+
if (names.length === 0) continue;
|
|
1102
|
+
const readers = tests.filter((test) => {
|
|
1103
|
+
const text = textOf$1(test) ?? "";
|
|
1104
|
+
return names.some((name) => text.includes(name)) || text.includes(`${ground}/`) || COMPUTED_GOLDEN.test(text);
|
|
1105
|
+
});
|
|
1106
|
+
if (readers.length !== 1) continue;
|
|
1107
|
+
const reader = readers[0] ?? "";
|
|
1108
|
+
const rel = relative(specsRoot, join(directory, ground));
|
|
1109
|
+
violations.push({
|
|
1110
|
+
file: rel,
|
|
1111
|
+
line: 1,
|
|
1112
|
+
message: `${rel}:1: \`${ground}/\` here is read by \`${relative(directory, reader)}\` alone — give it its own domain folder (C21 — see docs/19-linting.md)`,
|
|
1113
|
+
rule: "c21w-ground-owned-by-one",
|
|
1114
|
+
severity: "warn"
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return violations;
|
|
1119
|
+
}
|
|
1120
|
+
//#endregion
|
|
1121
|
+
//#region src/lint/checker-placement.ts
|
|
1122
|
+
/**
|
|
1123
|
+
* C12 placement — the suffix says the kind, so the tree has to agree with it.
|
|
1124
|
+
*
|
|
1125
|
+
* `.spec.ts` is the ASSEMBLED product's word: the app met through HTTP, a
|
|
1126
|
+
* binary run, a page served, a module put against real services. It lives under
|
|
1127
|
+
* `specs/<facet>/`. `.test.ts` is the UNIT's word: it sits beside the module it
|
|
1128
|
+
* covers. A file wearing one word in the other's place is the one shape that
|
|
1129
|
+
* makes the fork unreadable — a reader cannot tell what a test is FOR without
|
|
1130
|
+
* opening it — so both directions are errors with a mechanical fix, and the
|
|
1131
|
+
* checker's `--fix` performs the move.
|
|
1132
|
+
*
|
|
1133
|
+
* Two clauses, and each runs where it can see what it judges:
|
|
1134
|
+
*
|
|
1135
|
+
* - a `.spec.ts` with no `specs/` ancestor — the MEMBER pass, which walks a
|
|
1136
|
+
* package and is the only run that sees a file outside a specs tree. The
|
|
1137
|
+
* ancestor is read by the one anchor every other pass reads (`specsAnchor`),
|
|
1138
|
+
* so a member whose own root IS the specs tree — a workspace declaring
|
|
1139
|
+
* `packages: ['specs']` — holds specs, not strays;
|
|
1140
|
+
* - a `.test.ts` under `specs/<facet>/` where `<facet>` is one of the six
|
|
1141
|
+
* constructors — the TREE pass. A first-level folder that is NOT a facet
|
|
1142
|
+
* (the package's own `specs/lint/`, a repository's consistency suites) is a
|
|
1143
|
+
* repository suite: it covers a tree rather than the assembled product, so
|
|
1144
|
+
* it keeps `.test.ts` and C1's declared depth is what judges its shape.
|
|
1145
|
+
*/
|
|
1146
|
+
/** The six facet folders — a first-level name under `specs/` that IS a constructor. */
|
|
1147
|
+
const FACETS = /* @__PURE__ */ new Set([
|
|
1148
|
+
"api",
|
|
1149
|
+
"cli",
|
|
1150
|
+
"integration",
|
|
1151
|
+
"jobs",
|
|
1152
|
+
"mobile",
|
|
1153
|
+
"website"
|
|
1154
|
+
]);
|
|
1155
|
+
/** Directories no placement walk enters. */
|
|
1156
|
+
const SKIPPED$1 = /* @__PURE__ */ new Set([
|
|
1157
|
+
".git",
|
|
1158
|
+
"dist",
|
|
1159
|
+
"node_modules"
|
|
1160
|
+
]);
|
|
1161
|
+
/**
|
|
1162
|
+
* Every file under `dir`, depth-first.
|
|
1163
|
+
*
|
|
1164
|
+
* `stopAtPackages` bounds the walk at the next `package.json`: the member pass
|
|
1165
|
+
* judges ONE member, and a nested member is judged by its own run — without the
|
|
1166
|
+
* bound, a workspace root reported every finding of every package below it and
|
|
1167
|
+
* the same file was named twice.
|
|
1168
|
+
*
|
|
1169
|
+
* @yields every file path under `dir`, depth-first.
|
|
1170
|
+
*/
|
|
1171
|
+
function* walk(dir, stopAtPackages = false) {
|
|
1172
|
+
let entries;
|
|
1173
|
+
try {
|
|
1174
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1175
|
+
} catch {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
for (const entry of entries) {
|
|
1179
|
+
const path = join(dir, entry.name);
|
|
1180
|
+
if (entry.isDirectory()) {
|
|
1181
|
+
if (SKIPPED$1.has(entry.name)) continue;
|
|
1182
|
+
if (stopAtPackages && existsSync(join(path, "package.json"))) continue;
|
|
1183
|
+
yield* walk(path, stopAtPackages);
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
yield path;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
/** Read a file as text, or the empty string when it cannot be read. */
|
|
1190
|
+
function textOf(path) {
|
|
1191
|
+
try {
|
|
1192
|
+
return readFileSync(path, "utf8");
|
|
1193
|
+
} catch {
|
|
1194
|
+
return "";
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Clause one — a `.spec.ts` that sits outside every `specs/` tree.
|
|
1199
|
+
*
|
|
1200
|
+
* The facet cannot be guessed (only the author knows whether the file meets the
|
|
1201
|
+
* product through HTTP or through a binary), so this clause reports and the
|
|
1202
|
+
* mover leaves it alone: `to` is `undefined` and the fix line names the shape.
|
|
1203
|
+
*/
|
|
1204
|
+
function checkSpecOutsideSpecs(memberDir) {
|
|
1205
|
+
const violations = [];
|
|
1206
|
+
for (const path of walk(memberDir, true)) {
|
|
1207
|
+
if (!path.endsWith(".spec.ts")) continue;
|
|
1208
|
+
if (isUnderSpecs(path)) continue;
|
|
1209
|
+
const rel = relative(memberDir, path);
|
|
1210
|
+
violations.push({
|
|
1211
|
+
file: rel,
|
|
1212
|
+
line: 1,
|
|
1213
|
+
message: `${rel}:1: a \`.spec.ts\` lives under \`specs/<facet>/\` — move it there, or rename it \`.test.ts\` beside the module it covers (C12 — see docs/19-linting.md)`,
|
|
1214
|
+
rule: "c12-spec-file-name",
|
|
1215
|
+
severity: "error"
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
return violations;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Clause two — a `.test.ts` under a facet folder of a `specs/` tree.
|
|
1222
|
+
*
|
|
1223
|
+
* The fix is mechanical and the mover performs it: the facet folder already
|
|
1224
|
+
* says what the file specifies, so the only thing missing is the word.
|
|
1225
|
+
*
|
|
1226
|
+
* A file that reaches no runner is left to C18, whose fix is the opposite move
|
|
1227
|
+
* — one file, one finding, and `--fix` never renames a module test into a spec.
|
|
1228
|
+
*/
|
|
1229
|
+
function checkTestUnderFacet(specsRoot) {
|
|
1230
|
+
return movesUnderFacet(specsRoot).map(({ from }) => {
|
|
1231
|
+
const rel = relative(specsRoot, from);
|
|
1232
|
+
return {
|
|
1233
|
+
file: rel,
|
|
1234
|
+
line: 1,
|
|
1235
|
+
message: `${rel}:1: a \`.test.ts\` under \`specs/${rel.split(/[/\\]/u)[0] ?? ""}/\` specifies the assembled product — rename it \`.spec.ts\` (fixable: \`jterrazz-test-check <root> --fix\`) (C12 — see docs/19-linting.md)`,
|
|
1236
|
+
rule: "c12-spec-file-name",
|
|
1237
|
+
severity: "error"
|
|
1238
|
+
};
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
/** The renames clause two asks for, as absolute from/to pairs. */
|
|
1242
|
+
function movesUnderFacet(specsRoot) {
|
|
1243
|
+
const moves = [];
|
|
1244
|
+
for (const path of walk(specsRoot)) {
|
|
1245
|
+
if (!path.endsWith(".test.ts")) continue;
|
|
1246
|
+
const parts = relative(specsRoot, path).split(/[/\\]/u);
|
|
1247
|
+
const facet = parts[0];
|
|
1248
|
+
if (parts.length < 2 || facet === void 0 || !FACETS.has(facet)) continue;
|
|
1249
|
+
if (parts.some((part) => part.startsWith("_"))) continue;
|
|
1250
|
+
if (!reachesRunner(textOf(path))) continue;
|
|
1251
|
+
moves.push({
|
|
1252
|
+
fix: "rename to `.spec.ts`",
|
|
1253
|
+
from: path,
|
|
1254
|
+
to: `${path.slice(0, -8)}.spec.ts`
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
return moves;
|
|
1258
|
+
}
|
|
1259
|
+
//#endregion
|
|
1260
|
+
//#region src/lint/checker-member.ts
|
|
1261
|
+
/**
|
|
1262
|
+
* The MEMBER pass — what a workspace member owes, read from the member itself
|
|
1263
|
+
* rather than from a spec tree.
|
|
1264
|
+
*
|
|
1265
|
+
* The three findings here answer questions no file under `specs/` can: does
|
|
1266
|
+
* this member have a vitest config at all, does its config give every test it
|
|
1267
|
+
* collects a drawing of a browser, and does it declare a dependency the
|
|
1268
|
+
* framework already carries. A member with no `specs/` root is still judged —
|
|
1269
|
+
* that is the whole point, since the first of the three is about a member that
|
|
1270
|
+
* has tests and no configuration for them.
|
|
1271
|
+
*
|
|
1272
|
+
* The pass is the toolchain's door: `jterrazz-test-check --member <dir>` runs
|
|
1273
|
+
* it for one member, and a path-less `--format json` run at the project root
|
|
1274
|
+
* runs it for every member the root manifest declares.
|
|
1275
|
+
*/
|
|
1276
|
+
/** The two simulated DOMs — the same pair the statique E5b refuses. */
|
|
1277
|
+
const SIMULATED_DOM = /\benvironment\s*:\s*['"](?<dom>happy-dom|jsdom)['"]/u;
|
|
1278
|
+
/**
|
|
1279
|
+
* Dependencies `@jterrazz/test` carries. A member declaring one of these
|
|
1280
|
+
* duplicates a transitive it already resolves. Optional peers are not here:
|
|
1281
|
+
* `playwright`, `vite`, `react`, `better-sqlite3` and their kin are declared
|
|
1282
|
+
* BY the consumer on purpose.
|
|
1283
|
+
*/
|
|
1284
|
+
const CARRIED = /* @__PURE__ */ new Set(["msw", "vitest-mock-extended"]);
|
|
1285
|
+
/**
|
|
1286
|
+
* Carried, but only where it can only be the test seam's. `yaml` is a product
|
|
1287
|
+
* library as much as a fixture reader: a member that parses YAML at RUNTIME
|
|
1288
|
+
* declares it rightly, and a rule that read `dependencies` here would be
|
|
1289
|
+
* telling a product to drop a library the framework never ships to it. So the
|
|
1290
|
+
* finding is limited to the one declaration that can only mean the seam.
|
|
1291
|
+
*/
|
|
1292
|
+
const CARRIED_IN_DEV = /* @__PURE__ */ new Set(["yaml"]);
|
|
1293
|
+
/**
|
|
1294
|
+
* The framework itself. It DECLARES what every other member inherits, so the
|
|
1295
|
+
* one manifest F8 must never read is its own — a rule that fired on the
|
|
1296
|
+
* package carrying the dependency would be asking it to remove the thing it
|
|
1297
|
+
* exists to provide.
|
|
1298
|
+
*/
|
|
1299
|
+
const FRAMEWORK = "@jterrazz/test";
|
|
1300
|
+
const RETIRED = /* @__PURE__ */ new Set([
|
|
1301
|
+
"@playwright/test",
|
|
1302
|
+
"happy-dom",
|
|
1303
|
+
"jsdom",
|
|
1304
|
+
"mockdate"
|
|
1305
|
+
]);
|
|
1306
|
+
const RETIRED_SCOPES = ["@testing-library/"];
|
|
1307
|
+
/**
|
|
1308
|
+
* The one retired seam a member may still declare, and what makes it allowed.
|
|
1309
|
+
*
|
|
1310
|
+
* React Native renders under jest until the react-native-web answer lands, and
|
|
1311
|
+
* where jest is the sanctioned runner `@testing-library/react-native` is the
|
|
1312
|
+
* only vocabulary there is: the rule would be asking for a facet that does not
|
|
1313
|
+
* reach that runtime yet. The allowance is read from the member itself — a
|
|
1314
|
+
* declared `jest`, a `jest` field, or a `test` script that calls it — so it
|
|
1315
|
+
* lapses the day the member stops running jest.
|
|
1316
|
+
*/
|
|
1317
|
+
const JEST_ONLY_SEAM = "@testing-library/react-native";
|
|
1318
|
+
/** Does this member run jest? */
|
|
1319
|
+
function runsJest(manifest) {
|
|
1320
|
+
const declares = (name) => name in (manifest.dependencies ?? {}) || name in (manifest.devDependencies ?? {});
|
|
1321
|
+
return declares("jest") || declares("jest-expo") || manifest.jest !== void 0 || /(?:^|[\s/])jest(?:\s|$)/u.test(manifest.scripts?.test ?? "");
|
|
1322
|
+
}
|
|
1323
|
+
/** Where a member states what it collects, whatever extension it writes it in. */
|
|
1324
|
+
const CONFIG_NAMES$1 = [
|
|
1325
|
+
"vitest.config.ts",
|
|
1326
|
+
"vitest.config.mts",
|
|
1327
|
+
"vitest.config.cts",
|
|
1328
|
+
"vitest.config.js",
|
|
1329
|
+
"vitest.config.mjs",
|
|
1330
|
+
"vitest.config.cjs"
|
|
1331
|
+
];
|
|
1332
|
+
/** Directories the member walk never enters. */
|
|
1333
|
+
const SKIPPED = /* @__PURE__ */ new Set([
|
|
1334
|
+
".git",
|
|
1335
|
+
".artifacts",
|
|
1336
|
+
"coverage",
|
|
1337
|
+
"dist",
|
|
1338
|
+
"node_modules"
|
|
1339
|
+
]);
|
|
1340
|
+
/** A member's manifest, or null when it has none (it is then not a member). */
|
|
1341
|
+
function readManifest(dir) {
|
|
1342
|
+
const path = join(dir, "package.json");
|
|
1343
|
+
if (!existsSync(path)) return null;
|
|
1344
|
+
try {
|
|
1345
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1346
|
+
} catch {
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
/** The member's vitest config, by path, or null. */
|
|
1351
|
+
function configOf(dir) {
|
|
1352
|
+
for (const name of CONFIG_NAMES$1) {
|
|
1353
|
+
const path = join(dir, name);
|
|
1354
|
+
if (existsSync(path)) return path;
|
|
1355
|
+
}
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Does this member hold a test file it OWNS?
|
|
1360
|
+
*
|
|
1361
|
+
* A directory carrying its own `package.json` is another package's tree — a
|
|
1362
|
+
* workspace member, a vendored clone — and its tests are its own to configure.
|
|
1363
|
+
* A root that only delegates would otherwise be told to write a config for
|
|
1364
|
+
* files it never collects, which is the finding read backwards.
|
|
1365
|
+
*/
|
|
1366
|
+
function holdsTests(dir) {
|
|
1367
|
+
const stack = [dir];
|
|
1368
|
+
let first = true;
|
|
1369
|
+
while (stack.length > 0) {
|
|
1370
|
+
const current = stack.pop();
|
|
1371
|
+
if (current === void 0) break;
|
|
1372
|
+
if (!first && existsSync(join(current, "package.json"))) continue;
|
|
1373
|
+
first = false;
|
|
1374
|
+
let entries;
|
|
1375
|
+
try {
|
|
1376
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
1377
|
+
} catch {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
for (const entry of entries) {
|
|
1381
|
+
if (entry.isDirectory()) {
|
|
1382
|
+
if (!SKIPPED.has(entry.name) && !entry.name.startsWith(".")) stack.push(join(current, entry.name));
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
if (isTestFileName(entry.name)) return true;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return false;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* A `test` script that only hands the work to the members below it. It is not
|
|
1392
|
+
* evidence that THIS package runs vitest — `npm run test --workspaces`,
|
|
1393
|
+
* `pnpm -r test` and `turbo run test` all read as "ask the members".
|
|
1394
|
+
*/
|
|
1395
|
+
const DELEGATING_TEST = /--workspaces|(?:^|\s)-r(?:\s|$)|--recursive|turbo\s+run|nx\s+run-many|lerna\s+run/u;
|
|
1396
|
+
/** Every seam name this member declares that it should not, and why it should not. */
|
|
1397
|
+
function declaredSeams(manifest) {
|
|
1398
|
+
if (manifest.name === FRAMEWORK) return [];
|
|
1399
|
+
const jestRuns = runsJest(manifest);
|
|
1400
|
+
const isRetired = (name) => {
|
|
1401
|
+
if (name === JEST_ONLY_SEAM && jestRuns) return false;
|
|
1402
|
+
return RETIRED.has(name) || RETIRED_SCOPES.some((scope) => name.startsWith(scope));
|
|
1403
|
+
};
|
|
1404
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(manifest.dependencies ?? {}).filter((name) => CARRIED.has(name) || isRetired(name)), ...Object.keys(manifest.devDependencies ?? {}).filter((name) => CARRIED.has(name) || CARRIED_IN_DEV.has(name) || isRetired(name))])].toSorted().map((name) => ({
|
|
1405
|
+
kind: isRetired(name) ? "retired" : "carried",
|
|
1406
|
+
name
|
|
1407
|
+
}));
|
|
1408
|
+
}
|
|
1409
|
+
/** The line a manifest key is written on — a finding points at the declaration, not at `{`. */
|
|
1410
|
+
function lineOfKey(dir, key) {
|
|
1411
|
+
try {
|
|
1412
|
+
const found = readFileSync(join(dir, "package.json"), "utf8").split("\n").findIndex((line) => line.includes(`"${key}"`));
|
|
1413
|
+
return found === -1 ? 1 : found + 1;
|
|
1414
|
+
} catch {
|
|
1415
|
+
return 1;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
|
|
1420
|
+
* anchors the paths the findings report, so a member's finding reads the same
|
|
1421
|
+
* whether the pass ran over it alone or over the whole workspace.
|
|
1422
|
+
*/
|
|
1423
|
+
/** E3 — a member with tests of its own states how they run. */
|
|
1424
|
+
function configPresent(manifest, memberDir, label, subject) {
|
|
1425
|
+
const testScript = manifest.scripts?.test;
|
|
1426
|
+
if (!(typeof testScript === "string" && !DELEGATING_TEST.test(testScript) || holdsTests(memberDir))) return [];
|
|
1427
|
+
return [{
|
|
1428
|
+
file: join(label, "package.json"),
|
|
1429
|
+
line: 1,
|
|
1430
|
+
message: `${subject}: no vitest.config.ts — vitest's 5 s budget and an unexcluded \`_fixtures/\` are running your tests; write \`export default defineSpecConfig()\` (E3 — see docs/19-linting.md#e3-config-present)`,
|
|
1431
|
+
rule: "e3",
|
|
1432
|
+
severity: "error"
|
|
1433
|
+
}];
|
|
1434
|
+
}
|
|
1435
|
+
/** E5b — a config that gives every file it collects a drawing of a browser. */
|
|
1436
|
+
function simulatedDom(config, rootDir) {
|
|
1437
|
+
const text = readFileSync(config, "utf8");
|
|
1438
|
+
const found = SIMULATED_DOM.exec(text);
|
|
1439
|
+
if (found?.groups?.dom === void 0) return [];
|
|
1440
|
+
const relConfig = relative(rootDir, config);
|
|
1441
|
+
return [{
|
|
1442
|
+
file: relConfig,
|
|
1443
|
+
line: text.slice(0, found.index).split("\n").length,
|
|
1444
|
+
message: `${relConfig}: \`environment: '${found.groups.dom}'\` gives every file of this project a drawing of a browser — a rendered thing is a \`.test.tsx\` beside its component, collected by \`component()\` (E5b — see docs/19-linting.md#e5b-no-simulated-dom-config-member)`,
|
|
1445
|
+
rule: "e5b",
|
|
1446
|
+
severity: "error"
|
|
1447
|
+
}];
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* F8 — a dependency the framework already carries, or a seam it replaced.
|
|
1451
|
+
*
|
|
1452
|
+
* The two are not the same sentence: removing a transitive changes nothing a
|
|
1453
|
+
* test can see, while dropping a retired seam means writing its facet.
|
|
1454
|
+
*/
|
|
1455
|
+
function seamDependencies(manifest, memberDir, label, subject) {
|
|
1456
|
+
return declaredSeams(manifest).map((seam) => ({
|
|
1457
|
+
file: join(label, "package.json"),
|
|
1458
|
+
line: lineOfKey(memberDir, seam.name),
|
|
1459
|
+
message: seam.kind === "carried" ? `${subject}: \`${seam.name}\` is a transitive of \`@jterrazz/test\` — remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)` : `${subject}: \`${seam.name}\` is a seam \`@jterrazz/test\` replaced — the facet is \`component()\`, \`website()\`, \`clock\` or \`intercept()\`; remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)`,
|
|
1460
|
+
rule: "f8",
|
|
1461
|
+
severity: "error"
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Re-anchor a member-relative finding on the root the run reports against, so
|
|
1466
|
+
* one member's path reads the same alone as inside a whole-workspace run.
|
|
1467
|
+
*/
|
|
1468
|
+
function anchoredToRoot(violations, label) {
|
|
1469
|
+
if (label === ".") return violations;
|
|
1470
|
+
const anchored = [];
|
|
1471
|
+
for (const violation of violations) anchored.push({
|
|
1472
|
+
...violation,
|
|
1473
|
+
file: join(label, violation.file)
|
|
1474
|
+
});
|
|
1475
|
+
return anchored;
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
|
|
1479
|
+
* anchors the paths the findings report, so a member's finding reads the same
|
|
1480
|
+
* whether the pass ran over it alone or over the whole workspace.
|
|
1481
|
+
*/
|
|
1482
|
+
function checkMember(memberDir, rootDir) {
|
|
1483
|
+
const manifest = readManifest(memberDir);
|
|
1484
|
+
if (manifest === null) return [];
|
|
1485
|
+
const label = relative(rootDir, memberDir) || ".";
|
|
1486
|
+
const subject = label === "." ? manifest.name ?? basename(memberDir) : label;
|
|
1487
|
+
const config = configOf(memberDir);
|
|
1488
|
+
return [
|
|
1489
|
+
...config === null ? configPresent(manifest, memberDir, label, subject) : [],
|
|
1490
|
+
...config === null ? [] : simulatedDom(config, rootDir),
|
|
1491
|
+
...seamDependencies(manifest, memberDir, label, subject),
|
|
1492
|
+
...anchoredToRoot(checkSpecOutsideSpecs(memberDir), label)
|
|
1493
|
+
];
|
|
1494
|
+
}
|
|
1495
|
+
/** The glob-ish workspace patterns a root manifest declares. */
|
|
1496
|
+
function workspacePatterns(manifest) {
|
|
1497
|
+
const { workspaces } = manifest;
|
|
1498
|
+
if (Array.isArray(workspaces)) return workspaces;
|
|
1499
|
+
return workspaces?.packages ?? [];
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* A glob over path SEGMENTS — `*` stops at a separator, `**` does not. The
|
|
1503
|
+
* same translation `@jterrazz/typescript`'s `lib/workspace-members.js` makes,
|
|
1504
|
+
* because the two lists have to be the same list: a member the toolchain runs
|
|
1505
|
+
* `--member` over and the path-less run never discovers is a finding the
|
|
1506
|
+
* ratchet can neither record nor clear.
|
|
1507
|
+
*/
|
|
1508
|
+
function toPattern(glob) {
|
|
1509
|
+
const escaped = glob.replaceAll(/[.+^${}()|[\]\\]/gu, String.raw`\$&`).replaceAll("**", " ").replaceAll("*", "[^/]*").replaceAll(" ", ".*");
|
|
1510
|
+
return new RegExp(`^${escaped}$`, "u");
|
|
1511
|
+
}
|
|
1512
|
+
/** How deep a member may sit below the root — the toolchain's bound, to the segment. */
|
|
1513
|
+
const MEMBER_DEPTH = 6;
|
|
1514
|
+
/**
|
|
1515
|
+
* Every workspace member declared by the root manifest, plus the root itself —
|
|
1516
|
+
* a single-package repository IS its own member, and the pass has to reach it.
|
|
1517
|
+
*/
|
|
1518
|
+
function discoverMembers(rootDir) {
|
|
1519
|
+
const manifest = readManifest(rootDir);
|
|
1520
|
+
if (manifest === null) return [];
|
|
1521
|
+
const patterns = workspacePatterns(manifest).filter((glob) => typeof glob === "string" && !glob.startsWith("!")).map((glob) => toPattern(glob.replace(/\/+$/u, "")));
|
|
1522
|
+
const members = /* @__PURE__ */ new Set([rootDir]);
|
|
1523
|
+
if (patterns.length === 0) return [...members];
|
|
1524
|
+
const walk = (dir, depth) => {
|
|
1525
|
+
if (depth > MEMBER_DEPTH) return;
|
|
1526
|
+
let entries;
|
|
1527
|
+
try {
|
|
1528
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1529
|
+
} catch {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
for (const entry of entries) {
|
|
1533
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED.has(entry.name)) continue;
|
|
1534
|
+
const child = join(dir, entry.name);
|
|
1535
|
+
const path = relative(rootDir, child).split(sep).join("/");
|
|
1536
|
+
if (patterns.some((pattern) => pattern.test(path)) && readManifest(child) !== null) {
|
|
1537
|
+
members.add(child);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
walk(child, depth + 1);
|
|
1541
|
+
}
|
|
1542
|
+
};
|
|
1543
|
+
walk(rootDir, 0);
|
|
1544
|
+
return [...members];
|
|
1545
|
+
}
|
|
1546
|
+
/** Paths git is told to ignore — the toolchain skips them, so the two lists agree. */
|
|
1547
|
+
function ignoredPaths(rootDir, candidates) {
|
|
1548
|
+
if (candidates.length === 0) return /* @__PURE__ */ new Set();
|
|
1549
|
+
try {
|
|
1550
|
+
const answer = spawnSync("git", ["check-ignore", "--stdin"], {
|
|
1551
|
+
cwd: rootDir,
|
|
1552
|
+
encoding: "utf8",
|
|
1553
|
+
input: `${candidates.join("\n")}\n`
|
|
1554
|
+
});
|
|
1555
|
+
if (answer.error !== void 0 || answer.stdout === "") return /* @__PURE__ */ new Set();
|
|
1556
|
+
return new Set(answer.stdout.split("\n").filter(Boolean));
|
|
1557
|
+
} catch {
|
|
1558
|
+
return /* @__PURE__ */ new Set();
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/** How deep below a member a nested `specs/` may sit before the walk gives up. */
|
|
1562
|
+
const SPECS_DEPTH = 6;
|
|
1563
|
+
/**
|
|
1564
|
+
* Every `specs/` root the tree passes have to walk: the project's own, and
|
|
1565
|
+
* each member's — including a member that NESTS its facet tree (`web/specs`).
|
|
1566
|
+
* Stated here rather than guessed by the walk, so a path-less run reports
|
|
1567
|
+
* exactly what a per-root run would.
|
|
1568
|
+
*
|
|
1569
|
+
* A `specs/` tree is never descended into (the fixtures under it are not spec
|
|
1570
|
+
* roots), and neither is another package's tree: a directory carrying its own
|
|
1571
|
+
* `package.json` belongs to whichever member declares it. A member DIRECTORY
|
|
1572
|
+
* named `specs` is a root of its own — the tree can be the member.
|
|
1573
|
+
*/
|
|
1574
|
+
function discoverSpecRoots(rootDir) {
|
|
1575
|
+
const roots = /* @__PURE__ */ new Set();
|
|
1576
|
+
for (const member of discoverMembers(rootDir)) {
|
|
1577
|
+
if (basename(member) === "specs") roots.add(member);
|
|
1578
|
+
const walk = (dir, depth) => {
|
|
1579
|
+
if (depth > SPECS_DEPTH) return;
|
|
1580
|
+
let entries;
|
|
1581
|
+
try {
|
|
1582
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1583
|
+
} catch {
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
for (const entry of entries) {
|
|
1587
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED.has(entry.name)) continue;
|
|
1588
|
+
const child = join(dir, entry.name);
|
|
1589
|
+
if (entry.name === "specs") {
|
|
1590
|
+
roots.add(child);
|
|
1591
|
+
continue;
|
|
1592
|
+
}
|
|
1593
|
+
if (readManifest(child) !== null) continue;
|
|
1594
|
+
walk(child, depth + 1);
|
|
1595
|
+
}
|
|
1596
|
+
};
|
|
1597
|
+
walk(member, 0);
|
|
1598
|
+
}
|
|
1599
|
+
const found = [...roots].toSorted();
|
|
1600
|
+
const ignored = ignoredPaths(rootDir, found.map((path) => relative(rootDir, path)));
|
|
1601
|
+
return found.filter((path) => !ignored.has(relative(rootDir, path)));
|
|
1602
|
+
}
|
|
1603
|
+
/** Run the member pass over every member the root declares. */
|
|
1604
|
+
function checkMembers(rootDir) {
|
|
1605
|
+
return discoverMembers(rootDir).flatMap((member) => checkMember(member, rootDir));
|
|
1606
|
+
}
|
|
1607
|
+
//#endregion
|
|
1608
|
+
//#region src/lint/rule-code.ts
|
|
1609
|
+
/**
|
|
1610
|
+
* The convention code a checker pass id carries: `d4b-spec-shape` → `d4b`.
|
|
1611
|
+
*
|
|
1612
|
+
* It lives alone because BOTH sides need it — the passes that build a finding
|
|
1613
|
+
* and the entry that publishes one as `jterrazz-check(<code>)` — and a copy in
|
|
1614
|
+
* either would be a second answer to what a finding is called.
|
|
1615
|
+
*/
|
|
1616
|
+
function codeOf(passId) {
|
|
1617
|
+
return passId.split("-")[0] ?? passId;
|
|
1618
|
+
}
|
|
751
1619
|
//#endregion
|
|
752
1620
|
//#region src/lint/checker-spec.ts
|
|
753
1621
|
function finding(rule, line, message, severity = "error") {
|
|
@@ -948,27 +1816,99 @@ const VOLATILE = [
|
|
|
948
1816
|
}
|
|
949
1817
|
];
|
|
950
1818
|
/**
|
|
1819
|
+
* The volatile literal on this line, if it carries one — the reader BOTH halves
|
|
1820
|
+
* of d5's reach use, so a document's expected stream and a file under
|
|
1821
|
+
* `_expected/` are judged by one list.
|
|
1822
|
+
*
|
|
1823
|
+
* A `{{token}}` is removed before the test: the token IS the answer the rule
|
|
1824
|
+
* asks for, and a path written after one is the token's tail, not a literal.
|
|
1825
|
+
*/
|
|
1826
|
+
/**
|
|
1827
|
+
* The whole word the match sits inside — the string the pinned-value carve-out
|
|
1828
|
+
* looks up.
|
|
1829
|
+
*
|
|
1830
|
+
* A pattern matches the SHAPE that makes a value volatile (`/tmp/`, a loopback
|
|
1831
|
+
* origin) and not the value itself, so asking whether the document pinned
|
|
1832
|
+
* `/tmp/` would exempt every temp path once any of them was named. The word the
|
|
1833
|
+
* match belongs to is what the document either states or does not.
|
|
1834
|
+
*/
|
|
1835
|
+
function wordAround(text, index) {
|
|
1836
|
+
let start = index;
|
|
1837
|
+
let end = index;
|
|
1838
|
+
while (start > 0 && !/[\s"'(),;=]/u.test(text[start - 1] ?? "")) start -= 1;
|
|
1839
|
+
while (end < text.length && !/[\s"'(),;=]/u.test(text[end] ?? "")) end += 1;
|
|
1840
|
+
return text.slice(start, end);
|
|
1841
|
+
}
|
|
1842
|
+
function volatileLiteralIn(line) {
|
|
1843
|
+
const bare = line.replaceAll(/\{\{[^}]*\}\}/gu, " ");
|
|
1844
|
+
for (const { pattern, token } of VOLATILE) {
|
|
1845
|
+
const found = pattern.exec(bare);
|
|
1846
|
+
if (found !== null) {
|
|
1847
|
+
const at = found.index + found[0].length - found[0].trimStart().length;
|
|
1848
|
+
return {
|
|
1849
|
+
found: found[0].trim(),
|
|
1850
|
+
token,
|
|
1851
|
+
word: wordAround(bare, at)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
951
1857
|
* A stream that pins a loopback port, a temp directory or somebody's home is a
|
|
952
1858
|
* golden that passed once, on one machine: the port was free that second, the
|
|
953
1859
|
* temp path belonged to that run. It is the exact shape of a value the update
|
|
954
1860
|
* writer would have tokenised, which is why finding one means a token was
|
|
955
1861
|
* overwritten by hand.
|
|
1862
|
+
*
|
|
1863
|
+
* Unless the document FIXED it — the same carve-out d5w rests on, for the same
|
|
1864
|
+
* reason: a path the command was handed, or one a `fixture:` ships, is a value
|
|
1865
|
+
* the document states rather than one the run minted.
|
|
956
1866
|
*/
|
|
957
|
-
function checkVolatileLiterals(document) {
|
|
1867
|
+
function checkVolatileLiterals(document, dir) {
|
|
958
1868
|
const findings = [];
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1869
|
+
let fixed = null;
|
|
1870
|
+
for (const stream of assertedStreams(document)) for (const [index, text] of stream.text.split("\n").entries()) {
|
|
1871
|
+
const volatileLiteral = volatileLiteralIn(text);
|
|
1872
|
+
if (volatileLiteral === void 0) continue;
|
|
1873
|
+
fixed ??= fixedData(document, dir);
|
|
1874
|
+
if (fixed.includes(volatileLiteral.word)) continue;
|
|
1875
|
+
findings.push(finding("d5-spec-volatile-literal", stream.line + index, `"${volatileLiteral.found}" is a value the next run will not reproduce — write ${volatileLiteral.token}`));
|
|
964
1876
|
}
|
|
965
1877
|
return findings;
|
|
966
1878
|
}
|
|
967
1879
|
const ISO8601 = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/u;
|
|
968
1880
|
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}/u;
|
|
969
|
-
/**
|
|
1881
|
+
/**
|
|
1882
|
+
* Everything the document itself FIXED — what it feeds in, what it names, and
|
|
1883
|
+
* what its fixtures hold.
|
|
1884
|
+
*
|
|
1885
|
+
* The first three sources are the document's own words: a literal that appears
|
|
1886
|
+
* in a command's arguments or in an `env:`/`serve:` value is one the DOCUMENT
|
|
1887
|
+
* put there, not one a run minted — a reader sees it being pinned two lines
|
|
1888
|
+
* above the stream that asserts it.
|
|
1889
|
+
*
|
|
1890
|
+
* The last is the ground beside it. A document's sibling `_fixtures/` is its
|
|
1891
|
+
* material whether or not a `fixture:` line names the exact file: the tree is
|
|
1892
|
+
* there to be read by the run, and a value it holds is one the repository
|
|
1893
|
+
* committed.
|
|
1894
|
+
*/
|
|
970
1895
|
function fixedData(document, dir) {
|
|
971
|
-
const parts = document.runs.
|
|
1896
|
+
const parts = document.runs.flatMap((run) => [run.stdin ?? "", run.command]);
|
|
1897
|
+
for (const token of document.env) parts.push(token.kind === "pair" ? `${token.key}=${token.value}` : token.name);
|
|
1898
|
+
for (const entry of document.serve) parts.push(entry.name, ...Object.values(entry.env));
|
|
1899
|
+
const visit = (path) => {
|
|
1900
|
+
let stat;
|
|
1901
|
+
try {
|
|
1902
|
+
stat = statSync(path);
|
|
1903
|
+
} catch {
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (!stat.isDirectory()) {
|
|
1907
|
+
parts.push(readText(path));
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
for (const entry of readdirSync(path)) visit(join(path, entry));
|
|
1911
|
+
};
|
|
972
1912
|
for (const fixture of document.fixtures) {
|
|
973
1913
|
let source;
|
|
974
1914
|
try {
|
|
@@ -976,21 +1916,9 @@ function fixedData(document, dir) {
|
|
|
976
1916
|
} catch {
|
|
977
1917
|
continue;
|
|
978
1918
|
}
|
|
979
|
-
const visit = (path) => {
|
|
980
|
-
let stat;
|
|
981
|
-
try {
|
|
982
|
-
stat = statSync(path);
|
|
983
|
-
} catch {
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
if (!stat.isDirectory()) {
|
|
987
|
-
parts.push(readText(path));
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
for (const entry of readdirSync(path)) visit(join(path, entry));
|
|
991
|
-
};
|
|
992
1919
|
visit(source);
|
|
993
1920
|
}
|
|
1921
|
+
visit(join(dir, GROUND_FIXTURES));
|
|
994
1922
|
return parts.join("\n");
|
|
995
1923
|
}
|
|
996
1924
|
/**
|
|
@@ -1154,7 +2082,7 @@ function checkSpecConventions(text, rel, path) {
|
|
|
1154
2082
|
...checkBlockScalars(file, (scalar) => lineAt(scalar)),
|
|
1155
2083
|
...checkFileName(rel),
|
|
1156
2084
|
...checkDescription(file.document),
|
|
1157
|
-
...checkVolatileLiterals(file.document),
|
|
2085
|
+
...checkVolatileLiterals(file.document, dir),
|
|
1158
2086
|
...checkPinnedValues(file.document, dir),
|
|
1159
2087
|
...checkEmptyAssertions(file.document),
|
|
1160
2088
|
...checkSilentRefusals(file.document),
|
|
@@ -1162,7 +2090,8 @@ function checkSpecConventions(text, rel, path) {
|
|
|
1162
2090
|
].map(({ line, message, rule, severity }) => ({
|
|
1163
2091
|
file: rel,
|
|
1164
2092
|
line,
|
|
1165
|
-
message: `${rel}:${line}: ${message} (${rule} — see docs/
|
|
2093
|
+
message: `${rel}:${line}: ${message} (${rule} — see docs/19-linting.md)`,
|
|
2094
|
+
rule: codeOf(rule),
|
|
1166
2095
|
severity
|
|
1167
2096
|
}));
|
|
1168
2097
|
}
|
|
@@ -1193,7 +2122,8 @@ function checkSpecDescriptionsUnique(rootDir) {
|
|
|
1193
2122
|
violations.push({
|
|
1194
2123
|
file: rel,
|
|
1195
2124
|
line: document.descriptionLine,
|
|
1196
|
-
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/
|
|
2125
|
+
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/19-linting.md)`,
|
|
2126
|
+
rule: "j4",
|
|
1197
2127
|
severity: "error"
|
|
1198
2128
|
});
|
|
1199
2129
|
}
|
|
@@ -1268,8 +2198,30 @@ const BRACE_BLOCK = /\{\{(?<inner>[^{}]*)\}\}/gu;
|
|
|
1268
2198
|
/** The leading identifier of a brace block, for malformed-ref classification. */
|
|
1269
2199
|
const LEADING_WORD = /^(?<kind>[A-Za-z][A-Za-z0-9]*)/u;
|
|
1270
2200
|
const KNOWN = new Set(TOKEN_KINDS);
|
|
1271
|
-
/** Directories whose files
|
|
1272
|
-
const FIXTURE_DIRS = /* @__PURE__ */ new Set([
|
|
2201
|
+
/** Directories whose files the walk judges, each by its own law. */
|
|
2202
|
+
const FIXTURE_DIRS = /* @__PURE__ */ new Set([
|
|
2203
|
+
GROUND_EXPECTED,
|
|
2204
|
+
GROUND_REQUESTS,
|
|
2205
|
+
GROUND_SEEDS
|
|
2206
|
+
]);
|
|
2207
|
+
/**
|
|
2208
|
+
* The ways a seed asks the machine what time it is.
|
|
2209
|
+
*
|
|
2210
|
+
* Three spellings and nothing else, because each is decidable on sight: the
|
|
2211
|
+
* quoted `'now'` argument SQLite's date functions take (`datetime('now')`,
|
|
2212
|
+
* `strftime('%s', 'now')`, `julianday('now')`, `date('now', '-1 day')`), the
|
|
2213
|
+
* SQL keyword every engine spells the same, and Postgres/MySQL's `NOW()`.
|
|
2214
|
+
* The bare word `now` is not one of them — it is a column name as often as a
|
|
2215
|
+
* clock read, and a rule that guessed would be refused rather than obeyed.
|
|
2216
|
+
*/
|
|
2217
|
+
const CLOCK_READ = /'now'|\bCURRENT_(?:TIMESTAMP|DATE|TIME)\b|\bNOW\s*\(\s*\)/iu;
|
|
2218
|
+
/** A line comment, and a block comment — a `-- datetime('now')` is prose, not state. */
|
|
2219
|
+
const SQL_LINE_COMMENT = /--[^\n]*/gu;
|
|
2220
|
+
const SQL_BLOCK_COMMENT = /\/\*[\s\S]*?\*\//gu;
|
|
2221
|
+
/** The file with its comments blanked out, line for line so a finding still points. */
|
|
2222
|
+
function withoutComments(text) {
|
|
2223
|
+
return text.replaceAll(SQL_BLOCK_COMMENT, (block) => block.replaceAll(/[^\n]/gu, " ")).replaceAll(SQL_LINE_COMMENT, "");
|
|
2224
|
+
}
|
|
1273
2225
|
/**
|
|
1274
2226
|
* Directories the walk never enters. `_fixtures/` trees (the shared pool and
|
|
1275
2227
|
* the leaf-local ones) are verbatim `.fixture()` cwd material — file STATE, not
|
|
@@ -1281,6 +2233,56 @@ const SKIPPED_DIRS = /* @__PURE__ */ new Set([
|
|
|
1281
2233
|
GROUND_FIXTURES,
|
|
1282
2234
|
"node_modules"
|
|
1283
2235
|
]);
|
|
2236
|
+
/** The four ground names — what a document may never sit under (C16). */
|
|
2237
|
+
const GROUND_NAMES = new Set(GROUND_DIRS);
|
|
2238
|
+
/**
|
|
2239
|
+
* The passes a run over a `specs/` TREE puts on it.
|
|
2240
|
+
*
|
|
2241
|
+
* The three `d4*`/`d10w` ids are sub-scans of {@link checkConventionFiles}; the
|
|
2242
|
+
* rest are the cross-file and facet passes. The CLI prints this list when a run
|
|
2243
|
+
* is clean, so what the reader is told ran is the registry itself.
|
|
2244
|
+
*/
|
|
2245
|
+
const TREE_PASS_IDS = [
|
|
2246
|
+
"a7-database-property",
|
|
2247
|
+
"b5-await-using-inference",
|
|
2248
|
+
"c12-spec-file-name",
|
|
2249
|
+
"c14-pool-fixture-shared",
|
|
2250
|
+
"c15-local-fixture-reach",
|
|
2251
|
+
"c16-document-outside-ground",
|
|
2252
|
+
"c18-module-test-under-facet",
|
|
2253
|
+
"c20-facet-folder",
|
|
2254
|
+
"c21w-ground-owned-by-one",
|
|
2255
|
+
"c23-seed-no-clock-read",
|
|
2256
|
+
"c8-spec-registered-name",
|
|
2257
|
+
"c9-dead-fixtures",
|
|
2258
|
+
"d10w-tokens-in-requests",
|
|
2259
|
+
"d11w-spec-silent-refusal",
|
|
2260
|
+
"d4-malformed-ref",
|
|
2261
|
+
"d4-unknown-token",
|
|
2262
|
+
"d4b-http-first-line",
|
|
2263
|
+
"d4b-spec-block-scalar",
|
|
2264
|
+
"d4b-spec-key-order",
|
|
2265
|
+
"d4b-spec-shape",
|
|
2266
|
+
"d5-spec-volatile-literal",
|
|
2267
|
+
"d5w-spec-pinned-value",
|
|
2268
|
+
"j3w-spec-empty-assertion",
|
|
2269
|
+
"j4-spec-description-unique",
|
|
2270
|
+
"j9-checker-suppression-reason",
|
|
2271
|
+
"j5-spec-description"
|
|
2272
|
+
];
|
|
2273
|
+
/**
|
|
2274
|
+
* The passes a run over one workspace MEMBER puts on it — what no file under
|
|
2275
|
+
* `specs/` can answer: whether the member states how its tests run, whether its
|
|
2276
|
+
* config draws a browser, what it declares, and whether a `.spec.ts` of its own
|
|
2277
|
+
* ever reached a specs tree.
|
|
2278
|
+
*/
|
|
2279
|
+
const MEMBER_PASS_IDS = [
|
|
2280
|
+
"c12-spec-file-name",
|
|
2281
|
+
"e3-config-present",
|
|
2282
|
+
"e5b-no-simulated-dom-config-member",
|
|
2283
|
+
"f8-no-seam-dependency"
|
|
2284
|
+
];
|
|
2285
|
+
[.../* @__PURE__ */ new Set([...TREE_PASS_IDS, ...MEMBER_PASS_IDS])];
|
|
1284
2286
|
/**
|
|
1285
2287
|
* Scan one fixture text for tokens outside the grammar: unknown kinds
|
|
1286
2288
|
* (`{{userid}}`) and malformed captures of a known kind (`{{iso8601#}}`,
|
|
@@ -1355,7 +2357,8 @@ function checkSpecFile(text, rel) {
|
|
|
1355
2357
|
return [{
|
|
1356
2358
|
file: rel,
|
|
1357
2359
|
line: error.line,
|
|
1358
|
-
message: `${error.message} (D4b — see docs/
|
|
2360
|
+
message: `${error.message} (D4b — see docs/19-linting.md)`,
|
|
2361
|
+
rule: "d4b",
|
|
1359
2362
|
severity: "error"
|
|
1360
2363
|
}];
|
|
1361
2364
|
}
|
|
@@ -1366,6 +2369,7 @@ function checkSpecFile(text, rel) {
|
|
|
1366
2369
|
file: rel,
|
|
1367
2370
|
line: at,
|
|
1368
2371
|
message: `${rel}:${at}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
|
|
2372
|
+
rule: "d4",
|
|
1369
2373
|
severity: "error",
|
|
1370
2374
|
token
|
|
1371
2375
|
});
|
|
@@ -1373,6 +2377,37 @@ function checkSpecFile(text, rel) {
|
|
|
1373
2377
|
return violations;
|
|
1374
2378
|
}
|
|
1375
2379
|
/**
|
|
2380
|
+
* C23 — a seed states absolute timestamps; it never reads the clock.
|
|
2381
|
+
*
|
|
2382
|
+
* A `_seeds/*.sql` that writes `datetime('now')` makes the row's date the date
|
|
2383
|
+
* the suite happened to run, so a case about "published yesterday" proves
|
|
2384
|
+
* something different every day and nothing at all across midnight. Worse under
|
|
2385
|
+
* SQLite, where `datetime('now')` writes a space-separated text while an ORM
|
|
2386
|
+
* writes ISO `T`: the two sort against each other, and the ordering breaks on
|
|
2387
|
+
* the days the values straddle a calendar boundary.
|
|
2388
|
+
*
|
|
2389
|
+
* The chain already owns the answer — `.clock()` pins what the code under test
|
|
2390
|
+
* reads — so the seed has nothing to ask the machine for.
|
|
2391
|
+
*/
|
|
2392
|
+
function checkSeedClockReads(path, rel, name) {
|
|
2393
|
+
if (!name.endsWith(".sql")) return [];
|
|
2394
|
+
const text = decodeText(path);
|
|
2395
|
+
if (text === null) return [];
|
|
2396
|
+
const violations = [];
|
|
2397
|
+
for (const [index, line] of withoutComments(text).split("\n").entries()) {
|
|
2398
|
+
const found = CLOCK_READ.exec(line);
|
|
2399
|
+
if (found === null) continue;
|
|
2400
|
+
violations.push({
|
|
2401
|
+
file: rel,
|
|
2402
|
+
line: index + 1,
|
|
2403
|
+
message: `${rel}:${index + 1}: \`${found[0]}\` reads the clock — a seed states the instant the case is about, and the chain pins the run's with \`.clock()\` (C23 — see docs/19-linting.md)`,
|
|
2404
|
+
rule: "c23-seed-no-clock-read",
|
|
2405
|
+
severity: "error"
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
return violations;
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
1376
2411
|
* Walk `rootDir` and check every fixture file. Paths in the result are relative
|
|
1377
2412
|
* to `rootDir`. Errors fail the checker; warnings are advisory.
|
|
1378
2413
|
*/
|
|
@@ -1395,12 +2430,23 @@ function checkConventionFiles(rootDir) {
|
|
|
1395
2430
|
continue;
|
|
1396
2431
|
}
|
|
1397
2432
|
if (entry.name.endsWith(".spec.yaml")) {
|
|
2433
|
+
if (rel.split(/[/\\]/u).some((segment) => GROUND_NAMES.has(segment))) violations.push({
|
|
2434
|
+
file: rel,
|
|
2435
|
+
line: 1,
|
|
2436
|
+
message: `${rel}:1: a \`${SPEC_EXTENSION}\` document is a SPEC, not ground — move it beside the test that runs it (C16 — see docs/19-linting.md)`,
|
|
2437
|
+
rule: "c16-document-outside-ground",
|
|
2438
|
+
severity: "error"
|
|
2439
|
+
});
|
|
1398
2440
|
const text = decodeText(path);
|
|
1399
2441
|
if (text !== null) violations.push(...checkSpecFile(text, rel), ...checkSpecConventions(text, rel, path));
|
|
1400
2442
|
continue;
|
|
1401
2443
|
}
|
|
1402
2444
|
if (inside === null) continue;
|
|
1403
2445
|
const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
|
|
2446
|
+
if (inside === "_seeds") {
|
|
2447
|
+
violations.push(...checkSeedClockReads(path, rel, entry.name));
|
|
2448
|
+
continue;
|
|
2449
|
+
}
|
|
1404
2450
|
if (inside === "_requests") {
|
|
1405
2451
|
if (!entry.name.endsWith(".http")) continue;
|
|
1406
2452
|
const text = decodeText(path);
|
|
@@ -1408,13 +2454,15 @@ function checkConventionFiles(rootDir) {
|
|
|
1408
2454
|
if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
|
|
1409
2455
|
file: rel,
|
|
1410
2456
|
line: 1,
|
|
1411
|
-
message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/
|
|
2457
|
+
message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/19-linting.md)`,
|
|
2458
|
+
rule: "d4b",
|
|
1412
2459
|
severity: "error"
|
|
1413
2460
|
});
|
|
1414
2461
|
for (const { line, token } of findKnownTokens(text)) violations.push({
|
|
1415
2462
|
file: rel,
|
|
1416
2463
|
line,
|
|
1417
|
-
message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/
|
|
2464
|
+
message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/19-linting.md)`,
|
|
2465
|
+
rule: "d10w",
|
|
1418
2466
|
severity: "warn",
|
|
1419
2467
|
token
|
|
1420
2468
|
});
|
|
@@ -1425,16 +2473,29 @@ function checkConventionFiles(rootDir) {
|
|
|
1425
2473
|
if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
|
|
1426
2474
|
file: rel,
|
|
1427
2475
|
line: 1,
|
|
1428
|
-
message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/
|
|
2476
|
+
message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/19-linting.md)`,
|
|
2477
|
+
rule: "d4b",
|
|
1429
2478
|
severity: "error"
|
|
1430
2479
|
});
|
|
1431
2480
|
for (const { line, token } of findUnknownTokens(text)) violations.push({
|
|
1432
2481
|
file: rel,
|
|
1433
2482
|
line,
|
|
1434
2483
|
message: `${rel}:${line}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
|
|
2484
|
+
rule: "d4",
|
|
1435
2485
|
severity: "error",
|
|
1436
2486
|
token
|
|
1437
2487
|
});
|
|
2488
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
2489
|
+
const volatileLiteral = volatileLiteralIn(line);
|
|
2490
|
+
if (volatileLiteral === void 0) continue;
|
|
2491
|
+
violations.push({
|
|
2492
|
+
file: rel,
|
|
2493
|
+
line: index + 1,
|
|
2494
|
+
message: `${rel}:${index + 1}: "${volatileLiteral.found}" is a value the next run will not reproduce — write ${volatileLiteral.token} (D5 — see docs/19-linting.md)`,
|
|
2495
|
+
rule: "d5-spec-volatile-literal",
|
|
2496
|
+
severity: "error"
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
1438
2499
|
}
|
|
1439
2500
|
};
|
|
1440
2501
|
visit(rootDir, null);
|
|
@@ -1448,6 +2509,11 @@ function checkConventionFiles(rootDir) {
|
|
|
1448
2509
|
*/
|
|
1449
2510
|
function runAllChecks(rootDir) {
|
|
1450
2511
|
return [
|
|
2512
|
+
...checkTestUnderFacet(rootDir),
|
|
2513
|
+
...checkFacetFolder(rootDir),
|
|
2514
|
+
...checkModuleTestUnderFacet(rootDir),
|
|
2515
|
+
...checkGroundOwnedByOne(rootDir),
|
|
2516
|
+
...checkSuppressionReason(rootDir),
|
|
1451
2517
|
...checkConventionFiles(rootDir),
|
|
1452
2518
|
...checkSpecDescriptionsUnique(rootDir),
|
|
1453
2519
|
...checkDeadFixtures(rootDir),
|
|
@@ -1466,38 +2532,204 @@ function formatViolations(violations) {
|
|
|
1466
2532
|
/**
|
|
1467
2533
|
* CLI entry for the conventions checker (bundled as `dist/checker.js`).
|
|
1468
2534
|
*
|
|
1469
|
-
* node dist/checker.js [rootDir] [--fix]
|
|
2535
|
+
* node dist/checker.js [rootDir] [--fix] # a specs tree
|
|
2536
|
+
* node dist/checker.js --format json # every root, every member
|
|
2537
|
+
* node dist/checker.js --member <dir> # one workspace member
|
|
1470
2538
|
*
|
|
1471
|
-
*
|
|
2539
|
+
* Three runs, one reporter. With a PATH it walks that tree and runs every
|
|
2540
|
+
* checker pass over it — the token/HTTP grammar (D4 / D4b / D10), the
|
|
1472
2541
|
* `<case>.spec.yaml` document conventions, and the cross-file passes (C9 dead
|
|
1473
2542
|
* fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
|
|
1474
|
-
* property).
|
|
1475
|
-
*
|
|
2543
|
+
* property). With `--member <dir>` it runs the member pass (E3 config-present,
|
|
2544
|
+
* E5b no simulated DOM in a config, F8 no seam dependency) over one workspace
|
|
2545
|
+
* member, whether or not that member has a `specs/` root.
|
|
2546
|
+
*
|
|
2547
|
+
* With NO path it runs both over the whole project: every `specs/` root it
|
|
2548
|
+
* discovers from the root manifest's workspaces, and every member. That is the
|
|
2549
|
+
* run `@jterrazz/typescript`'s ratchet rests on, so the contract is exactly
|
|
2550
|
+
* "a path-less run reports what the per-root and per-member runs report".
|
|
2551
|
+
*
|
|
2552
|
+
* `--format json` prints `[{ code, file, line, severity, message }]` on stdout
|
|
2553
|
+
* and nothing else, where `code` is `jterrazz-check(<id>)` — the namespace the
|
|
2554
|
+
* ratchet records under. Exit 1 on any ERROR-level violation either way;
|
|
2555
|
+
* warnings are reported but never fail the run.
|
|
1476
2556
|
*
|
|
1477
2557
|
* `--fix` applies the rewritable passes — the two document ones (key order and
|
|
1478
|
-
* block scalars)
|
|
1479
|
-
*
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
2558
|
+
* block scalars), C14, which MOVES a single-reader pool fixture beside its leaf
|
|
2559
|
+
* and rewrites the literals that named it, and C12, which RENAMES a `.test.ts`
|
|
2560
|
+
* under a facet folder to `.spec.ts` — then checks what is left, so a run that
|
|
2561
|
+
* fixes everything exits 0. With no path it applies them to every `specs/` root
|
|
2562
|
+
* it discovers: `--fix` means the same thing whichever run the operator typed.
|
|
2563
|
+
*
|
|
2564
|
+
* After a C12 rename it NAMES every include glob of the owning member that
|
|
2565
|
+
* still says `.test.ts`. A glob the rename left behind collects nothing, the
|
|
2566
|
+
* run stays green with fewer files, and nothing else in the toolchain would
|
|
2567
|
+
* have said a word.
|
|
2568
|
+
*
|
|
2569
|
+
* C12's rename goes through `git mv` where the tree is a git working tree, so
|
|
2570
|
+
* the history follows the file across a migration that touches hundreds of
|
|
2571
|
+
* them; outside one it falls back to a plain rename. C14's move stays a plain
|
|
2572
|
+
* rename and the author stages what the working tree now shows.
|
|
2573
|
+
*/
|
|
2574
|
+
const argv = process.argv.slice(2);
|
|
2575
|
+
const fix = argv.includes("--fix");
|
|
2576
|
+
const json = valueOf("--format") === "json";
|
|
2577
|
+
const member = valueOf("--member");
|
|
2578
|
+
const positional = argv.find((argument, index) => {
|
|
2579
|
+
if (argument.startsWith("--")) return false;
|
|
2580
|
+
const previous = argv[index - 1];
|
|
2581
|
+
return previous !== "--format" && previous !== "--member";
|
|
2582
|
+
});
|
|
2583
|
+
/** The value of `--flag value` or `--flag=value`, or undefined. */
|
|
2584
|
+
function valueOf(flag) {
|
|
2585
|
+
const inline = argv.find((argument) => argument.startsWith(`${flag}=`));
|
|
2586
|
+
if (inline !== void 0) return inline.slice(flag.length + 1);
|
|
2587
|
+
const index = argv.indexOf(flag);
|
|
2588
|
+
return index === -1 ? void 0 : argv[index + 1];
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* What a run names when it is clean — the pass REGISTRY, never a sentence
|
|
2592
|
+
* written beside it.
|
|
2593
|
+
*
|
|
2594
|
+
* The hand-written list stopped at the cross-file passes of 15.x while the
|
|
2595
|
+
* channel grew four tree passes and a suppression gate, so the summary
|
|
2596
|
+
* under-reported exactly what it exists to report.
|
|
2597
|
+
*/
|
|
2598
|
+
const TREE_PASSES = TREE_PASS_IDS.join(", ");
|
|
2599
|
+
/** The passes a member run puts over a package. */
|
|
2600
|
+
const MEMBER_PASSES = MEMBER_PASS_IDS.join(", ");
|
|
2601
|
+
/**
|
|
2602
|
+
* Print the findings and exit on the first error-level one. `passes` names the
|
|
2603
|
+
* ones that ACTUALLY ran: a member run that claimed the tree passes would be
|
|
2604
|
+
* telling the reader a `specs/` tree was judged when none was walked.
|
|
2605
|
+
*/
|
|
2606
|
+
function report(violations, what, passes) {
|
|
2607
|
+
const errors = violations.filter((violation) => violation.severity === "error");
|
|
2608
|
+
if (json) {
|
|
2609
|
+
console.log(JSON.stringify(violations.map((violation) => ({
|
|
2610
|
+
code: `jterrazz-check(${codeOf(violation.rule)})`,
|
|
2611
|
+
file: violation.file,
|
|
2612
|
+
line: violation.line,
|
|
2613
|
+
message: violation.message,
|
|
2614
|
+
severity: violation.severity
|
|
2615
|
+
}))));
|
|
2616
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
2617
|
+
}
|
|
2618
|
+
if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
|
|
2619
|
+
if (errors.length > 0) {
|
|
2620
|
+
console.error(`\nconventions checker: ${errors.length} error(s) found ${what}`);
|
|
2621
|
+
process.exit(1);
|
|
2622
|
+
}
|
|
2623
|
+
console.log(`conventions checker: all passes clean ${what} (${passes})${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
|
|
2624
|
+
process.exit(0);
|
|
2625
|
+
}
|
|
2626
|
+
/** A directory that must exist — a typo'd path is operator error, not a clean tree. */
|
|
2627
|
+
function requireDirectory(path, what) {
|
|
2628
|
+
const resolved = resolve(path);
|
|
2629
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
2630
|
+
console.error(`conventions checker: no such ${what}: ${resolved}`);
|
|
2631
|
+
process.exit(1);
|
|
2632
|
+
}
|
|
2633
|
+
return resolved;
|
|
2634
|
+
}
|
|
2635
|
+
if (member !== void 0) {
|
|
2636
|
+
const root = resolve(positional ?? ".");
|
|
2637
|
+
const dir = requireDirectory(member, "member directory");
|
|
2638
|
+
report(checkMember(dir, root), `for member ${relative(root, dir) || "."}`, MEMBER_PASSES);
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Rename one file, keeping git's record of it where there is one.
|
|
2642
|
+
*
|
|
2643
|
+
* `git mv` is tried first and its failure is not an error: a path outside a
|
|
2644
|
+
* working tree, or one git does not track yet, renames exactly as well with
|
|
2645
|
+
* `renameSync` — what must not happen is a migration that loses the history of
|
|
2646
|
+
* every spec it touches because the tool reached for the blunter call.
|
|
2647
|
+
*/
|
|
2648
|
+
function renameKeepingHistory(from, to) {
|
|
2649
|
+
if (spawnSync("git", [
|
|
2650
|
+
"mv",
|
|
2651
|
+
from,
|
|
2652
|
+
to
|
|
2653
|
+
], {
|
|
2654
|
+
cwd: dirname(from),
|
|
2655
|
+
stdio: "ignore"
|
|
2656
|
+
}).status !== 0) renameSync(from, to);
|
|
2657
|
+
}
|
|
2658
|
+
/** C12's mover: every `.test.ts` under a facet folder becomes a `.spec.ts`. */
|
|
2659
|
+
function fixSpecSuffixes(root) {
|
|
2660
|
+
const renamed = [];
|
|
2661
|
+
for (const { from, to } of movesUnderFacet(root)) {
|
|
2662
|
+
if (to === void 0 || existsSync(to)) continue;
|
|
2663
|
+
renameKeepingHistory(from, to);
|
|
2664
|
+
renamed.push(`${relative(root, from)} → ${relative(root, to)}`);
|
|
2665
|
+
}
|
|
2666
|
+
return renamed;
|
|
2667
|
+
}
|
|
2668
|
+
/** Where a member states what it collects, whatever extension it writes it in. */
|
|
2669
|
+
const CONFIG_NAMES = [
|
|
2670
|
+
"vitest.config.ts",
|
|
2671
|
+
"vitest.config.mts",
|
|
2672
|
+
"vitest.config.cts",
|
|
2673
|
+
"vitest.config.js",
|
|
2674
|
+
"vitest.config.mjs",
|
|
2675
|
+
"vitest.config.cjs"
|
|
2676
|
+
];
|
|
2677
|
+
/** Any string in a config that ends in the suffix the mover just retired. */
|
|
2678
|
+
const TEST_GLOB = /['"`](?<glob>[^'"`]*\*[^'"`]*\.test\.ts)['"`]/gu;
|
|
2679
|
+
/**
|
|
2680
|
+
* The include globs a rename leaves behind.
|
|
2681
|
+
*
|
|
2682
|
+
* A consumer that names its suffix in an `include` loses the whole suite the
|
|
2683
|
+
* moment the mover renames it: vitest collects fewer files, exits 0, and the
|
|
2684
|
+
* only trace is a number nobody compares. The mover owns that consequence —
|
|
2685
|
+
* it cannot rewrite a config it does not parse, so it NAMES every glob that
|
|
2686
|
+
* still says `.test.ts` in the configs of the member it just moved files
|
|
2687
|
+
* under, and the author updates them in the same commit as the rename.
|
|
2688
|
+
*/
|
|
2689
|
+
function staleIncludes(root) {
|
|
2690
|
+
const owner = packageRootOf(root) ?? dirname(root);
|
|
2691
|
+
const found = [];
|
|
2692
|
+
for (const directory of /* @__PURE__ */ new Set([
|
|
2693
|
+
root,
|
|
2694
|
+
owner,
|
|
2695
|
+
dirname(root)
|
|
2696
|
+
])) for (const name of CONFIG_NAMES) {
|
|
2697
|
+
const path = join(directory, name);
|
|
2698
|
+
if (!existsSync(path)) continue;
|
|
2699
|
+
const text = readFileSync(path, "utf8");
|
|
2700
|
+
for (const match of text.matchAll(TEST_GLOB)) {
|
|
2701
|
+
const glob = match.groups?.glob ?? "";
|
|
2702
|
+
if (glob !== "") found.push(`${relative(owner, path)}: ${glob}`);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
return found;
|
|
2706
|
+
}
|
|
2707
|
+
/** Apply every rewritable pass over one tree, printing what it did. */
|
|
2708
|
+
function applyFixes(root) {
|
|
2709
|
+
const renamed = fixSpecSuffixes(root);
|
|
2710
|
+
for (const rename of renamed) console.log(`conventions checker: renamed ${rename} (C12)`);
|
|
2711
|
+
for (const glob of renamed.length === 0 ? [] : staleIncludes(root)) console.log(`conventions checker: ${glob} still names \`.test.ts\` — the files it collected are \`.spec.ts\` now, and the project collects nothing until the glob follows (C12)`);
|
|
1490
2712
|
const written = fixSpecFiles(root);
|
|
1491
2713
|
if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
|
|
1492
2714
|
for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
|
|
1493
2715
|
}
|
|
1494
|
-
|
|
1495
|
-
const
|
|
1496
|
-
if (
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2716
|
+
if (positional !== void 0) {
|
|
2717
|
+
const root = requireDirectory(positional, "directory");
|
|
2718
|
+
if (fix) applyFixes(root);
|
|
2719
|
+
report(runAllChecks(root), `under ${root}`, TREE_PASSES);
|
|
2720
|
+
}
|
|
2721
|
+
const root = resolve(".");
|
|
2722
|
+
const found = [];
|
|
2723
|
+
for (const specsRoot of discoverSpecRoots(root)) {
|
|
2724
|
+
const prefix = relative(root, specsRoot);
|
|
2725
|
+
if (fix) applyFixes(specsRoot);
|
|
2726
|
+
for (const violation of runAllChecks(specsRoot)) found.push({
|
|
2727
|
+
...violation,
|
|
2728
|
+
file: `${prefix}/${violation.file}`,
|
|
2729
|
+
message: `${prefix}/${violation.message}`
|
|
2730
|
+
});
|
|
1500
2731
|
}
|
|
1501
|
-
|
|
2732
|
+
found.push(...checkMembers(root));
|
|
2733
|
+
report(found, `under ${root}`, `${TREE_PASSES}, ${MEMBER_PASSES}`);
|
|
1502
2734
|
//#endregion
|
|
1503
2735
|
export {};
|