@jterrazz/test 15.1.1 → 15.3.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
@@ -2,7 +2,8 @@
2
2
  import { a as assertedStreams, d as isMap, f as isScalar, i as SpecSyntaxError, m as renderYamlSource, n as RUN_KEYS, o as parseSpecDocument, p as isSeq, r as SPEC_EXTENSION, s as readSpecFile, t as DOCUMENT_KEYS, u as Scalar } from "./spec-document.js";
3
3
  import { c as GROUND_EXPECTED, l as GROUND_FIXTURES, o as resolveFixtureSource, r as TOKEN_KINDS, s as GROUND_DIRS, u as GROUND_REQUESTS } from "./match.js";
4
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";
6
7
  //#region src/lint/checker-crossfile.ts
7
8
  /**
8
9
  * The cross-file checker passes — the analyses oxlint cannot express because
@@ -197,9 +198,15 @@ function collectSpecLiterals(path) {
197
198
  return /* @__PURE__ */ new Set();
198
199
  }
199
200
  }
200
- /** Is this a file a leaf's ground may be referenced from? */
201
+ /**
202
+ * Is this a file a leaf's ground may be referenced from?
203
+ *
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.
207
+ */
201
208
  function isReferrer(name) {
202
- return name.endsWith(".test.ts") || name.endsWith(".spec.yaml");
209
+ return name.endsWith(".test.ts") || name.endsWith(".test.tsx") || name.endsWith(".spec.yaml");
203
210
  }
204
211
  function isDir(path) {
205
212
  try {
@@ -227,6 +234,23 @@ function collectLiterals(text) {
227
234
  }
228
235
  return found;
229
236
  }
237
+ const TEMPLATE_WITH_HOLE = /`(?<body>(?:[^`\\]|\\.)*\$\{(?:[^`\\]|\\.)*)`/gu;
238
+ function collectLiteralPatterns(text) {
239
+ const found = [];
240
+ for (const match of text.matchAll(TEMPLATE_WITH_HOLE)) {
241
+ const body = match.groups?.body ?? "";
242
+ const opens = body.indexOf("${");
243
+ const closes = body.lastIndexOf("}");
244
+ if (opens === -1 || closes === -1 || closes < opens) continue;
245
+ const prefix = body.slice(0, opens);
246
+ const suffix = body.slice(closes + 1);
247
+ if (prefix.length > 0 || suffix.length > 0) found.push({
248
+ prefix,
249
+ suffix
250
+ });
251
+ }
252
+ return found;
253
+ }
230
254
  /**
231
255
  * Does the text pass a NON-literal argument to a fixture-ish verb
232
256
  * (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
@@ -417,6 +441,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
417
441
  file: rel,
418
442
  line,
419
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)`,
444
+ rule: "b5",
420
445
  severity: "error"
421
446
  });
422
447
  }
@@ -474,12 +499,14 @@ function checkDatabaseProperty(rootDir) {
474
499
  file: rel,
475
500
  line,
476
501
  message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/13-linting.md)`,
502
+ rule: "a7",
477
503
  severity: "error"
478
504
  });
479
505
  else if (count === 1 && hasDatabase) violations.push({
480
506
  file: rel,
481
507
  line,
482
508
  message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/13-linting.md)`,
509
+ rule: "a7",
483
510
  severity: "error"
484
511
  });
485
512
  }
@@ -487,7 +514,8 @@ function checkDatabaseProperty(rootDir) {
487
514
  return violations;
488
515
  }
489
516
  /** Is a top-level ground-subdir entry referenced by any test literal? */
490
- function entryReferenced(entry, entryIsDir, literals) {
517
+ function entryReferenced(entry, entryIsDir, literals, patterns = []) {
518
+ for (const pattern of patterns) if (entry.startsWith(pattern.prefix) && entry.endsWith(pattern.suffix)) return true;
491
519
  for (const literal of literals) {
492
520
  if (entryIsDir) {
493
521
  if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
@@ -527,12 +555,14 @@ function checkDeadFixtures(rootDir) {
527
555
  violations.push({
528
556
  file: rel,
529
557
  line: 1,
530
- message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts or *.spec.yaml (C9 — see docs/13-linting.md)`,
558
+ message: `${rel}: domain directory has ground subdirs (${groundSubdirs.join(", ")}) but no *.test.ts(x) or *.spec.yaml (C9 — see docs/13-linting.md)`,
559
+ rule: "c9",
531
560
  severity: "error"
532
561
  });
533
562
  continue;
534
563
  }
535
564
  const literals = /* @__PURE__ */ new Set();
565
+ const patterns = [];
536
566
  let downgrade = false;
537
567
  for (const testFile of testFiles) {
538
568
  if (testFile.endsWith(".spec.yaml")) {
@@ -541,6 +571,7 @@ function checkDeadFixtures(rootDir) {
541
571
  }
542
572
  const text = readSource(testFile);
543
573
  for (const literal of collectLiterals(text)) literals.add(literal);
574
+ patterns.push(...collectLiteralPatterns(text));
544
575
  downgrade ||= hasNonLiteralFixtureArg(text);
545
576
  }
546
577
  const severity = downgrade ? "warn" : "error";
@@ -548,12 +579,13 @@ function checkDeadFixtures(rootDir) {
548
579
  const subPath = join(dir, sub);
549
580
  for (const entry of readdirSync(subPath, { withFileTypes: true })) {
550
581
  const entryIsDir = entry.isDirectory();
551
- if (entryReferenced(entry.name, entryIsDir, literals)) continue;
582
+ if (entryReferenced(entry.name, entryIsDir, literals, patterns)) continue;
552
583
  const relEntry = relative(rootDir, join(subPath, entry.name));
553
584
  violations.push({
554
585
  file: relEntry,
555
586
  line: 1,
556
587
  message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/13-linting.md)`,
588
+ rule: "c9",
557
589
  severity
558
590
  });
559
591
  }
@@ -579,6 +611,7 @@ function checkPoolFixtures(rootDir) {
579
611
  file: relEntry,
580
612
  line: 1,
581
613
  message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/13-linting.md)`,
614
+ rule: "c9",
582
615
  severity: "error"
583
616
  });
584
617
  }
@@ -682,6 +715,7 @@ function checkPoolFixtureSharing(rootDir) {
682
715
  file: relEntry,
683
716
  line: 1,
684
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)`,
718
+ rule: "c14",
685
719
  severity: "error"
686
720
  };
687
721
  });
@@ -735,6 +769,7 @@ function checkLocalFixtureReach(rootDir) {
735
769
  file: rel,
736
770
  line,
737
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)`,
772
+ rule: "c15",
738
773
  severity: "error"
739
774
  });
740
775
  }
@@ -743,6 +778,330 @@ function checkLocalFixtureReach(rootDir) {
743
778
  return violations;
744
779
  }
745
780
  //#endregion
781
+ //#region src/lint/checker-member.ts
782
+ /**
783
+ * The MEMBER pass — what a workspace member owes, read from the member itself
784
+ * rather than from a spec tree.
785
+ *
786
+ * The three findings here answer questions no file under `specs/` can: does
787
+ * this member have a vitest config at all, does its config give every test it
788
+ * collects a drawing of a browser, and does it declare a dependency the
789
+ * framework already carries. A member with no `specs/` root is still judged —
790
+ * that is the whole point, since the first of the three is about a member that
791
+ * has tests and no configuration for them.
792
+ *
793
+ * The pass is the toolchain's door: `jterrazz-test-check --member <dir>` runs
794
+ * it for one member, and a path-less `--format json` run at the project root
795
+ * runs it for every member the root manifest declares.
796
+ */
797
+ /** The two simulated DOMs — the same pair the statique E5b refuses. */
798
+ const SIMULATED_DOM = /\benvironment\s*:\s*['"](?<dom>happy-dom|jsdom)['"]/u;
799
+ /**
800
+ * Dependencies `@jterrazz/test` carries. A member declaring one of these
801
+ * duplicates a transitive it already resolves. Optional peers are not here:
802
+ * `playwright`, `vite`, `react`, `better-sqlite3` and their kin are declared
803
+ * BY the consumer on purpose.
804
+ */
805
+ const CARRIED = /* @__PURE__ */ new Set(["msw", "vitest-mock-extended"]);
806
+ /**
807
+ * Carried, but only where it can only be the test seam's. `yaml` is a product
808
+ * library as much as a fixture reader: a member that parses YAML at RUNTIME
809
+ * declares it rightly, and a rule that read `dependencies` here would be
810
+ * telling a product to drop a library the framework never ships to it. So the
811
+ * finding is limited to the one declaration that can only mean the seam.
812
+ */
813
+ const CARRIED_IN_DEV = /* @__PURE__ */ new Set(["yaml"]);
814
+ /**
815
+ * The framework itself. It DECLARES what every other member inherits, so the
816
+ * one manifest F8 must never read is its own — a rule that fired on the
817
+ * package carrying the dependency would be asking it to remove the thing it
818
+ * exists to provide.
819
+ */
820
+ const FRAMEWORK = "@jterrazz/test";
821
+ const RETIRED = /* @__PURE__ */ new Set([
822
+ "@playwright/test",
823
+ "happy-dom",
824
+ "jsdom",
825
+ "mockdate"
826
+ ]);
827
+ const RETIRED_SCOPES = ["@testing-library/"];
828
+ /** Where a member states what it collects, whatever extension it writes it in. */
829
+ const CONFIG_NAMES = [
830
+ "vitest.config.ts",
831
+ "vitest.config.mts",
832
+ "vitest.config.cts",
833
+ "vitest.config.js",
834
+ "vitest.config.mjs",
835
+ "vitest.config.cjs"
836
+ ];
837
+ /** Directories the member walk never enters. */
838
+ const SKIPPED = /* @__PURE__ */ new Set([
839
+ ".git",
840
+ ".artifacts",
841
+ "coverage",
842
+ "dist",
843
+ "node_modules"
844
+ ]);
845
+ /** A member's manifest, or null when it has none (it is then not a member). */
846
+ function readManifest(dir) {
847
+ const path = join(dir, "package.json");
848
+ if (!existsSync(path)) return null;
849
+ try {
850
+ return JSON.parse(readFileSync(path, "utf8"));
851
+ } catch {
852
+ return null;
853
+ }
854
+ }
855
+ /** The member's vitest config, by path, or null. */
856
+ function configOf(dir) {
857
+ for (const name of CONFIG_NAMES) {
858
+ const path = join(dir, name);
859
+ if (existsSync(path)) return path;
860
+ }
861
+ return null;
862
+ }
863
+ /**
864
+ * Does this member hold a test file it OWNS?
865
+ *
866
+ * A directory carrying its own `package.json` is another package's tree — a
867
+ * workspace member, a vendored clone — and its tests are its own to configure.
868
+ * A root that only delegates would otherwise be told to write a config for
869
+ * files it never collects, which is the finding read backwards.
870
+ */
871
+ function holdsTests(dir) {
872
+ const stack = [dir];
873
+ let first = true;
874
+ while (stack.length > 0) {
875
+ const current = stack.pop();
876
+ if (current === void 0) break;
877
+ if (!first && existsSync(join(current, "package.json"))) continue;
878
+ first = false;
879
+ let entries;
880
+ try {
881
+ entries = readdirSync(current, { withFileTypes: true });
882
+ } catch {
883
+ continue;
884
+ }
885
+ for (const entry of entries) {
886
+ if (entry.isDirectory()) {
887
+ if (!SKIPPED.has(entry.name) && !entry.name.startsWith(".")) stack.push(join(current, entry.name));
888
+ continue;
889
+ }
890
+ if (/\.test\.[cm]?tsx?$/u.test(entry.name)) return true;
891
+ }
892
+ }
893
+ return false;
894
+ }
895
+ /**
896
+ * A `test` script that only hands the work to the members below it. It is not
897
+ * evidence that THIS package runs vitest — `npm run test --workspaces`,
898
+ * `pnpm -r test` and `turbo run test` all read as "ask the members".
899
+ */
900
+ const DELEGATING_TEST = /--workspaces|(?:^|\s)-r(?:\s|$)|--recursive|turbo\s+run|nx\s+run-many|lerna\s+run/u;
901
+ /** Every seam name this member declares that it should not, and why it should not. */
902
+ function declaredSeams(manifest) {
903
+ if (manifest.name === FRAMEWORK) return [];
904
+ const isRetired = (name) => RETIRED.has(name) || RETIRED_SCOPES.some((scope) => name.startsWith(scope));
905
+ 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
+ kind: isRetired(name) ? "retired" : "carried",
907
+ name
908
+ }));
909
+ }
910
+ /** The line a manifest key is written on — a finding points at the declaration, not at `{`. */
911
+ function lineOfKey(dir, key) {
912
+ try {
913
+ const found = readFileSync(join(dir, "package.json"), "utf8").split("\n").findIndex((line) => line.includes(`"${key}"`));
914
+ return found === -1 ? 1 : found + 1;
915
+ } catch {
916
+ return 1;
917
+ }
918
+ }
919
+ /**
920
+ * Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
921
+ * anchors the paths the findings report, so a member's finding reads the same
922
+ * whether the pass ran over it alone or over the whole workspace.
923
+ */
924
+ /** E3 — a member with tests of its own states how they run. */
925
+ function configPresent(manifest, memberDir, label, subject) {
926
+ const testScript = manifest.scripts?.test;
927
+ if (!(typeof testScript === "string" && !DELEGATING_TEST.test(testScript) || holdsTests(memberDir))) return [];
928
+ return [{
929
+ file: join(label, "package.json"),
930
+ 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)`,
932
+ rule: "e3",
933
+ severity: "error"
934
+ }];
935
+ }
936
+ /** E5b — a config that gives every file it collects a drawing of a browser. */
937
+ function simulatedDom(config, rootDir) {
938
+ const text = readFileSync(config, "utf8");
939
+ const found = SIMULATED_DOM.exec(text);
940
+ if (found?.groups?.dom === void 0) return [];
941
+ const relConfig = relative(rootDir, config);
942
+ return [{
943
+ file: relConfig,
944
+ 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)`,
946
+ rule: "e5b",
947
+ severity: "error"
948
+ }];
949
+ }
950
+ /**
951
+ * F8 — a dependency the framework already carries, or a seam it replaced.
952
+ *
953
+ * The two are not the same sentence: removing a transitive changes nothing a
954
+ * test can see, while dropping a retired seam means writing its facet.
955
+ */
956
+ function seamDependencies(manifest, memberDir, label, subject) {
957
+ return declaredSeams(manifest).map((seam) => ({
958
+ file: join(label, "package.json"),
959
+ 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)`,
961
+ rule: "f8",
962
+ severity: "error"
963
+ }));
964
+ }
965
+ /**
966
+ * Run E3, E5b and F8 over ONE member. `memberDir` is absolute; `rootDir`
967
+ * anchors the paths the findings report, so a member's finding reads the same
968
+ * whether the pass ran over it alone or over the whole workspace.
969
+ */
970
+ function checkMember(memberDir, rootDir) {
971
+ const manifest = readManifest(memberDir);
972
+ if (manifest === null) return [];
973
+ const label = relative(rootDir, memberDir) || ".";
974
+ const subject = label === "." ? manifest.name ?? basename(memberDir) : label;
975
+ const config = configOf(memberDir);
976
+ return [
977
+ ...config === null ? configPresent(manifest, memberDir, label, subject) : [],
978
+ ...config === null ? [] : simulatedDom(config, rootDir),
979
+ ...seamDependencies(manifest, memberDir, label, subject)
980
+ ];
981
+ }
982
+ /** The glob-ish workspace patterns a root manifest declares. */
983
+ function workspacePatterns(manifest) {
984
+ const { workspaces } = manifest;
985
+ if (Array.isArray(workspaces)) return workspaces;
986
+ return workspaces?.packages ?? [];
987
+ }
988
+ /**
989
+ * A glob over path SEGMENTS — `*` stops at a separator, `**` does not. The
990
+ * same translation `@jterrazz/typescript`'s `lib/workspace-members.js` makes,
991
+ * because the two lists have to be the same list: a member the toolchain runs
992
+ * `--member` over and the path-less run never discovers is a finding the
993
+ * ratchet can neither record nor clear.
994
+ */
995
+ function toPattern(glob) {
996
+ const escaped = glob.replaceAll(/[.+^${}()|[\]\\]/gu, String.raw`\$&`).replaceAll("**", " ").replaceAll("*", "[^/]*").replaceAll(" ", ".*");
997
+ return new RegExp(`^${escaped}$`, "u");
998
+ }
999
+ /** How deep a member may sit below the root — the toolchain's bound, to the segment. */
1000
+ const MEMBER_DEPTH = 6;
1001
+ /**
1002
+ * Every workspace member declared by the root manifest, plus the root itself —
1003
+ * a single-package repository IS its own member, and the pass has to reach it.
1004
+ */
1005
+ function discoverMembers(rootDir) {
1006
+ const manifest = readManifest(rootDir);
1007
+ if (manifest === null) return [];
1008
+ const patterns = workspacePatterns(manifest).filter((glob) => typeof glob === "string" && !glob.startsWith("!")).map((glob) => toPattern(glob.replace(/\/+$/u, "")));
1009
+ const members = /* @__PURE__ */ new Set([rootDir]);
1010
+ if (patterns.length === 0) return [...members];
1011
+ const walk = (dir, depth) => {
1012
+ if (depth > MEMBER_DEPTH) return;
1013
+ let entries;
1014
+ try {
1015
+ entries = readdirSync(dir, { withFileTypes: true });
1016
+ } catch {
1017
+ return;
1018
+ }
1019
+ for (const entry of entries) {
1020
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED.has(entry.name)) continue;
1021
+ const child = join(dir, entry.name);
1022
+ const path = relative(rootDir, child).split(sep).join("/");
1023
+ if (patterns.some((pattern) => pattern.test(path)) && readManifest(child) !== null) {
1024
+ members.add(child);
1025
+ continue;
1026
+ }
1027
+ walk(child, depth + 1);
1028
+ }
1029
+ };
1030
+ walk(rootDir, 0);
1031
+ return [...members];
1032
+ }
1033
+ /** Paths git is told to ignore — the toolchain skips them, so the two lists agree. */
1034
+ function ignoredPaths(rootDir, candidates) {
1035
+ if (candidates.length === 0) return /* @__PURE__ */ new Set();
1036
+ try {
1037
+ const answer = spawnSync("git", ["check-ignore", "--stdin"], {
1038
+ cwd: rootDir,
1039
+ encoding: "utf8",
1040
+ input: `${candidates.join("\n")}\n`
1041
+ });
1042
+ if (answer.error !== void 0 || answer.stdout === "") return /* @__PURE__ */ new Set();
1043
+ return new Set(answer.stdout.split("\n").filter(Boolean));
1044
+ } catch {
1045
+ return /* @__PURE__ */ new Set();
1046
+ }
1047
+ }
1048
+ /** How deep below a member a nested `specs/` may sit before the walk gives up. */
1049
+ const SPECS_DEPTH = 6;
1050
+ /**
1051
+ * Every `specs/` root the tree passes have to walk: the project's own, and
1052
+ * each member's — including a member that NESTS its facet tree (`web/specs`).
1053
+ * Stated here rather than guessed by the walk, so a path-less run reports
1054
+ * exactly what a per-root run would.
1055
+ *
1056
+ * A `specs/` tree is never descended into (the fixtures under it are not spec
1057
+ * roots), and neither is another package's tree: a directory carrying its own
1058
+ * `package.json` belongs to whichever member declares it.
1059
+ */
1060
+ function discoverSpecRoots(rootDir) {
1061
+ const roots = /* @__PURE__ */ new Set();
1062
+ for (const member of discoverMembers(rootDir)) {
1063
+ const walk = (dir, depth) => {
1064
+ if (depth > SPECS_DEPTH) return;
1065
+ let entries;
1066
+ try {
1067
+ entries = readdirSync(dir, { withFileTypes: true });
1068
+ } catch {
1069
+ return;
1070
+ }
1071
+ for (const entry of entries) {
1072
+ if (!entry.isDirectory() || entry.name.startsWith(".") || SKIPPED.has(entry.name)) continue;
1073
+ const child = join(dir, entry.name);
1074
+ if (entry.name === "specs") {
1075
+ roots.add(child);
1076
+ continue;
1077
+ }
1078
+ if (readManifest(child) !== null) continue;
1079
+ walk(child, depth + 1);
1080
+ }
1081
+ };
1082
+ walk(member, 0);
1083
+ }
1084
+ const found = [...roots].toSorted();
1085
+ const ignored = ignoredPaths(rootDir, found.map((path) => relative(rootDir, path)));
1086
+ return found.filter((path) => !ignored.has(relative(rootDir, path)));
1087
+ }
1088
+ /** Run the member pass over every member the root declares. */
1089
+ function checkMembers(rootDir) {
1090
+ return discoverMembers(rootDir).flatMap((member) => checkMember(member, rootDir));
1091
+ }
1092
+ //#endregion
1093
+ //#region src/lint/rule-code.ts
1094
+ /**
1095
+ * The convention code a checker pass id carries: `d4b-spec-shape` → `d4b`.
1096
+ *
1097
+ * It lives alone because BOTH sides need it — the passes that build a finding
1098
+ * and the entry that publishes one as `jterrazz-check(<code>)` — and a copy in
1099
+ * either would be a second answer to what a finding is called.
1100
+ */
1101
+ function codeOf(passId) {
1102
+ return passId.split("-")[0] ?? passId;
1103
+ }
1104
+ //#endregion
746
1105
  //#region src/lint/checker-spec.ts
747
1106
  function finding(rule, line, message, severity = "error") {
748
1107
  return {
@@ -1157,6 +1516,7 @@ function checkSpecConventions(text, rel, path) {
1157
1516
  file: rel,
1158
1517
  line,
1159
1518
  message: `${rel}:${line}: ${message} (${rule} — see docs/13-linting.md)`,
1519
+ rule: codeOf(rule),
1160
1520
  severity
1161
1521
  }));
1162
1522
  }
@@ -1188,6 +1548,7 @@ function checkSpecDescriptionsUnique(rootDir) {
1188
1548
  file: rel,
1189
1549
  line: document.descriptionLine,
1190
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)`,
1551
+ rule: "j4",
1191
1552
  severity: "error"
1192
1553
  });
1193
1554
  }
@@ -1350,6 +1711,7 @@ function checkSpecFile(text, rel) {
1350
1711
  file: rel,
1351
1712
  line: error.line,
1352
1713
  message: `${error.message} (D4b — see docs/13-linting.md)`,
1714
+ rule: "d4b",
1353
1715
  severity: "error"
1354
1716
  }];
1355
1717
  }
@@ -1360,6 +1722,7 @@ function checkSpecFile(text, rel) {
1360
1722
  file: rel,
1361
1723
  line: at,
1362
1724
  message: `${rel}:${at}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
1725
+ rule: "d4",
1363
1726
  severity: "error",
1364
1727
  token
1365
1728
  });
@@ -1403,12 +1766,14 @@ function checkConventionFiles(rootDir) {
1403
1766
  file: rel,
1404
1767
  line: 1,
1405
1768
  message: `${rel}:1: a _requests/*.http file must start with a request line "METHOD /path" (D4b — see docs/13-linting.md)`,
1769
+ rule: "d4b",
1406
1770
  severity: "error"
1407
1771
  });
1408
1772
  for (const { line, token } of findKnownTokens(text)) violations.push({
1409
1773
  file: rel,
1410
1774
  line,
1411
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)`,
1776
+ rule: "d10w",
1412
1777
  severity: "warn",
1413
1778
  token
1414
1779
  });
@@ -1420,12 +1785,14 @@ function checkConventionFiles(rootDir) {
1420
1785
  file: rel,
1421
1786
  line: 1,
1422
1787
  message: `${rel}:1: an _expected/*.http file must start with a status line "HTTP/1.1 <status>" (D4b — see docs/13-linting.md)`,
1788
+ rule: "d4b",
1423
1789
  severity: "error"
1424
1790
  });
1425
1791
  for (const { line, token } of findUnknownTokens(text)) violations.push({
1426
1792
  file: rel,
1427
1793
  line,
1428
1794
  message: `${rel}:${line}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
1795
+ rule: "d4",
1429
1796
  severity: "error",
1430
1797
  token
1431
1798
  });
@@ -1460,13 +1827,27 @@ function formatViolations(violations) {
1460
1827
  /**
1461
1828
  * CLI entry for the conventions checker (bundled as `dist/checker.js`).
1462
1829
  *
1463
- * node dist/checker.js [rootDir] [--fix] # default root: cwd
1830
+ * node dist/checker.js [rootDir] [--fix] # a specs tree
1831
+ * node dist/checker.js --format json # every root, every member
1832
+ * node dist/checker.js --member <dir> # one workspace member
1464
1833
  *
1465
- * Runs every checker pass — the token/HTTP grammar (D4 / D4b / D10), the
1834
+ * Three runs, one reporter. With a PATH it walks that tree and runs every
1835
+ * checker pass over it — the token/HTTP grammar (D4 / D4b / D10), the
1466
1836
  * `<case>.spec.yaml` document conventions, and the cross-file passes (C9 dead
1467
1837
  * fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
1468
- * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded C9
1469
- * feature) are printed but do not fail the run.
1838
+ * property). With `--member <dir>` it runs the member pass (E3 config-present,
1839
+ * E5b no simulated DOM in a config, F8 no seam dependency) over one workspace
1840
+ * member, whether or not that member has a `specs/` root.
1841
+ *
1842
+ * With NO path it runs both over the whole project: every `specs/` root it
1843
+ * discovers from the root manifest's workspaces, and every member. That is the
1844
+ * run `@jterrazz/typescript`'s ratchet rests on, so the contract is exactly
1845
+ * "a path-less run reports what the per-root and per-member runs report".
1846
+ *
1847
+ * `--format json` prints `[{ code, file, line, severity, message }]` on stdout
1848
+ * and nothing else, where `code` is `jterrazz-check(<id>)` — the namespace the
1849
+ * ratchet records under. Exit 1 on any ERROR-level violation either way;
1850
+ * warnings are reported but never fail the run.
1470
1851
  *
1471
1852
  * `--fix` applies the rewritable passes — the two document ones (key order and
1472
1853
  * block scalars) and C14, which MOVES a single-reader pool fixture beside its
@@ -1474,24 +1855,84 @@ function formatViolations(violations) {
1474
1855
  * run that fixes everything exits 0. The move is a plain rename: the checker
1475
1856
  * never runs git, and the author stages what the working tree now shows.
1476
1857
  */
1477
- const fix = process.argv.includes("--fix");
1478
- const root = resolve(process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ".");
1479
- if (!existsSync(root) || !statSync(root).isDirectory()) {
1480
- console.error(`conventions checker: no such directory: ${root}`);
1481
- process.exit(1);
1482
- }
1483
- if (fix) {
1484
- const written = fixSpecFiles(root);
1485
- if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
1486
- for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
1487
- }
1488
- const violations = runAllChecks(root);
1489
- const errors = violations.filter((violation) => violation.severity === "error");
1490
- if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
1491
- if (errors.length > 0) {
1492
- console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
1493
- process.exit(1);
1494
- }
1495
- console.log(`conventions checker: all passes clean under ${root} (D4/D4b/D10 grammar, spec documents, C9 dead fixtures, C14/C15 fixture placement, B5 await-using, A7 database)${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
1858
+ const argv = process.argv.slice(2);
1859
+ const fix = argv.includes("--fix");
1860
+ const json = valueOf("--format") === "json";
1861
+ const member = valueOf("--member");
1862
+ const positional = argv.find((argument, index) => {
1863
+ if (argument.startsWith("--")) return false;
1864
+ const previous = argv[index - 1];
1865
+ return previous !== "--format" && previous !== "--member";
1866
+ });
1867
+ /** The value of `--flag value` or `--flag=value`, or undefined. */
1868
+ function valueOf(flag) {
1869
+ const inline = argv.find((argument) => argument.startsWith(`${flag}=`));
1870
+ if (inline !== void 0) return inline.slice(flag.length + 1);
1871
+ const index = argv.indexOf(flag);
1872
+ return index === -1 ? void 0 : argv[index + 1];
1873
+ }
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";
1876
+ /** The passes a member run puts over a package. */
1877
+ const MEMBER_PASSES = "E3 config-present, E5b no simulated DOM, F8 no seam dependency";
1878
+ /**
1879
+ * Print the findings and exit on the first error-level one. `passes` names the
1880
+ * ones that ACTUALLY ran: a member run that claimed the tree passes would be
1881
+ * telling the reader a `specs/` tree was judged when none was walked.
1882
+ */
1883
+ function report(violations, what, passes) {
1884
+ const errors = violations.filter((violation) => violation.severity === "error");
1885
+ if (json) {
1886
+ console.log(JSON.stringify(violations.map((violation) => ({
1887
+ code: `jterrazz-check(${violation.rule})`,
1888
+ file: violation.file,
1889
+ line: violation.line,
1890
+ message: violation.message,
1891
+ severity: violation.severity
1892
+ }))));
1893
+ process.exit(errors.length > 0 ? 1 : 0);
1894
+ }
1895
+ if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
1896
+ if (errors.length > 0) {
1897
+ console.error(`\nconventions checker: ${errors.length} error(s) found ${what}`);
1898
+ process.exit(1);
1899
+ }
1900
+ console.log(`conventions checker: all passes clean ${what} (${passes})${violations.length > 0 ? ` — ${violations.length} warning(s)` : ""}`);
1901
+ process.exit(0);
1902
+ }
1903
+ /** A directory that must exist — a typo'd path is operator error, not a clean tree. */
1904
+ function requireDirectory(path, what) {
1905
+ const resolved = resolve(path);
1906
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
1907
+ console.error(`conventions checker: no such ${what}: ${resolved}`);
1908
+ process.exit(1);
1909
+ }
1910
+ return resolved;
1911
+ }
1912
+ if (member !== void 0) {
1913
+ const root = resolve(positional ?? ".");
1914
+ const dir = requireDirectory(member, "member directory");
1915
+ report(checkMember(dir, root), `for member ${relative(root, dir) || "."}`, MEMBER_PASSES);
1916
+ }
1917
+ if (positional !== void 0) {
1918
+ 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
+ }
1924
+ report(runAllChecks(root), `under ${root}`, TREE_PASSES);
1925
+ }
1926
+ const root = resolve(".");
1927
+ const found = [];
1928
+ for (const specsRoot of discoverSpecRoots(root)) {
1929
+ const prefix = relative(root, specsRoot);
1930
+ for (const violation of runAllChecks(specsRoot)) found.push({
1931
+ ...violation,
1932
+ file: `${prefix}/${violation.file}`
1933
+ });
1934
+ }
1935
+ found.push(...checkMembers(root));
1936
+ report(found, `under ${root}`, `${TREE_PASSES}, ${MEMBER_PASSES}`);
1496
1937
  //#endregion
1497
1938
  export {};