@jterrazz/test 15.3.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/dist/checker.js CHANGED
@@ -1,9 +1,86 @@
1
1
  #!/usr/bin/env node
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 { 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";
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
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
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
7
84
  //#region src/lint/checker-crossfile.ts
8
85
  /**
9
86
  * The cross-file checker passes — the analyses oxlint cannot express because
@@ -159,9 +236,10 @@ function blankComments(text) {
159
236
  for (let i = 0; i < text.length; i += 1) {
160
237
  const char = text[i];
161
238
  const next = text[i + 1];
162
- if (state === "line") if (char === "\n") state = "";
163
- else out[i] = " ";
164
- else if (state === "block") {
239
+ if (state === "line") {
240
+ if (char === "\n") state = "";
241
+ else out[i] = " ";
242
+ } else if (state === "block") {
165
243
  if (char === "*" && next === "/") {
166
244
  out[i] = " ";
167
245
  out[i + 1] = " ";
@@ -201,12 +279,12 @@ function collectSpecLiterals(path) {
201
279
  /**
202
280
  * Is this a file a leaf's ground may be referenced from?
203
281
  *
204
- * `.test.tsx` counts as much as `.test.ts`: a rendered unit's golden — its ARIA
205
- * tree — sits in the same `_expected/`, read by a test the suffix alone tells
206
- * 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.
207
285
  */
208
286
  function isReferrer(name) {
209
- return name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.yaml");
287
+ return isTestFileName(name) || name.endsWith(".spec.yaml");
210
288
  }
211
289
  function isDir(path) {
212
290
  try {
@@ -421,7 +499,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
421
499
  if (model.dockerAware && model.runnerExports.size > 0) dockerSpecs.set(file, model);
422
500
  }
423
501
  if (dockerSpecs.size === 0) return violations;
424
- for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
502
+ for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
425
503
  const text = readSource(testFile);
426
504
  const runners = /* @__PURE__ */ new Set();
427
505
  for (const binding of namedImports(text)) {
@@ -440,7 +518,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
440
518
  violations.push({
441
519
  file: rel,
442
520
  line,
443
- message: `${rel}:${line}: result of docker-aware runner "${runner}" must be bound with \`await using\` so its containers are disposed (B5 — see docs/13-linting.md)`,
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)`,
444
522
  rule: "b5",
445
523
  severity: "error"
446
524
  });
@@ -473,7 +551,7 @@ function checkDatabaseProperty(rootDir) {
473
551
  if (model.sqlDatabaseCount !== null && model.sqlDatabaseCount >= 1 && model.runnerExports.size > 0) enforced.set(file, model);
474
552
  }
475
553
  if (enforced.size === 0) return violations;
476
- for (const testFile of listFiles(rootDir, (path) => path.endsWith(".test.ts"))) {
554
+ for (const testFile of listFiles(rootDir, isNodeTestFileName)) {
477
555
  const text = readSource(testFile);
478
556
  const applicable = /* @__PURE__ */ new Set();
479
557
  for (const binding of namedImports(text)) {
@@ -498,14 +576,14 @@ function checkDatabaseProperty(rootDir) {
498
576
  if (count >= 2 && !hasDatabase) violations.push({
499
577
  file: rel,
500
578
  line,
501
- message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/13-linting.md)`,
579
+ message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/19-linting.md)`,
502
580
  rule: "a7",
503
581
  severity: "error"
504
582
  });
505
583
  else if (count === 1 && hasDatabase) violations.push({
506
584
  file: rel,
507
585
  line,
508
- message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/13-linting.md)`,
586
+ message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/19-linting.md)`,
509
587
  rule: "a7",
510
588
  severity: "error"
511
589
  });
@@ -527,7 +605,7 @@ function entryReferenced(entry, entryIsDir, literals, patterns = []) {
527
605
  /** A child named like a ground subdir but actually a fixture container. */
528
606
  function isConventionalSubdir(path) {
529
607
  if (!isDir(path)) return false;
530
- if (listFiles(path, (candidate) => candidate.endsWith(".test.ts")).length > 0) return false;
608
+ if (listFiles(path, isTestFileName).length > 0) return false;
531
609
  let children;
532
610
  try {
533
611
  children = readdirSync(path, { withFileTypes: true });
@@ -555,7 +633,7 @@ function checkDeadFixtures(rootDir) {
555
633
  violations.push({
556
634
  file: rel,
557
635
  line: 1,
558
- message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/13-linting.md)`,
636
+ message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/19-linting.md)`,
559
637
  rule: "c9",
560
638
  severity: "error"
561
639
  });
@@ -584,7 +662,7 @@ function checkDeadFixtures(rootDir) {
584
662
  violations.push({
585
663
  file: relEntry,
586
664
  line: 1,
587
- message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/13-linting.md)`,
665
+ message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/19-linting.md)`,
588
666
  rule: "c9",
589
667
  severity
590
668
  });
@@ -610,7 +688,7 @@ function checkPoolFixtures(rootDir) {
610
688
  violations.push({
611
689
  file: relEntry,
612
690
  line: 1,
613
- message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/13-linting.md)`,
691
+ message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/19-linting.md)`,
614
692
  rule: "c9",
615
693
  severity: "error"
616
694
  });
@@ -714,7 +792,7 @@ function checkPoolFixtureSharing(rootDir) {
714
792
  return {
715
793
  file: relEntry,
716
794
  line: 1,
717
- 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/13-linting.md, or run the checker with --fix)`,
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)`,
718
796
  rule: "c14",
719
797
  severity: "error"
720
798
  };
@@ -768,7 +846,7 @@ function checkLocalFixtureReach(rootDir) {
768
846
  violations.push({
769
847
  file: rel,
770
848
  line,
771
- 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/13-linting.md)`,
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)`,
772
850
  rule: "c15",
773
851
  severity: "error"
774
852
  });
@@ -777,6 +855,407 @@ function checkLocalFixtureReach(rootDir) {
777
855
  }
778
856
  return violations;
779
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
+ }
780
1259
  //#endregion
781
1260
  //#region src/lint/checker-member.ts
782
1261
  /**
@@ -825,8 +1304,24 @@ const RETIRED = /* @__PURE__ */ new Set([
825
1304
  "mockdate"
826
1305
  ]);
827
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
+ }
828
1323
  /** Where a member states what it collects, whatever extension it writes it in. */
829
- const CONFIG_NAMES = [
1324
+ const CONFIG_NAMES$1 = [
830
1325
  "vitest.config.ts",
831
1326
  "vitest.config.mts",
832
1327
  "vitest.config.cts",
@@ -854,7 +1349,7 @@ function readManifest(dir) {
854
1349
  }
855
1350
  /** The member's vitest config, by path, or null. */
856
1351
  function configOf(dir) {
857
- for (const name of CONFIG_NAMES) {
1352
+ for (const name of CONFIG_NAMES$1) {
858
1353
  const path = join(dir, name);
859
1354
  if (existsSync(path)) return path;
860
1355
  }
@@ -887,7 +1382,7 @@ function holdsTests(dir) {
887
1382
  if (!SKIPPED.has(entry.name) && !entry.name.startsWith(".")) stack.push(join(current, entry.name));
888
1383
  continue;
889
1384
  }
890
- if (/\.test\.[cm]?tsx?$/u.test(entry.name)) return true;
1385
+ if (isTestFileName(entry.name)) return true;
891
1386
  }
892
1387
  }
893
1388
  return false;
@@ -901,7 +1396,11 @@ const DELEGATING_TEST = /--workspaces|(?:^|\s)-r(?:\s|$)|--recursive|turbo\s+run
901
1396
  /** Every seam name this member declares that it should not, and why it should not. */
902
1397
  function declaredSeams(manifest) {
903
1398
  if (manifest.name === FRAMEWORK) return [];
904
- const isRetired = (name) => RETIRED.has(name) || RETIRED_SCOPES.some((scope) => name.startsWith(scope));
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
+ };
905
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) => ({
906
1405
  kind: isRetired(name) ? "retired" : "carried",
907
1406
  name
@@ -928,7 +1427,7 @@ function configPresent(manifest, memberDir, label, subject) {
928
1427
  return [{
929
1428
  file: join(label, "package.json"),
930
1429
  line: 1,
931
- 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/13-linting.md#e3-config-present)`,
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)`,
932
1431
  rule: "e3",
933
1432
  severity: "error"
934
1433
  }];
@@ -942,7 +1441,7 @@ function simulatedDom(config, rootDir) {
942
1441
  return [{
943
1442
  file: relConfig,
944
1443
  line: text.slice(0, found.index).split("\n").length,
945
- 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/13-linting.md#e5b-no-simulated-dom-config-member)`,
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)`,
946
1445
  rule: "e5b",
947
1446
  severity: "error"
948
1447
  }];
@@ -957,12 +1456,25 @@ function seamDependencies(manifest, memberDir, label, subject) {
957
1456
  return declaredSeams(manifest).map((seam) => ({
958
1457
  file: join(label, "package.json"),
959
1458
  line: lineOfKey(memberDir, seam.name),
960
- message: seam.kind === "carried" ? `${subject}: \`${seam.name}\` is a transitive of \`@jterrazz/test\` — remove the declaration (F8 — see docs/13-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/13-linting.md#f8-no-seam-dependency)`,
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)`,
961
1460
  rule: "f8",
962
1461
  severity: "error"
963
1462
  }));
964
1463
  }
965
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
+ /**
966
1478
  * Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
967
1479
  * anchors the paths the findings report, so a member's finding reads the same
968
1480
  * whether the pass ran over it alone or over the whole workspace.
@@ -976,7 +1488,8 @@ function checkMember(memberDir, rootDir) {
976
1488
  return [
977
1489
  ...config === null ? configPresent(manifest, memberDir, label, subject) : [],
978
1490
  ...config === null ? [] : simulatedDom(config, rootDir),
979
- ...seamDependencies(manifest, memberDir, label, subject)
1491
+ ...seamDependencies(manifest, memberDir, label, subject),
1492
+ ...anchoredToRoot(checkSpecOutsideSpecs(memberDir), label)
980
1493
  ];
981
1494
  }
982
1495
  /** The glob-ish workspace patterns a root manifest declares. */
@@ -1055,11 +1568,13 @@ const SPECS_DEPTH = 6;
1055
1568
  *
1056
1569
  * A `specs/` tree is never descended into (the fixtures under it are not spec
1057
1570
  * roots), and neither is another package's tree: a directory carrying its own
1058
- * `package.json` belongs to whichever member declares it.
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.
1059
1573
  */
1060
1574
  function discoverSpecRoots(rootDir) {
1061
1575
  const roots = /* @__PURE__ */ new Set();
1062
1576
  for (const member of discoverMembers(rootDir)) {
1577
+ if (basename(member) === "specs") roots.add(member);
1063
1578
  const walk = (dir, depth) => {
1064
1579
  if (depth > SPECS_DEPTH) return;
1065
1580
  let entries;
@@ -1301,27 +1816,99 @@ const VOLATILE = [
1301
1816
  }
1302
1817
  ];
1303
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
+ /**
1304
1857
  * A stream that pins a loopback port, a temp directory or somebody's home is a
1305
1858
  * golden that passed once, on one machine: the port was free that second, the
1306
1859
  * temp path belonged to that run. It is the exact shape of a value the update
1307
1860
  * writer would have tokenised, which is why finding one means a token was
1308
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.
1309
1866
  */
1310
- function checkVolatileLiterals(document) {
1867
+ function checkVolatileLiterals(document, dir) {
1311
1868
  const findings = [];
1312
- for (const stream of assertedStreams(document)) for (const [index, text] of stream.text.split("\n").entries()) for (const { pattern, token } of VOLATILE) {
1313
- const found = pattern.exec(text);
1314
- if (found === null) continue;
1315
- findings.push(finding("d5-spec-volatile-literal", stream.line + index, `"${found[0].trim()}" is a value the next run will not reproduce — write ${token}`));
1316
- break;
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}`));
1317
1876
  }
1318
1877
  return findings;
1319
1878
  }
1320
1879
  const ISO8601 = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/u;
1321
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;
1322
- /** Everything the document itself FIXED: what it feeds in, and what its fixtures hold. */
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
+ */
1323
1895
  function fixedData(document, dir) {
1324
- const parts = document.runs.map((run) => run.stdin ?? "");
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
+ };
1325
1912
  for (const fixture of document.fixtures) {
1326
1913
  let source;
1327
1914
  try {
@@ -1329,21 +1916,9 @@ function fixedData(document, dir) {
1329
1916
  } catch {
1330
1917
  continue;
1331
1918
  }
1332
- const visit = (path) => {
1333
- let stat;
1334
- try {
1335
- stat = statSync(path);
1336
- } catch {
1337
- return;
1338
- }
1339
- if (!stat.isDirectory()) {
1340
- parts.push(readText(path));
1341
- return;
1342
- }
1343
- for (const entry of readdirSync(path)) visit(join(path, entry));
1344
- };
1345
1919
  visit(source);
1346
1920
  }
1921
+ visit(join(dir, GROUND_FIXTURES));
1347
1922
  return parts.join("\n");
1348
1923
  }
1349
1924
  /**
@@ -1507,7 +2082,7 @@ function checkSpecConventions(text, rel, path) {
1507
2082
  ...checkBlockScalars(file, (scalar) => lineAt(scalar)),
1508
2083
  ...checkFileName(rel),
1509
2084
  ...checkDescription(file.document),
1510
- ...checkVolatileLiterals(file.document),
2085
+ ...checkVolatileLiterals(file.document, dir),
1511
2086
  ...checkPinnedValues(file.document, dir),
1512
2087
  ...checkEmptyAssertions(file.document),
1513
2088
  ...checkSilentRefusals(file.document),
@@ -1515,7 +2090,7 @@ function checkSpecConventions(text, rel, path) {
1515
2090
  ].map(({ line, message, rule, severity }) => ({
1516
2091
  file: rel,
1517
2092
  line,
1518
- message: `${rel}:${line}: ${message} (${rule} — see docs/13-linting.md)`,
2093
+ message: `${rel}:${line}: ${message} (${rule} — see docs/19-linting.md)`,
1519
2094
  rule: codeOf(rule),
1520
2095
  severity
1521
2096
  }));
@@ -1547,7 +2122,7 @@ function checkSpecDescriptionsUnique(rootDir) {
1547
2122
  violations.push({
1548
2123
  file: rel,
1549
2124
  line: document.descriptionLine,
1550
- 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/13-linting.md)`,
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)`,
1551
2126
  rule: "j4",
1552
2127
  severity: "error"
1553
2128
  });
@@ -1623,8 +2198,30 @@ const BRACE_BLOCK = /\{\{(?<inner>[^{}]*)\}\}/gu;
1623
2198
  /** The leading identifier of a brace block, for malformed-ref classification. */
1624
2199
  const LEADING_WORD = /^(?<kind>[A-Za-z][A-Za-z0-9]*)/u;
1625
2200
  const KNOWN = new Set(TOKEN_KINDS);
1626
- /** Directories whose files carry the token grammar (D4). */
1627
- const FIXTURE_DIRS = /* @__PURE__ */ new Set([GROUND_EXPECTED, GROUND_REQUESTS]);
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
+ }
1628
2225
  /**
1629
2226
  * Directories the walk never enters. `_fixtures/` trees (the shared pool and
1630
2227
  * the leaf-local ones) are verbatim `.fixture()` cwd material — file STATE, not
@@ -1636,6 +2233,56 @@ const SKIPPED_DIRS = /* @__PURE__ */ new Set([
1636
2233
  GROUND_FIXTURES,
1637
2234
  "node_modules"
1638
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])];
1639
2286
  /**
1640
2287
  * Scan one fixture text for tokens outside the grammar: unknown kinds
1641
2288
  * (`{{userid}}`) and malformed captures of a known kind (`{{iso8601#}}`,
@@ -1710,7 +2357,7 @@ function checkSpecFile(text, rel) {
1710
2357
  return [{
1711
2358
  file: rel,
1712
2359
  line: error.line,
1713
- message: `${error.message} (D4b — see docs/13-linting.md)`,
2360
+ message: `${error.message} (D4b — see docs/19-linting.md)`,
1714
2361
  rule: "d4b",
1715
2362
  severity: "error"
1716
2363
  }];
@@ -1730,6 +2377,37 @@ function checkSpecFile(text, rel) {
1730
2377
  return violations;
1731
2378
  }
1732
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
+ /**
1733
2411
  * Walk `rootDir` and check every fixture file. Paths in the result are relative
1734
2412
  * to `rootDir`. Errors fail the checker; warnings are advisory.
1735
2413
  */
@@ -1752,12 +2430,23 @@ function checkConventionFiles(rootDir) {
1752
2430
  continue;
1753
2431
  }
1754
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
+ });
1755
2440
  const text = decodeText(path);
1756
2441
  if (text !== null) violations.push(...checkSpecFile(text, rel), ...checkSpecConventions(text, rel, path));
1757
2442
  continue;
1758
2443
  }
1759
2444
  if (inside === null) continue;
1760
2445
  const depth1 = dir.endsWith(`/${inside}`) || dir.endsWith(`\\${inside}`);
2446
+ if (inside === "_seeds") {
2447
+ violations.push(...checkSeedClockReads(path, rel, entry.name));
2448
+ continue;
2449
+ }
1761
2450
  if (inside === "_requests") {
1762
2451
  if (!entry.name.endsWith(".http")) continue;
1763
2452
  const text = decodeText(path);
@@ -1765,14 +2454,14 @@ function checkConventionFiles(rootDir) {
1765
2454
  if (depth1 && !REQUEST_LINE.test(firstLine(text))) violations.push({
1766
2455
  file: rel,
1767
2456
  line: 1,
1768
- message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/13-linting.md)`,
2457
+ message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/19-linting.md)`,
1769
2458
  rule: "d4b",
1770
2459
  severity: "error"
1771
2460
  });
1772
2461
  for (const { line, token } of findKnownTokens(text)) violations.push({
1773
2462
  file: rel,
1774
2463
  line,
1775
- message: `${rel}:${line}: token ${token} in a _requests/ file — requests are inputs, never matched; tokens are not validated here (D10 — see docs/13-linting.md)`,
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)`,
1776
2465
  rule: "d10w",
1777
2466
  severity: "warn",
1778
2467
  token
@@ -1784,7 +2473,7 @@ function checkConventionFiles(rootDir) {
1784
2473
  if (depth1 && entry.name.endsWith(".http") && !STATUS_LINE.test(firstLine(text))) violations.push({
1785
2474
  file: rel,
1786
2475
  line: 1,
1787
- message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/13-linting.md)`,
2476
+ message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/19-linting.md)`,
1788
2477
  rule: "d4b",
1789
2478
  severity: "error"
1790
2479
  });
@@ -1796,6 +2485,17 @@ function checkConventionFiles(rootDir) {
1796
2485
  severity: "error",
1797
2486
  token
1798
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
+ }
1799
2499
  }
1800
2500
  };
1801
2501
  visit(rootDir, null);
@@ -1809,6 +2509,11 @@ function checkConventionFiles(rootDir) {
1809
2509
  */
1810
2510
  function runAllChecks(rootDir) {
1811
2511
  return [
2512
+ ...checkTestUnderFacet(rootDir),
2513
+ ...checkFacetFolder(rootDir),
2514
+ ...checkModuleTestUnderFacet(rootDir),
2515
+ ...checkGroundOwnedByOne(rootDir),
2516
+ ...checkSuppressionReason(rootDir),
1812
2517
  ...checkConventionFiles(rootDir),
1813
2518
  ...checkSpecDescriptionsUnique(rootDir),
1814
2519
  ...checkDeadFixtures(rootDir),
@@ -1850,10 +2555,21 @@ function formatViolations(violations) {
1850
2555
  * warnings are reported but never fail the run.
1851
2556
  *
1852
2557
  * `--fix` applies the rewritable passes — the two document ones (key order and
1853
- * block scalars) and C14, which MOVES a single-reader pool fixture beside its
1854
- * leaf and rewrites the literals that named it — then checks what is left, so a
1855
- * run that fixes everything exits 0. The move is a plain rename: the checker
1856
- * never runs git, and the author stages what the working tree now shows.
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.
1857
2573
  */
1858
2574
  const argv = process.argv.slice(2);
1859
2575
  const fix = argv.includes("--fix");
@@ -1871,10 +2587,17 @@ function valueOf(flag) {
1871
2587
  const index = argv.indexOf(flag);
1872
2588
  return index === -1 ? void 0 : argv[index + 1];
1873
2589
  }
1874
- /** The passes a tree run puts over a `specs/` root. */
1875
- const TREE_PASSES = "D4/D4b/D10 grammar, spec documents, C9 dead fixtures, C14/C15 fixture placement, B5 await-using, A7 database";
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(", ");
1876
2599
  /** The passes a member run puts over a package. */
1877
- const MEMBER_PASSES = "E3 config-present, E5b no simulated DOM, F8 no seam dependency";
2600
+ const MEMBER_PASSES = MEMBER_PASS_IDS.join(", ");
1878
2601
  /**
1879
2602
  * Print the findings and exit on the first error-level one. `passes` names the
1880
2603
  * ones that ACTUALLY ran: a member run that claimed the tree passes would be
@@ -1884,7 +2607,7 @@ function report(violations, what, passes) {
1884
2607
  const errors = violations.filter((violation) => violation.severity === "error");
1885
2608
  if (json) {
1886
2609
  console.log(JSON.stringify(violations.map((violation) => ({
1887
- code: `jterrazz-check(${violation.rule})`,
2610
+ code: `jterrazz-check(${codeOf(violation.rule)})`,
1888
2611
  file: violation.file,
1889
2612
  line: violation.line,
1890
2613
  message: violation.message,
@@ -1914,22 +2637,96 @@ if (member !== void 0) {
1914
2637
  const dir = requireDirectory(member, "member directory");
1915
2638
  report(checkMember(dir, root), `for member ${relative(root, dir) || "."}`, MEMBER_PASSES);
1916
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)`);
2712
+ const written = fixSpecFiles(root);
2713
+ if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
2714
+ for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
2715
+ }
1917
2716
  if (positional !== void 0) {
1918
2717
  const root = requireDirectory(positional, "directory");
1919
- if (fix) {
1920
- const written = fixSpecFiles(root);
1921
- if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
1922
- for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
1923
- }
2718
+ if (fix) applyFixes(root);
1924
2719
  report(runAllChecks(root), `under ${root}`, TREE_PASSES);
1925
2720
  }
1926
2721
  const root = resolve(".");
1927
2722
  const found = [];
1928
2723
  for (const specsRoot of discoverSpecRoots(root)) {
1929
2724
  const prefix = relative(root, specsRoot);
2725
+ if (fix) applyFixes(specsRoot);
1930
2726
  for (const violation of runAllChecks(specsRoot)) found.push({
1931
2727
  ...violation,
1932
- file: `${prefix}/${violation.file}`
2728
+ file: `${prefix}/${violation.file}`,
2729
+ message: `${prefix}/${violation.message}`
1933
2730
  });
1934
2731
  }
1935
2732
  found.push(...checkMembers(root));