@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/README.md +38 -29
- package/dist/ambiguity.js +23 -4
- package/dist/artifacts.js +108 -163
- package/dist/browser/index.js +2796 -0
- package/dist/checker.js +470 -29
- package/dist/index.d.ts +1434 -609
- package/dist/index.js +3696 -2745
- package/dist/intercept.js +117 -59
- package/dist/oxlint.cjs +481 -35
- package/dist/oxlint.d.cts +13 -1
- package/dist/oxlint.d.ts +13 -1
- package/dist/oxlint.js +481 -35
- package/dist/playwright.adapter.js +96 -22
- package/dist/{queue.js → stream.js} +61 -2
- package/dist/vitest.d.ts +130 -2
- package/dist/vitest.js +433 -3
- package/package.json +37 -5
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
|
-
/**
|
|
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]
|
|
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
|
-
*
|
|
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).
|
|
1469
|
-
*
|
|
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
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
const
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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 {};
|