@jterrazz/test 12.0.2 → 12.2.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/oxlint.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  //#region src/lint/fs-cache.ts
4
5
  /**
5
6
  * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
@@ -301,7 +302,7 @@ const RULE_DOCS = {
301
302
  },
302
303
  "b4-given-then": {
303
304
  channel: "statique",
304
- convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur.",
305
+ convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur. Dans un spec littéraire `<cas>.cli`, les deux marqueurs sont les lignes `given:` et `then:` de l’en-tête (passe checker `b4-cli-header`).",
305
306
  family: "B",
306
307
  id: "B4",
307
308
  rationale: "La narration Given/Then rend l’intention du test lisible sans lire les assertions."
@@ -336,7 +337,7 @@ const RULE_DOCS = {
336
337
  },
337
338
  "c1-domain-structure": {
338
339
  channel: "statique",
339
- convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`.",
340
+ convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`.",
340
341
  family: "C",
341
342
  id: "C1",
342
343
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
@@ -448,7 +449,7 @@ const RULE_DOCS = {
448
449
  },
449
450
  "f1-no-subpath-import": {
450
451
  channel: "statique",
451
- convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf le subpath tool-facing `@jterrazz/test/oxlint` (exempté partout).",
452
+ convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf les subpaths que la map `exports` du paquet publie — `@jterrazz/test/oxlint` (plugin de lint) et `@jterrazz/test/vitest` (surface de configuration du runner) — exemptés partout. La liste est LUE du manifeste, jamais recopiée dans la règle.",
452
453
  family: "F",
453
454
  id: "F1",
454
455
  rationale: "Un point d’entrée unique garde l’API publique explicite et les subpaths internes invisibles."
@@ -562,7 +563,7 @@ const RULE_DOCS = {
562
563
  const CHECKER_PASSES = [
563
564
  {
564
565
  channel: "checker",
565
- convention: "Tout `{{token}}` dans une fixture `expected/` appartient au vocabulaire figé ; un token inconnu est une erreur.",
566
+ convention: "Tout `{{token}}` dans une fixture `expected/` — ou dans les blocs d’un spec littéraire `<cas>.cli` — appartient au vocabulaire figé ; un token inconnu est une erreur.",
566
567
  family: "D",
567
568
  id: "D4",
568
569
  name: "d4-unknown-token",
@@ -584,6 +585,22 @@ const CHECKER_PASSES = [
584
585
  name: "d4b-http-first-line",
585
586
  rationale: "La ligne d’ouverture distingue une requête d’une réponse — la contraindre attrape les fichiers mal placés."
586
587
  },
588
+ {
589
+ channel: "checker",
590
+ convention: "Un spec littéraire `<cas>.cli` respecte sa grammaire : clés d’en-tête fermées (`test`, `given`, `then`, `fixture`, `env`, `serve`), première ligne de corps en `$ <commande>`, `exit: <entier>` juste après.",
591
+ family: "D",
592
+ id: "D4b",
593
+ name: "d4b-cli-shape",
594
+ rationale: "La grammaire est lue par le parseur du runner lui-même : le fichier que le lint accepte est exactement celui que le runner exécute."
595
+ },
596
+ {
597
+ channel: "checker",
598
+ convention: "Un spec littéraire `<cas>.cli` porte les trois lignes narratives `test:`, `given:` et `then:` ; il en manque une est une erreur.",
599
+ family: "B",
600
+ id: "B4",
601
+ name: "b4-cli-header",
602
+ rationale: "Le format littéraire est un test : la narration Given/Then y vit dans l’en-tête, pas dans des commentaires."
603
+ },
587
604
  {
588
605
  channel: "checker",
589
606
  convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
@@ -594,7 +611,7 @@ const CHECKER_PASSES = [
594
611
  },
595
612
  {
596
613
  channel: "checker",
597
- convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
614
+ convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` ni `*.cli` est orphelin (warning si argument non littéral). Un `<cas>.cli` référence les fixtures nommées par ses lignes `fixture:`.",
598
615
  family: "C",
599
616
  id: "C9",
600
617
  name: "c9-dead-fixtures",
@@ -1467,6 +1484,16 @@ const DEFAULT_DEPTH = "facet-domain";
1467
1484
  * - a `*.specification.ts` sits at the facet root — exactly
1468
1485
  * `specs/<facet>/<file>.specification.ts`, never inside a domain.
1469
1486
  *
1487
+ * `depth: 'facet'`
1488
+ *
1489
+ * The same facet root, one degree looser: the folder follows the assets. A
1490
+ * `*.test.ts` sits EITHER at the facet root (`specs/<facet>/<aspect>.test.ts`)
1491
+ * or one domain folder down (`specs/<facet>/<domain>/<aspect>.test.ts`) — a
1492
+ * test earns a domain folder when it carries assets of its own, and sits
1493
+ * beside its siblings when it does not. Anything deeper is rejected, and so is
1494
+ * a test loose at the specs root. A `*.specification.ts` sits at the facet
1495
+ * root, exactly as in `facet-domain`.
1496
+ *
1470
1497
  * `depth: 'mirror'`
1471
1498
  *
1472
1499
  * The tree mirrors something outside itself (a command tree, a source tree), so
@@ -1491,6 +1518,24 @@ const c1DomainStructure = {
1491
1518
  if (anchor === void 0) return;
1492
1519
  const base = anchor.relative.at(-1) ?? "";
1493
1520
  const nesting = anchor.relative.length - 1;
1521
+ if (depth === "facet") {
1522
+ if (base.endsWith(TEST_SUFFIX)) {
1523
+ if (nesting < 1) context.report({
1524
+ messageId: "testOutsideFacet",
1525
+ node
1526
+ });
1527
+ else if (nesting > 2) context.report({
1528
+ messageId: "testTooDeepForFacet",
1529
+ node
1530
+ });
1531
+ return;
1532
+ }
1533
+ if (base.endsWith(SPECIFICATION_SUFFIX) && nesting !== 1) context.report({
1534
+ messageId: "specNotAtFacetRoot",
1535
+ node
1536
+ });
1537
+ return;
1538
+ }
1494
1539
  if (depth === "mirror") {
1495
1540
  if (!base.endsWith(TEST_SUFFIX)) return;
1496
1541
  if (nesting < 1) {
@@ -1535,12 +1580,15 @@ const c1DomainStructure = {
1535
1580
  testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
1536
1581
  testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
1537
1582
  testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/10-linting.md).",
1538
- testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/10-linting.md)."
1583
+ testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/10-linting.md).",
1584
+ testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/10-linting.md).",
1585
+ testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/10-linting.md)."
1539
1586
  },
1540
1587
  schema: [{
1541
1588
  additionalProperties: false,
1542
1589
  properties: { depth: {
1543
1590
  enum: [
1591
+ "facet",
1544
1592
  "facet-domain",
1545
1593
  "mirror",
1546
1594
  "off"
@@ -2590,22 +2638,88 @@ const d9wSingleUseRef = {
2590
2638
  }
2591
2639
  };
2592
2640
  //#endregion
2641
+ //#region src/lint/package-exports.ts
2642
+ /**
2643
+ * The subpaths this package actually publishes, read from its OWN
2644
+ * `package.json` `exports` map.
2645
+ *
2646
+ * Rule F1 forbids importing `@jterrazz/test/<subpath>` — but a subpath the
2647
+ * package DECLARES is, by definition, part of its public contract
2648
+ * (`/oxlint` for the lint plugin, `/vitest` for the runner-config surface).
2649
+ * Deriving the exemption from the manifest rather than listing it in the rule
2650
+ * is what keeps the two from drifting: a subpath is exempt the moment it is
2651
+ * published, and stops being exempt the moment it is withdrawn — there is
2652
+ * nothing to remember to update.
2653
+ *
2654
+ * The manifest is found by walking up from THIS module, so the answer does not
2655
+ * depend on where the linted file sits: `<pkg>/dist/oxlint.js` in an install,
2656
+ * `<repo>/src/lint/` when the framework lints itself. `import.meta.url` is
2657
+ * safe in both bundle formats — the CJS build shims it to `__filename`.
2658
+ */
2659
+ const PACKAGE$2 = "@jterrazz/test";
2660
+ /** Manifests are read once per lint process. */
2661
+ let cached = null;
2662
+ /**
2663
+ * Walk up from `startDir` for the `package.json` that declares {@link PACKAGE}.
2664
+ * The FIRST manifest found decides: if it names another package, the walk has
2665
+ * already left this package's tree and there is nothing to find above it.
2666
+ */
2667
+ function findOwnManifest(startDir) {
2668
+ let dir = startDir;
2669
+ for (;;) {
2670
+ const text = readFileCached(join(dir, "package.json"));
2671
+ if (text !== null) try {
2672
+ const manifest = JSON.parse(text);
2673
+ return manifest.name === PACKAGE$2 ? manifest : null;
2674
+ } catch {
2675
+ return null;
2676
+ }
2677
+ const parent = dirname(dir);
2678
+ if (parent === dir) return null;
2679
+ dir = parent;
2680
+ }
2681
+ }
2682
+ /**
2683
+ * Every subpath specifier the package publishes — `@jterrazz/test/oxlint`,
2684
+ * `@jterrazz/test/vitest`, … The root export (`.`) is not a subpath and is
2685
+ * absent from the list.
2686
+ *
2687
+ * An unreadable manifest yields an EMPTY list: with no contract to read, no
2688
+ * subpath can be vouched for, and F1's plain rule applies.
2689
+ *
2690
+ * @internal Exported for unit tests.
2691
+ */
2692
+ function declaredSubpaths(startDir) {
2693
+ if (startDir === void 0 && cached !== null) return cached;
2694
+ const exports = findOwnManifest(startDir ?? dirname(fileURLToPath(import.meta.url)))?.exports;
2695
+ const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2696
+ if (startDir === void 0) cached = subpaths;
2697
+ return subpaths;
2698
+ }
2699
+ //#endregion
2593
2700
  //#region src/lint/rules/f1-no-subpath-import.ts
2594
2701
  const PACKAGE$1 = "@jterrazz/test";
2595
- const TOOL_SUBPATH$2 = "@jterrazz/test/oxlint";
2596
2702
  /**
2597
- * CONVENTIONS F1 — everything is imported from `@jterrazz/test`; subpaths do
2598
- * not exist. Sanctioned exception: `@jterrazz/test/oxlint`, the tool-facing,
2599
- * zero-runtime plugin entry — exempt from any file (an oxlint config, a shared
2600
- * preset, whatever wires the lint layer).
2703
+ * CONVENTIONS F1 — everything a SPEC needs is imported from `@jterrazz/test`;
2704
+ * internal subpaths do not exist.
2705
+ *
2706
+ * The exception is not a list kept here: it is the package's own `exports` map
2707
+ * (`/oxlint`, the tool-facing plugin entry; `/vitest`, the runner-config
2708
+ * surface `vitest.config.ts` imports). A published subpath IS the public
2709
+ * contract, so reading the manifest is what keeps the rule from outlawing an
2710
+ * import the package itself prescribes. Each is exempt from any file — an
2711
+ * oxlint config, a shared preset, a vitest config.
2601
2712
  */
2602
2713
  const f1NoSubpathImport = {
2603
2714
  create(context) {
2715
+ const allowed = new Set(declaredSubpaths());
2604
2716
  return { ...importSourceVisitor(({ node, source }) => {
2605
- if (!source.startsWith(`${PACKAGE$1}/`)) return;
2606
- if (source === TOOL_SUBPATH$2) return;
2717
+ if (!source.startsWith(`${PACKAGE$1}/`) || allowed.has(source)) return;
2607
2718
  context.report({
2608
- data: { source },
2719
+ data: {
2720
+ published: allowed.size === 0 ? "none" : [...allowed].sort().join(", "),
2721
+ source
2722
+ },
2609
2723
  messageId: "subpath",
2610
2724
  node
2611
2725
  });
@@ -2613,7 +2727,7 @@ const f1NoSubpathImport = {
2613
2727
  },
2614
2728
  meta: {
2615
2729
  docs: RULE_DOCS["f1-no-subpath-import"],
2616
- messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (F1 — see docs/10-linting.md; only the tool-facing @jterrazz/test/oxlint is exempt)." },
2730
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2617
2731
  type: "problem"
2618
2732
  }
2619
2733
  };
package/dist/queue.js CHANGED
@@ -1,297 +1,5 @@
1
- import { n as Matcher, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
2
- //#region src/core/matching/structural.ts
3
- /**
4
- * Structural comparison engine shared by every fixture matcher.
5
- *
6
- * Handles three kinds of "expected" values:
7
- * - plain JSON values → strict deep equality
8
- * - {@link Matcher} instances (code-side `match.*`)
9
- * - strings containing `{{placeholder}}` forms (file-side fixtures)
10
- *
11
- * One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
12
- * works in `expected/*.http` (body and headers), `expected/*.json`, and
13
- * text snapshots (`expected/*.txt`).
14
- *
15
- * Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
16
- * {@link CaptureScope} supplied by the caller.
17
- */
18
- const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
19
- const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
20
- const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
21
- const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
22
- const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
23
- const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
24
- const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
25
- const INT_SOURCE = String.raw`-?\d+`;
26
- const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
27
- const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
28
- const SHA_SOURCE = "[0-9a-f]{7,64}";
29
- const HEX_SOURCE = "[0-9a-fA-F]+";
30
- const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
31
- const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
32
- const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
33
- const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
34
- const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
35
- const EMBEDDED_SOURCES = {
36
- base64: BASE64_SOURCE,
37
- date: DATE_SOURCE,
38
- duration: DURATION_SOURCE,
39
- email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
40
- float: FLOAT_SOURCE,
41
- hex: HEX_SOURCE,
42
- int: INT_SOURCE,
43
- ip: IP_SOURCE,
44
- iso8601: ISO8601_SOURCE,
45
- number: NUMBER_SOURCE,
46
- path: String.raw`\.{0,2}\/[^\s"'<>]*`,
47
- port: PORT_SOURCE,
48
- semver: SEMVER_SOURCE,
49
- sha: SHA_SOURCE,
50
- time: TIME_SOURCE,
51
- ulid: ULID_SOURCE,
52
- url: URL_SOURCE,
53
- uuid: UUID_SOURCE
54
- };
55
- const wholeRe = (source) => new RegExp(`^(?:${source})$`);
56
- const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
57
- const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "g");
58
- /** Whether a fixture string contains at least one `{{placeholder}}`. */
59
- function hasPlaceholders(value) {
60
- PLACEHOLDER_RE.lastIndex = 0;
61
- return PLACEHOLDER_RE.test(value);
62
- }
63
- /** Key-order-independent stringification for captured-object comparison. */
64
- function stableStringify(value) {
65
- if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
66
- if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
67
- return JSON.stringify(value) ?? "undefined";
68
- }
69
- function capturedEquals(a, b) {
70
- if (Object.is(a, b)) return true;
71
- if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
72
- if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
73
- return false;
74
- }
75
- function recordRef(name, actual, scope) {
76
- if (scope.has(name)) return capturedEquals(scope.get(name), actual);
77
- scope.set(name, actual);
78
- return true;
79
- }
80
- function isPortValue(value) {
81
- return Number.isInteger(value) && value >= 0 && value <= 65535;
82
- }
83
- function kindMatches(kind, actual, scope) {
84
- switch (kind) {
85
- case "any": return true;
86
- case "float":
87
- if (typeof actual === "number") return Number.isFinite(actual);
88
- return typeof actual === "string" && WHOLE_RES.float.test(actual);
89
- case "int":
90
- if (typeof actual === "number") return Number.isInteger(actual);
91
- return typeof actual === "string" && WHOLE_RES.int.test(actual);
92
- case "number":
93
- if (typeof actual === "number") return Number.isFinite(actual);
94
- return typeof actual === "string" && WHOLE_RES.number.test(actual);
95
- case "port":
96
- if (typeof actual === "number") return isPortValue(actual);
97
- return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
98
- case "string": return typeof actual === "string";
99
- case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
100
- default: {
101
- const re = WHOLE_RES[kind];
102
- return re !== void 0 && typeof actual === "string" && re.test(actual);
103
- }
104
- }
105
- }
106
- function matcherMatches(matcher, actual, scope) {
107
- if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
108
- if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
109
- if (matcher.kind === "ref") {
110
- if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
111
- return recordRef(matcher.refName, actual, scope);
112
- }
113
- return kindMatches(matcher.kind, actual, scope);
114
- }
115
- function placeholderSource(kind, scope) {
116
- if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
117
- const source = EMBEDDED_SOURCES[kind];
118
- if (source) return source;
119
- return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
120
- }
121
- function escapeRegExp(text) {
122
- return text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
123
- }
124
- function parsePlaceholderString(expected, scope) {
125
- PLACEHOLDER_RE.lastIndex = 0;
126
- const refs = [];
127
- let source = "";
128
- let lastIndex = 0;
129
- let single = null;
130
- let count = 0;
131
- for (const found of expected.matchAll(PLACEHOLDER_RE)) {
132
- const kind = found.groups.kind;
133
- const ref = found.groups.ref;
134
- source += escapeRegExp(expected.slice(lastIndex, found.index));
135
- source += `(${placeholderSource(kind, scope)})`;
136
- refs.push({
137
- index: count,
138
- kind,
139
- ref
140
- });
141
- count++;
142
- lastIndex = found.index + found[0].length;
143
- if (found.index === 0 && found[0].length === expected.length) single = {
144
- kind,
145
- ref
146
- };
147
- }
148
- source += escapeRegExp(expected.slice(lastIndex));
149
- return {
150
- pattern: new RegExp(`^${source}$`),
151
- refs,
152
- single,
153
- source
154
- };
155
- }
156
- /**
157
- * Unanchored regex source matching a fixture string — plain text escaped
158
- * verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
159
- * declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
160
- */
161
- function placeholderPatternSource(expected) {
162
- return parsePlaceholderString(expected, new CaptureScope()).source;
163
- }
164
- function placeholderStringMatches(expected, actual, scope) {
165
- const parsed = parsePlaceholderString(expected, scope);
166
- if (parsed.single) {
167
- if (!kindMatches(parsed.single.kind, actual, scope)) return false;
168
- if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
169
- return true;
170
- }
171
- if (typeof actual !== "string" && typeof actual !== "number") return false;
172
- const text = String(actual);
173
- const found = parsed.pattern.exec(text);
174
- if (!found) return false;
175
- for (const entry of parsed.refs) {
176
- if (!entry.ref) continue;
177
- if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
178
- }
179
- return true;
180
- }
181
- function isPlainObject(value) {
182
- return value !== null && typeof value === "object" && !Array.isArray(value);
183
- }
184
- /**
185
- * Deep structural equality with matcher / placeholder support. Ref captures
186
- * are recorded in `scope` in traversal order (arrays left-to-right, object
187
- * keys in expected-key order).
188
- */
189
- function structuralEquals(expected, actual, scope) {
190
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
191
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
192
- if (Array.isArray(expected)) {
193
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
194
- return expected.every((item, i) => structuralEquals(item, actual[i], scope));
195
- }
196
- if (isPlainObject(expected)) {
197
- if (!isPlainObject(actual)) return false;
198
- const expectedKeys = Object.keys(expected);
199
- const actualKeys = Object.keys(actual);
200
- if (expectedKeys.length !== actualKeys.length) return false;
201
- return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
202
- }
203
- return Object.is(expected, actual);
204
- }
205
- /**
206
- * Deep structural SUBSET match (toMatchObject-style) with matcher /
207
- * placeholder support. Plain objects match when every expected key is present
208
- * and recursively subset-matches — the actual value may carry extra keys.
209
- * Arrays require equal length with each element subset-matched. Leaves fall
210
- * back to {@link structuralEquals} semantics (matchers, placeholders, strict
211
- * equality). Used by request-body filters on intercept triggers.
212
- */
213
- function structuralSubset(expected, actual, scope) {
214
- if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
215
- if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
216
- if (Array.isArray(expected)) {
217
- if (!Array.isArray(actual) || actual.length !== expected.length) return false;
218
- return expected.every((item, i) => structuralSubset(item, actual[i], scope));
219
- }
220
- if (isPlainObject(expected)) {
221
- if (!isPlainObject(actual)) return false;
222
- return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
223
- }
224
- return Object.is(expected, actual);
225
- }
226
- /**
227
- * Multi-line text comparison with `{{token}}` support — used by text
228
- * snapshots (`expected/*.txt`). Without placeholders this is strict equality.
229
- */
230
- function textEquals(expected, actual, scope) {
231
- if (!hasPlaceholders(expected)) return expected === actual;
232
- return placeholderStringMatches(expected, actual, scope);
233
- }
234
- /**
235
- * Render an expected value for failure diffs and serialization: Matcher
236
- * instances become their placeholder text, everything else is untouched.
237
- */
238
- function renderExpected(value) {
239
- if (value instanceof Matcher) return value.toString();
240
- if (Array.isArray(value)) return value.map((item) => renderExpected(item));
241
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
242
- return value;
243
- }
244
- /**
245
- * Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
246
- * every string leaf of a structured value. The structural mirror of the text
247
- * path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
248
- * `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
249
- * D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
250
- */
251
- function substituteWorkdirDeep(value, workdir) {
252
- if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
253
- if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
254
- if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
255
- return value;
256
- }
257
- /**
258
- * Update-mode merge: rewrite a fixture from the actual output while
259
- * preserving every segment of the previous fixture that was covered by a
260
- * placeholder (and still matches the actual value).
261
- *
262
- * Token preservation is symmetric with the text path (CONVENTIONS D5): a
263
- * previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
264
- * RAW actual value is kept; every other leaf is taken from the actual output
265
- * with the framework-known cwd substituted back to `{{workdir}}`.
266
- */
267
- function mergePreservingPlaceholders(previous, actual, workdir) {
268
- const scope = new CaptureScope(workdir);
269
- const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
270
- function merge(prev, act) {
271
- if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
272
- if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
273
- if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
274
- return subst(act);
275
- }
276
- return merge(previous, actual);
277
- }
278
- /**
279
- * Update-mode merge for text snapshots: line-by-line, a previous line whose
280
- * placeholders still match the actual line is preserved; every other line is
281
- * taken from the actual output. Values the framework knows to be dynamic
282
- * (`{{workdir}}`) are substituted automatically (CONVENTIONS D5).
283
- */
284
- function mergeTextPreservingPlaceholders(previous, actual, scope) {
285
- const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
286
- if (previous === null) return substituted;
287
- const prevLines = previous.split("\n");
288
- return substituted.split("\n").map((line, i) => {
289
- const prev = prevLines[i];
290
- if (prev === void 0 || !hasPlaceholders(prev)) return line;
291
- return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, actual.split("\n")[i] ?? line, new CaptureScope(scope.workdir)) ? prev : line;
292
- }).join("\n");
293
- }
294
- //#endregion
1
+ import { t as CaptureScope } from "./match.js";
2
+ import { F as textEquals, O as hasPlaceholders, j as placeholderPatternSource } from "./literate.js";
295
3
  //#region src/core/contracts/contract.ts
296
4
  /**
297
5
  * Declare one contract. Identity function — its value is the enforced shape
@@ -552,4 +260,4 @@ var ContractQueue = class {
552
260
  }
553
261
  };
554
262
  //#endregion
555
- export { isContract as a, mergeTextPreservingPlaceholders as c, structuralSubset as d, textEquals as f, defineContracts as i, renderExpected as l, contractsOf as n, isContracts as o, defineContract as r, mergePreservingPlaceholders as s, ContractQueue as t, structuralEquals as u };
263
+ export { isContract as a, defineContracts as i, contractsOf as n, isContracts as o, defineContract as r, ContractQueue as t };
@@ -0,0 +1,41 @@
1
+ //#region src/vitest/literate-plugin.d.ts
2
+ interface LiterateOptions {
3
+ /**
4
+ * Globs added to the project's test include. Default: every `.cli` file.
5
+ * Narrow it when a tree holds `.cli` files that are INPUTS to other specs
6
+ * (a deliberately-broken header) rather than scenarios to run.
7
+ */
8
+ include?: string[];
9
+ /**
10
+ * Path to the `*.specification.ts` whose exported `cli` runs the files —
11
+ * relative to the vite root, or absolute.
12
+ *
13
+ * It is stated, never guessed: a repository may declare several cli
14
+ * runners (different binaries, different service records), and there is no
15
+ * convention that could pick the right one without silently binding a
16
+ * scenario to the wrong command.
17
+ */
18
+ specification: string;
19
+ }
20
+ /**
21
+ * The shape vite consumes. Declared structurally rather than imported from
22
+ * `vite`, so the package's runner coupling stays the one dependency it already
23
+ * has (CONVENTIONS I1) — the object is assignable to vite's `Plugin`.
24
+ */
25
+ interface LiteratePlugin {
26
+ config: () => {
27
+ test: {
28
+ include: string[];
29
+ };
30
+ };
31
+ /** Vite hands the resolved root here — what a relative `specification` is resolved against. */
32
+ configResolved: (resolved: {
33
+ root: string;
34
+ }) => void;
35
+ enforce: 'pre';
36
+ load: (id: string) => null | string;
37
+ name: string;
38
+ }
39
+ declare function literate(options: LiterateOptions): LiteratePlugin;
40
+ //#endregion
41
+ export { type LiterateOptions, type LiteratePlugin, literate };
package/dist/vitest.js ADDED
@@ -0,0 +1,68 @@
1
+ import { t as literateTitle } from "./literate.js";
2
+ import { readFileSync } from "node:fs";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ //#region src/vitest/literate-plugin.ts
5
+ /**
6
+ * The `literate()` vite plugin — door one of the literate format.
7
+ *
8
+ * It makes a `<case>.cli` file a TEST FILE: the glob joins the project's test
9
+ * include, and each `.cli` is transformed into a one-test module that runs the
10
+ * file through the registered `cli` runner. The runner shows the `.cli` path as
11
+ * the file and the header's `test:` line as the title, so a failing scenario is
12
+ * opened where it is written — not in a `.test.ts` that merely points at it.
13
+ *
14
+ * ```typescript
15
+ * // vitest.config.ts
16
+ * import { defineConfig } from 'vitest/config';
17
+ * import { literate } from '@jterrazz/test/vitest';
18
+ *
19
+ * export default defineConfig({
20
+ * plugins: [literate({ specification: './specs/cli/cli.specification.ts' })],
21
+ * });
22
+ * ```
23
+ */
24
+ /** The `.cli` extension, matched on the id vite hands the plugin. */
25
+ const CLI_FILE = /\.cli(?:\?.*)?$/;
26
+ /**
27
+ * The module a `.cli` file becomes: one `test()` whose title is the header's
28
+ * `test:` line and whose body runs the whole file — header, servers, every
29
+ * block asserted.
30
+ *
31
+ * @internal Exported for unit tests.
32
+ */
33
+ function literateModule(content, filePath, specificationPath) {
34
+ const title = literateTitle(content, filePath);
35
+ return [
36
+ "import { test } from 'vitest';",
37
+ `import { cli } from ${JSON.stringify(specificationPath)};`,
38
+ "",
39
+ `test(${JSON.stringify(title)}, async () => {`,
40
+ ` await cli.run(${JSON.stringify(filePath)});`,
41
+ "});",
42
+ ""
43
+ ].join("\n");
44
+ }
45
+ /** Strip vite's query suffix (`?v=…`) from a module id. */
46
+ function cleanId(id) {
47
+ return id.split("?")[0];
48
+ }
49
+ function literate(options) {
50
+ const include = options.include ?? ["**/*.cli"];
51
+ let root = process.cwd();
52
+ return {
53
+ config: () => ({ test: { include } }),
54
+ configResolved: (resolved) => {
55
+ root = resolved.root;
56
+ },
57
+ enforce: "pre",
58
+ load: (id) => {
59
+ if (!CLI_FILE.test(id)) return null;
60
+ const filePath = cleanId(id);
61
+ const specificationPath = isAbsolute(options.specification) ? options.specification : resolve(root, options.specification);
62
+ return literateModule(readFileSync(filePath, "utf8"), filePath, specificationPath);
63
+ },
64
+ name: "jterrazz-test:literate"
65
+ };
66
+ }
67
+ //#endregion
68
+ export { literate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "12.0.2",
3
+ "version": "12.2.0",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",
@@ -35,6 +35,10 @@
35
35
  "types": "./dist/index.d.ts",
36
36
  "import": "./dist/index.js"
37
37
  },
38
+ "./vitest": {
39
+ "types": "./dist/vitest.d.ts",
40
+ "import": "./dist/vitest.js"
41
+ },
38
42
  "./oxlint": {
39
43
  "types": "./dist/oxlint.d.ts",
40
44
  "import": "./dist/oxlint.js",