@jterrazz/test 15.2.0 → 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
@@ -233,6 +234,23 @@ function collectLiterals(text) {
233
234
  }
234
235
  return found;
235
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
+ }
236
254
  /**
237
255
  * Does the text pass a NON-literal argument to a fixture-ish verb
238
256
  * (`.seed`/`.request`/`.fixture`/`toMatch`)? A template with an expression or a
@@ -423,6 +441,7 @@ function checkDockerRunnerAwaitUsing(rootDir) {
423
441
  file: rel,
424
442
  line,
425
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",
426
445
  severity: "error"
427
446
  });
428
447
  }
@@ -480,12 +499,14 @@ function checkDatabaseProperty(rootDir) {
480
499
  file: rel,
481
500
  line,
482
501
  message: `${rel}:${line}: .${verb}() must pass { database } — ${count} SQL databases are declared (A7 — see docs/13-linting.md)`,
502
+ rule: "a7",
483
503
  severity: "error"
484
504
  });
485
505
  else if (count === 1 && hasDatabase) violations.push({
486
506
  file: rel,
487
507
  line,
488
508
  message: `${rel}:${line}: .${verb}() must not pass { database } — a single SQL database is declared (A7 — see docs/13-linting.md)`,
509
+ rule: "a7",
489
510
  severity: "error"
490
511
  });
491
512
  }
@@ -493,7 +514,8 @@ function checkDatabaseProperty(rootDir) {
493
514
  return violations;
494
515
  }
495
516
  /** Is a top-level ground-subdir entry referenced by any test literal? */
496
- 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;
497
519
  for (const literal of literals) {
498
520
  if (entryIsDir) {
499
521
  if (literal === entry || literal.startsWith(`${entry}/`) || literal.includes(`/${entry}`)) return true;
@@ -534,11 +556,13 @@ function checkDeadFixtures(rootDir) {
534
556
  file: rel,
535
557
  line: 1,
536
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",
537
560
  severity: "error"
538
561
  });
539
562
  continue;
540
563
  }
541
564
  const literals = /* @__PURE__ */ new Set();
565
+ const patterns = [];
542
566
  let downgrade = false;
543
567
  for (const testFile of testFiles) {
544
568
  if (testFile.endsWith(".spec.yaml")) {
@@ -547,6 +571,7 @@ function checkDeadFixtures(rootDir) {
547
571
  }
548
572
  const text = readSource(testFile);
549
573
  for (const literal of collectLiterals(text)) literals.add(literal);
574
+ patterns.push(...collectLiteralPatterns(text));
550
575
  downgrade ||= hasNonLiteralFixtureArg(text);
551
576
  }
552
577
  const severity = downgrade ? "warn" : "error";
@@ -554,12 +579,13 @@ function checkDeadFixtures(rootDir) {
554
579
  const subPath = join(dir, sub);
555
580
  for (const entry of readdirSync(subPath, { withFileTypes: true })) {
556
581
  const entryIsDir = entry.isDirectory();
557
- if (entryReferenced(entry.name, entryIsDir, literals)) continue;
582
+ if (entryReferenced(entry.name, entryIsDir, literals, patterns)) continue;
558
583
  const relEntry = relative(rootDir, join(subPath, entry.name));
559
584
  violations.push({
560
585
  file: relEntry,
561
586
  line: 1,
562
587
  message: `${relEntry}: dead fixture — no test literal in ${feature} references ${sub}/${entry.name} (C9 — see docs/13-linting.md)`,
588
+ rule: "c9",
563
589
  severity
564
590
  });
565
591
  }
@@ -585,6 +611,7 @@ function checkPoolFixtures(rootDir) {
585
611
  file: relEntry,
586
612
  line: 1,
587
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",
588
615
  severity: "error"
589
616
  });
590
617
  }
@@ -688,6 +715,7 @@ function checkPoolFixtureSharing(rootDir) {
688
715
  file: relEntry,
689
716
  line: 1,
690
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",
691
719
  severity: "error"
692
720
  };
693
721
  });
@@ -741,6 +769,7 @@ function checkLocalFixtureReach(rootDir) {
741
769
  file: rel,
742
770
  line,
743
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",
744
773
  severity: "error"
745
774
  });
746
775
  }
@@ -749,6 +778,330 @@ function checkLocalFixtureReach(rootDir) {
749
778
  return violations;
750
779
  }
751
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
752
1105
  //#region src/lint/checker-spec.ts
753
1106
  function finding(rule, line, message, severity = "error") {
754
1107
  return {
@@ -1163,6 +1516,7 @@ function checkSpecConventions(text, rel, path) {
1163
1516
  file: rel,
1164
1517
  line,
1165
1518
  message: `${rel}:${line}: ${message} (${rule} — see docs/13-linting.md)`,
1519
+ rule: codeOf(rule),
1166
1520
  severity
1167
1521
  }));
1168
1522
  }
@@ -1194,6 +1548,7 @@ function checkSpecDescriptionsUnique(rootDir) {
1194
1548
  file: rel,
1195
1549
  line: document.descriptionLine,
1196
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",
1197
1552
  severity: "error"
1198
1553
  });
1199
1554
  }
@@ -1356,6 +1711,7 @@ function checkSpecFile(text, rel) {
1356
1711
  file: rel,
1357
1712
  line: error.line,
1358
1713
  message: `${error.message} (D4b — see docs/13-linting.md)`,
1714
+ rule: "d4b",
1359
1715
  severity: "error"
1360
1716
  }];
1361
1717
  }
@@ -1366,6 +1722,7 @@ function checkSpecFile(text, rel) {
1366
1722
  file: rel,
1367
1723
  line: at,
1368
1724
  message: `${rel}:${at}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
1725
+ rule: "d4",
1369
1726
  severity: "error",
1370
1727
  token
1371
1728
  });
@@ -1409,12 +1766,14 @@ function checkConventionFiles(rootDir) {
1409
1766
  file: rel,
1410
1767
  line: 1,
1411
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",
1412
1770
  severity: "error"
1413
1771
  });
1414
1772
  for (const { line, token } of findKnownTokens(text)) violations.push({
1415
1773
  file: rel,
1416
1774
  line,
1417
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",
1418
1777
  severity: "warn",
1419
1778
  token
1420
1779
  });
@@ -1426,12 +1785,14 @@ function checkConventionFiles(rootDir) {
1426
1785
  file: rel,
1427
1786
  line: 1,
1428
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",
1429
1789
  severity: "error"
1430
1790
  });
1431
1791
  for (const { line, token } of findUnknownTokens(text)) violations.push({
1432
1792
  file: rel,
1433
1793
  line,
1434
1794
  message: `${rel}:${line}: unknown token ${token} — the D4 vocabulary is frozen (known: ${[...TOKEN_KINDS].join(", ")})`,
1795
+ rule: "d4",
1435
1796
  severity: "error",
1436
1797
  token
1437
1798
  });
@@ -1466,13 +1827,27 @@ function formatViolations(violations) {
1466
1827
  /**
1467
1828
  * CLI entry for the conventions checker (bundled as `dist/checker.js`).
1468
1829
  *
1469
- * 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
1470
1833
  *
1471
- * 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
1472
1836
  * `<case>.spec.yaml` document conventions, and the cross-file passes (C9 dead
1473
1837
  * fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
1474
- * property). Exit 1 on any ERROR-level violation; warnings (D10, a downgraded C9
1475
- * 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.
1476
1851
  *
1477
1852
  * `--fix` applies the rewritable passes — the two document ones (key order and
1478
1853
  * block scalars) and C14, which MOVES a single-reader pool fixture beside its
@@ -1480,24 +1855,84 @@ function formatViolations(violations) {
1480
1855
  * run that fixes everything exits 0. The move is a plain rename: the checker
1481
1856
  * never runs git, and the author stages what the working tree now shows.
1482
1857
  */
1483
- const fix = process.argv.includes("--fix");
1484
- const root = resolve(process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ".");
1485
- if (!existsSync(root) || !statSync(root).isDirectory()) {
1486
- console.error(`conventions checker: no such directory: ${root}`);
1487
- process.exit(1);
1488
- }
1489
- if (fix) {
1490
- const written = fixSpecFiles(root);
1491
- if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
1492
- for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
1493
- }
1494
- const violations = runAllChecks(root);
1495
- const errors = violations.filter((violation) => violation.severity === "error");
1496
- if (violations.length > 0) (errors.length > 0 ? console.error : console.warn)(formatViolations(violations));
1497
- if (errors.length > 0) {
1498
- console.error(`\nconventions checker: ${errors.length} error(s) found under ${root}`);
1499
- process.exit(1);
1500
- }
1501
- 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}`);
1502
1937
  //#endregion
1503
1938
  export {};