@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/README.md +38 -29
- package/dist/artifacts.js +41 -4
- package/dist/browser/index.js +1364 -1045
- package/dist/checker.js +461 -26
- package/dist/index.d.ts +648 -133
- package/dist/index.js +726 -39
- package/dist/intercept.js +9 -1
- package/dist/oxlint.cjs +47 -14
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +47 -14
- package/dist/playwright.adapter.js +1 -0
- package/dist/{queue.js → stream.js} +60 -1
- package/dist/vitest.d.ts +38 -8
- package/dist/vitest.js +59 -1
- package/package.json +1 -1
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]
|
|
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
|
-
*
|
|
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).
|
|
1475
|
-
*
|
|
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
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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 {};
|