@jterrazz/test 12.1.0 → 13.0.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/match.js CHANGED
@@ -1,3 +1,75 @@
1
+ import { basename, dirname, resolve } from "node:path";
2
+ //#region src/core/specification/shared/fixtures.ts
3
+ /**
4
+ * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
5
+ *
6
+ * A fixture path is one of two shapes:
7
+ *
8
+ * - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
9
+ * where `<specs-root>` is the nearest ancestor directory named `specs`.
10
+ * - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
11
+ *
12
+ * Any other `$`-prefixed marker is a usage error (listed against the known
13
+ * markers). Copy semantics mirror rsync's trailing-slash rule (see
14
+ * {@link copyPlan}).
15
+ */
16
+ /**
17
+ * Markers understood in a `.fixture()` path. Extend here + in
18
+ * {@link resolveFixtureSource}. Exported so the lint layer (rule
19
+ * `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
20
+ */
21
+ const KNOWN_FIXTURE_MARKERS = ["$FIXTURES"];
22
+ /**
23
+ * Walk up from `startDir` to the nearest ancestor directory named `specs`.
24
+ * Throws with guidance if none is found up to the filesystem root — the
25
+ * `$FIXTURES` marker is meaningless without a specs root.
26
+ */
27
+ function discoverSpecsRoot(startDir) {
28
+ let dir = startDir;
29
+ for (;;) {
30
+ if (basename(dir) === "specs") return dir;
31
+ const parent = dirname(dir);
32
+ if (parent === dir) break;
33
+ dir = parent;
34
+ }
35
+ throw new Error(`.fixture(): the $FIXTURES marker resolves to <specs-root>/fixtures, but no directory named "specs" was found walking up from ${startDir}. Move the specification under a specs/ directory, or use a feature-local fixtures/ path (no $FIXTURES marker).`);
36
+ }
37
+ /**
38
+ * Resolve the absolute source path of a fixture, applying marker resolution.
39
+ * The trailing slash (spread semantics) is preserved by callers via
40
+ * {@link copyPlan} — it is irrelevant to source resolution.
41
+ */
42
+ function resolveFixtureSource(path, testDir) {
43
+ const clean = path.replace(/\/+$/, "");
44
+ if (clean === "$FIXTURES" || path.startsWith("$FIXTURES/")) {
45
+ const rest = clean.slice(9).replace(/^\/+/, "");
46
+ return resolve(discoverSpecsRoot(testDir), "fixtures", rest);
47
+ }
48
+ if (path.startsWith("$")) {
49
+ const marker = clean.split("/")[0];
50
+ throw new Error(`.fixture("${path}"): unknown marker "${marker}". Known markers: ${KNOWN_FIXTURE_MARKERS.join(", ")}. A path without a marker is feature-local (resolved under <test-dir>/fixtures/).`);
51
+ }
52
+ return resolve(testDir, "fixtures", clean);
53
+ }
54
+ /**
55
+ * Compute the source + destination for copying a fixture into the working
56
+ * directory. Copy semantics follow rsync's trailing-slash rule:
57
+ *
58
+ * - `dir/` (trailing slash) — the CONTENTS are spread into the cwd.
59
+ * - `dir` (no slash, a directory) — the directory is copied as `<cwd>/<basename>`.
60
+ * - `file` — copied as `<cwd>/<basename>`.
61
+ *
62
+ * Chained `.fixture()` calls layer in order: a later fixture overwrites files
63
+ * written by an earlier one.
64
+ */
65
+ function copyPlan(path, testDir, workDir) {
66
+ const src = resolveFixtureSource(path, testDir);
67
+ return {
68
+ dest: /\/+$/.test(path) ? workDir : resolve(workDir, basename(src)),
69
+ src
70
+ };
71
+ }
72
+ //#endregion
1
73
  //#region src/core/matching/match.ts
2
74
  /** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
3
75
  const TOKEN_KINDS = [
@@ -161,4 +233,4 @@ var CaptureScope = class {
161
233
  }
162
234
  };
163
235
  //#endregion
164
- export { match as i, Matcher as n, TOKEN_KINDS as r, CaptureScope as t };
236
+ export { copyPlan as a, match as i, Matcher as n, resolveFixtureSource as o, TOKEN_KINDS as r, CaptureScope as t };
package/dist/oxlint.cjs CHANGED
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  let node_path = require("node:path");
6
6
  let node_fs = require("node:fs");
7
+ let node_url = require("node:url");
7
8
  //#region src/lint/fs-cache.ts
8
9
  /**
9
10
  * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
@@ -305,7 +306,7 @@ const RULE_DOCS = {
305
306
  },
306
307
  "b4-given-then": {
307
308
  channel: "statique",
308
- convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur.",
309
+ convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur. Un document `<cas>.spec.yaml` n’a pas de commentaires : sa narration est sa `description:`.",
309
310
  family: "B",
310
311
  id: "B4",
311
312
  rationale: "La narration Given/Then rend l’intention du test lisible sans lire les assertions."
@@ -452,7 +453,7 @@ const RULE_DOCS = {
452
453
  },
453
454
  "f1-no-subpath-import": {
454
455
  channel: "statique",
455
- 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).",
456
+ 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.",
456
457
  family: "F",
457
458
  id: "F1",
458
459
  rationale: "Un point d’entrée unique garde l’API publique explicite et les subpaths internes invisibles."
@@ -566,7 +567,7 @@ const RULE_DOCS = {
566
567
  const CHECKER_PASSES = [
567
568
  {
568
569
  channel: "checker",
569
- convention: "Tout `{{token}}` dans une fixture `expected/` appartient au vocabulaire figé ; un token inconnu est une erreur.",
570
+ convention: "Tout `{{token}}` dans une fixture `expected/` — ou dans un flux attendu d’un document `<cas>.spec.yaml` — appartient au vocabulaire figé ; un token inconnu est une erreur.",
570
571
  family: "D",
571
572
  id: "D4",
572
573
  name: "d4-unknown-token",
@@ -588,6 +589,94 @@ const CHECKER_PASSES = [
588
589
  name: "d4b-http-first-line",
589
590
  rationale: "La ligne d’ouverture distingue une requête d’une réponse — la contraindre attrape les fichiers mal placés."
590
591
  },
592
+ {
593
+ channel: "checker",
594
+ convention: "Un document `<cas>.spec.yaml` respecte sa grammaire : clés fermées au niveau du document (`kind`, `description`, `fixture`, `env`, `serve`, `runs`) comme dans un run (`command`, `stdin`, `timeout`, `waitFor`, `exit`, `stdout`, `stderr`, `files`), `description:` et `runs:` obligatoires, `command:` et `exit:` obligatoires dans chaque run, `exit:` entier littéral, `waitFor:` seulement sur le dernier run et jamais avec `stdin:`, chemin `files:` relatif et sous le workdir.",
595
+ family: "D",
596
+ id: "D4b",
597
+ name: "d4b-spec-shape",
598
+ rationale: "La grammaire est lue par le parseur du runner lui-même : le document que le lint accepte est exactement celui que le runner exécute."
599
+ },
600
+ {
601
+ channel: "checker",
602
+ convention: "Les clés d’un `<cas>.spec.yaml` suivent l’ordre canonique — `kind, description, fixture, env, serve, runs` au niveau du document, `command, stdin, timeout, waitFor, exit, stdout, stderr, files` dans un run. Corrigeable (`--fix`).",
603
+ family: "D",
604
+ id: "D4b",
605
+ name: "d4b-spec-key-order",
606
+ rationale: "Le décor avant la session, l’entrée avant la sortie : un ordre unique rend les documents comparables et supprime les diffs qui ne changent rien."
607
+ },
608
+ {
609
+ channel: "checker",
610
+ convention: "`stdout`, `stderr`, `stdin` et `files.*.equals` d’un `<cas>.spec.yaml` s’écrivent en scalaire bloc (`|` garde le saut de ligne final, `|-` le supprime), jamais en chaîne entre guillemets avec des `\\n`. Corrigeable (`--fix`).",
611
+ family: "D",
612
+ id: "D4b",
613
+ name: "d4b-spec-block-scalar",
614
+ rationale: "Une sortie attendue doit ressembler à une sortie — une golden sur une ligne est illisible et aucun diff ne peut la montrer."
615
+ },
616
+ {
617
+ channel: "checker",
618
+ convention: "Un document se nomme `<cas>.spec.yaml` : `<cas>` en kebab-case, sans les mots `test`/`spec`/`cli` que le suffixe porte déjà, et jamais le nom nu de son dossier.",
619
+ family: "C",
620
+ id: "C12",
621
+ name: "c12-spec-file-name",
622
+ rationale: "Le nom du fichier est la première phrase que le lecteur lit ; `rm/rm.spec.yaml` ne dit rien deux fois."
623
+ },
624
+ {
625
+ channel: "checker",
626
+ convention: "La `description:` d’un document est un titre : une seule ligne, initiale minuscule (identifiant tout en majuscules exempté), sans point final, moins de 100 caractères.",
627
+ family: "J",
628
+ id: "J5",
629
+ name: "j5-spec-description",
630
+ rationale: "La description EST le titre vitest — les règles du titre s’y appliquent, pas celles d’un paragraphe."
631
+ },
632
+ {
633
+ channel: "checker",
634
+ convention: "Deux `<cas>.spec.yaml` d’un même dossier ne partagent pas leur `description:`.",
635
+ family: "J",
636
+ id: "J4",
637
+ name: "j4-spec-description-unique",
638
+ rationale: "Le rapporteur nomme le titre : deux titres identiques laissent deux fichiers à ouvrir."
639
+ },
640
+ {
641
+ channel: "checker",
642
+ convention: "Un flux attendu ne contient pas de valeur volatile littérale : origine loopback avec port (`127.0.0.1:8080`, `localhost:3000`), chemin temporaire absolu (`/tmp/`, `/private/tmp/`, `/var/folders/`) ou chemin home (`/Users/…`, `/home/…`). Le message nomme le token à écrire.",
643
+ family: "D",
644
+ id: "D5",
645
+ name: "d5-spec-volatile-literal",
646
+ rationale: "C’est exactement la forme qu’un `TEST_UPDATE` aurait tokenisée : en trouver une signifie qu’un token a été écrasé à la main."
647
+ },
648
+ {
649
+ channel: "checker",
650
+ convention: "Un horodatage ISO-8601 ou un uuid littéral dans un flux attendu → warning, sauf si la même valeur apparaît dans un `fixture:` ou un `stdin:` du même document (elle est alors fixée, pas volatile).",
651
+ family: "D",
652
+ id: "D5",
653
+ name: "d5w-spec-pinned-value",
654
+ rationale: "Le défaut n’est pas le littéral : c’est le littéral que rien n’a fixé."
655
+ },
656
+ {
657
+ channel: "checker",
658
+ convention: "Un flux attendu dont tout le contenu est `{{any}}` → warning : le run s’exécute et ne prouve rien.",
659
+ family: "J",
660
+ id: "J3",
661
+ name: "j3w-spec-empty-assertion",
662
+ rationale: "Le miroir de J3 pour les documents — une assertion qui accepte tout est une assertion absente."
663
+ },
664
+ {
665
+ channel: "checker",
666
+ convention: "Un run dont `exit:` est non nul sans `stdout:` ni `stderr:` → warning : le document ne dit pas pourquoi la commande a refusé.",
667
+ family: "D",
668
+ id: "D11",
669
+ name: "d11w-spec-silent-refusal",
670
+ rationale: "Un refus muet est soit un défaut du produit, soit une golden qui a oublié que les mots partaient sur stderr."
671
+ },
672
+ {
673
+ channel: "checker",
674
+ convention: "Les mots nus d’`env:` et les noms de `serve:` d’un document sont des clés des objets `env`/`serve` d’un `specification.cli(…)` du plus proche dossier portant des `*.specification.ts` (lecture statique des littéraux ; ignoré sinon).",
675
+ family: "C",
676
+ id: "C8",
677
+ name: "c8-spec-registered-name",
678
+ rationale: "Une faute de frappe (`serve: dashbord`) échoue aujourd’hui à l’exécution, un fichier de test plus tard ; ici c’est une ligne de lint."
679
+ },
591
680
  {
592
681
  channel: "checker",
593
682
  convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
@@ -598,7 +687,7 @@ const CHECKER_PASSES = [
598
687
  },
599
688
  {
600
689
  channel: "checker",
601
- 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).",
690
+ 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 `*.spec.yaml` est orphelin (warning si argument non littéral). Un `<cas>.spec.yaml` référence les fixtures nommées par ses entrées `fixture:`.",
602
691
  family: "C",
603
692
  id: "C9",
604
693
  name: "c9-dead-fixtures",
@@ -2625,22 +2714,88 @@ const d9wSingleUseRef = {
2625
2714
  }
2626
2715
  };
2627
2716
  //#endregion
2717
+ //#region src/lint/package-exports.ts
2718
+ /**
2719
+ * The subpaths this package actually publishes, read from its OWN
2720
+ * `package.json` `exports` map.
2721
+ *
2722
+ * Rule F1 forbids importing `@jterrazz/test/<subpath>` — but a subpath the
2723
+ * package DECLARES is, by definition, part of its public contract
2724
+ * (`/oxlint` for the lint plugin, `/vitest` for the runner-config surface).
2725
+ * Deriving the exemption from the manifest rather than listing it in the rule
2726
+ * is what keeps the two from drifting: a subpath is exempt the moment it is
2727
+ * published, and stops being exempt the moment it is withdrawn — there is
2728
+ * nothing to remember to update.
2729
+ *
2730
+ * The manifest is found by walking up from THIS module, so the answer does not
2731
+ * depend on where the linted file sits: `<pkg>/dist/oxlint.js` in an install,
2732
+ * `<repo>/src/lint/` when the framework lints itself. `import.meta.url` is
2733
+ * safe in both bundle formats — the CJS build shims it to `__filename`.
2734
+ */
2735
+ const PACKAGE$2 = "@jterrazz/test";
2736
+ /** Manifests are read once per lint process. */
2737
+ let cached = null;
2738
+ /**
2739
+ * Walk up from `startDir` for the `package.json` that declares {@link PACKAGE}.
2740
+ * The FIRST manifest found decides: if it names another package, the walk has
2741
+ * already left this package's tree and there is nothing to find above it.
2742
+ */
2743
+ function findOwnManifest(startDir) {
2744
+ let dir = startDir;
2745
+ for (;;) {
2746
+ const text = readFileCached((0, node_path.join)(dir, "package.json"));
2747
+ if (text !== null) try {
2748
+ const manifest = JSON.parse(text);
2749
+ return manifest.name === PACKAGE$2 ? manifest : null;
2750
+ } catch {
2751
+ return null;
2752
+ }
2753
+ const parent = (0, node_path.dirname)(dir);
2754
+ if (parent === dir) return null;
2755
+ dir = parent;
2756
+ }
2757
+ }
2758
+ /**
2759
+ * Every subpath specifier the package publishes — `@jterrazz/test/oxlint`,
2760
+ * `@jterrazz/test/vitest`, … The root export (`.`) is not a subpath and is
2761
+ * absent from the list.
2762
+ *
2763
+ * An unreadable manifest yields an EMPTY list: with no contract to read, no
2764
+ * subpath can be vouched for, and F1's plain rule applies.
2765
+ *
2766
+ * @internal Exported for unit tests.
2767
+ */
2768
+ function declaredSubpaths(startDir) {
2769
+ if (startDir === void 0 && cached !== null) return cached;
2770
+ const exports = findOwnManifest(startDir ?? (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)))?.exports;
2771
+ const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2772
+ if (startDir === void 0) cached = subpaths;
2773
+ return subpaths;
2774
+ }
2775
+ //#endregion
2628
2776
  //#region src/lint/rules/f1-no-subpath-import.ts
2629
2777
  const PACKAGE$1 = "@jterrazz/test";
2630
- const TOOL_SUBPATH$2 = "@jterrazz/test/oxlint";
2631
2778
  /**
2632
- * CONVENTIONS F1 — everything is imported from `@jterrazz/test`; subpaths do
2633
- * not exist. Sanctioned exception: `@jterrazz/test/oxlint`, the tool-facing,
2634
- * zero-runtime plugin entry — exempt from any file (an oxlint config, a shared
2635
- * preset, whatever wires the lint layer).
2779
+ * CONVENTIONS F1 — everything a SPEC needs is imported from `@jterrazz/test`;
2780
+ * internal subpaths do not exist.
2781
+ *
2782
+ * The exception is not a list kept here: it is the package's own `exports` map
2783
+ * (`/oxlint`, the tool-facing plugin entry; `/vitest`, the runner-config
2784
+ * surface `vitest.config.ts` imports). A published subpath IS the public
2785
+ * contract, so reading the manifest is what keeps the rule from outlawing an
2786
+ * import the package itself prescribes. Each is exempt from any file — an
2787
+ * oxlint config, a shared preset, a vitest config.
2636
2788
  */
2637
2789
  const f1NoSubpathImport = {
2638
2790
  create(context) {
2791
+ const allowed = new Set(declaredSubpaths());
2639
2792
  return { ...importSourceVisitor(({ node, source }) => {
2640
- if (!source.startsWith(`${PACKAGE$1}/`)) return;
2641
- if (source === TOOL_SUBPATH$2) return;
2793
+ if (!source.startsWith(`${PACKAGE$1}/`) || allowed.has(source)) return;
2642
2794
  context.report({
2643
- data: { source },
2795
+ data: {
2796
+ published: allowed.size === 0 ? "none" : [...allowed].sort().join(", "),
2797
+ source
2798
+ },
2644
2799
  messageId: "subpath",
2645
2800
  node
2646
2801
  });
@@ -2648,7 +2803,7 @@ const f1NoSubpathImport = {
2648
2803
  },
2649
2804
  meta: {
2650
2805
  docs: RULE_DOCS["f1-no-subpath-import"],
2651
- 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)." },
2806
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2652
2807
  type: "problem"
2653
2808
  }
2654
2809
  };
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. Un document `<cas>.spec.yaml` n’a pas de commentaires : sa narration est sa `description:`.",
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."
@@ -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 un flux attendu d’un document `<cas>.spec.yaml` — 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,94 @@ 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 document `<cas>.spec.yaml` respecte sa grammaire : clés fermées au niveau du document (`kind`, `description`, `fixture`, `env`, `serve`, `runs`) comme dans un run (`command`, `stdin`, `timeout`, `waitFor`, `exit`, `stdout`, `stderr`, `files`), `description:` et `runs:` obligatoires, `command:` et `exit:` obligatoires dans chaque run, `exit:` entier littéral, `waitFor:` seulement sur le dernier run et jamais avec `stdin:`, chemin `files:` relatif et sous le workdir.",
591
+ family: "D",
592
+ id: "D4b",
593
+ name: "d4b-spec-shape",
594
+ rationale: "La grammaire est lue par le parseur du runner lui-même : le document que le lint accepte est exactement celui que le runner exécute."
595
+ },
596
+ {
597
+ channel: "checker",
598
+ convention: "Les clés d’un `<cas>.spec.yaml` suivent l’ordre canonique — `kind, description, fixture, env, serve, runs` au niveau du document, `command, stdin, timeout, waitFor, exit, stdout, stderr, files` dans un run. Corrigeable (`--fix`).",
599
+ family: "D",
600
+ id: "D4b",
601
+ name: "d4b-spec-key-order",
602
+ rationale: "Le décor avant la session, l’entrée avant la sortie : un ordre unique rend les documents comparables et supprime les diffs qui ne changent rien."
603
+ },
604
+ {
605
+ channel: "checker",
606
+ convention: "`stdout`, `stderr`, `stdin` et `files.*.equals` d’un `<cas>.spec.yaml` s’écrivent en scalaire bloc (`|` garde le saut de ligne final, `|-` le supprime), jamais en chaîne entre guillemets avec des `\\n`. Corrigeable (`--fix`).",
607
+ family: "D",
608
+ id: "D4b",
609
+ name: "d4b-spec-block-scalar",
610
+ rationale: "Une sortie attendue doit ressembler à une sortie — une golden sur une ligne est illisible et aucun diff ne peut la montrer."
611
+ },
612
+ {
613
+ channel: "checker",
614
+ convention: "Un document se nomme `<cas>.spec.yaml` : `<cas>` en kebab-case, sans les mots `test`/`spec`/`cli` que le suffixe porte déjà, et jamais le nom nu de son dossier.",
615
+ family: "C",
616
+ id: "C12",
617
+ name: "c12-spec-file-name",
618
+ rationale: "Le nom du fichier est la première phrase que le lecteur lit ; `rm/rm.spec.yaml` ne dit rien deux fois."
619
+ },
620
+ {
621
+ channel: "checker",
622
+ convention: "La `description:` d’un document est un titre : une seule ligne, initiale minuscule (identifiant tout en majuscules exempté), sans point final, moins de 100 caractères.",
623
+ family: "J",
624
+ id: "J5",
625
+ name: "j5-spec-description",
626
+ rationale: "La description EST le titre vitest — les règles du titre s’y appliquent, pas celles d’un paragraphe."
627
+ },
628
+ {
629
+ channel: "checker",
630
+ convention: "Deux `<cas>.spec.yaml` d’un même dossier ne partagent pas leur `description:`.",
631
+ family: "J",
632
+ id: "J4",
633
+ name: "j4-spec-description-unique",
634
+ rationale: "Le rapporteur nomme le titre : deux titres identiques laissent deux fichiers à ouvrir."
635
+ },
636
+ {
637
+ channel: "checker",
638
+ convention: "Un flux attendu ne contient pas de valeur volatile littérale : origine loopback avec port (`127.0.0.1:8080`, `localhost:3000`), chemin temporaire absolu (`/tmp/`, `/private/tmp/`, `/var/folders/`) ou chemin home (`/Users/…`, `/home/…`). Le message nomme le token à écrire.",
639
+ family: "D",
640
+ id: "D5",
641
+ name: "d5-spec-volatile-literal",
642
+ rationale: "C’est exactement la forme qu’un `TEST_UPDATE` aurait tokenisée : en trouver une signifie qu’un token a été écrasé à la main."
643
+ },
644
+ {
645
+ channel: "checker",
646
+ convention: "Un horodatage ISO-8601 ou un uuid littéral dans un flux attendu → warning, sauf si la même valeur apparaît dans un `fixture:` ou un `stdin:` du même document (elle est alors fixée, pas volatile).",
647
+ family: "D",
648
+ id: "D5",
649
+ name: "d5w-spec-pinned-value",
650
+ rationale: "Le défaut n’est pas le littéral : c’est le littéral que rien n’a fixé."
651
+ },
652
+ {
653
+ channel: "checker",
654
+ convention: "Un flux attendu dont tout le contenu est `{{any}}` → warning : le run s’exécute et ne prouve rien.",
655
+ family: "J",
656
+ id: "J3",
657
+ name: "j3w-spec-empty-assertion",
658
+ rationale: "Le miroir de J3 pour les documents — une assertion qui accepte tout est une assertion absente."
659
+ },
660
+ {
661
+ channel: "checker",
662
+ convention: "Un run dont `exit:` est non nul sans `stdout:` ni `stderr:` → warning : le document ne dit pas pourquoi la commande a refusé.",
663
+ family: "D",
664
+ id: "D11",
665
+ name: "d11w-spec-silent-refusal",
666
+ rationale: "Un refus muet est soit un défaut du produit, soit une golden qui a oublié que les mots partaient sur stderr."
667
+ },
668
+ {
669
+ channel: "checker",
670
+ convention: "Les mots nus d’`env:` et les noms de `serve:` d’un document sont des clés des objets `env`/`serve` d’un `specification.cli(…)` du plus proche dossier portant des `*.specification.ts` (lecture statique des littéraux ; ignoré sinon).",
671
+ family: "C",
672
+ id: "C8",
673
+ name: "c8-spec-registered-name",
674
+ rationale: "Une faute de frappe (`serve: dashbord`) échoue aujourd’hui à l’exécution, un fichier de test plus tard ; ici c’est une ligne de lint."
675
+ },
587
676
  {
588
677
  channel: "checker",
589
678
  convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
@@ -594,7 +683,7 @@ const CHECKER_PASSES = [
594
683
  },
595
684
  {
596
685
  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).",
686
+ 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 `*.spec.yaml` est orphelin (warning si argument non littéral). Un `<cas>.spec.yaml` référence les fixtures nommées par ses entrées `fixture:`.",
598
687
  family: "C",
599
688
  id: "C9",
600
689
  name: "c9-dead-fixtures",
@@ -2621,22 +2710,88 @@ const d9wSingleUseRef = {
2621
2710
  }
2622
2711
  };
2623
2712
  //#endregion
2713
+ //#region src/lint/package-exports.ts
2714
+ /**
2715
+ * The subpaths this package actually publishes, read from its OWN
2716
+ * `package.json` `exports` map.
2717
+ *
2718
+ * Rule F1 forbids importing `@jterrazz/test/<subpath>` — but a subpath the
2719
+ * package DECLARES is, by definition, part of its public contract
2720
+ * (`/oxlint` for the lint plugin, `/vitest` for the runner-config surface).
2721
+ * Deriving the exemption from the manifest rather than listing it in the rule
2722
+ * is what keeps the two from drifting: a subpath is exempt the moment it is
2723
+ * published, and stops being exempt the moment it is withdrawn — there is
2724
+ * nothing to remember to update.
2725
+ *
2726
+ * The manifest is found by walking up from THIS module, so the answer does not
2727
+ * depend on where the linted file sits: `<pkg>/dist/oxlint.js` in an install,
2728
+ * `<repo>/src/lint/` when the framework lints itself. `import.meta.url` is
2729
+ * safe in both bundle formats — the CJS build shims it to `__filename`.
2730
+ */
2731
+ const PACKAGE$2 = "@jterrazz/test";
2732
+ /** Manifests are read once per lint process. */
2733
+ let cached = null;
2734
+ /**
2735
+ * Walk up from `startDir` for the `package.json` that declares {@link PACKAGE}.
2736
+ * The FIRST manifest found decides: if it names another package, the walk has
2737
+ * already left this package's tree and there is nothing to find above it.
2738
+ */
2739
+ function findOwnManifest(startDir) {
2740
+ let dir = startDir;
2741
+ for (;;) {
2742
+ const text = readFileCached(join(dir, "package.json"));
2743
+ if (text !== null) try {
2744
+ const manifest = JSON.parse(text);
2745
+ return manifest.name === PACKAGE$2 ? manifest : null;
2746
+ } catch {
2747
+ return null;
2748
+ }
2749
+ const parent = dirname(dir);
2750
+ if (parent === dir) return null;
2751
+ dir = parent;
2752
+ }
2753
+ }
2754
+ /**
2755
+ * Every subpath specifier the package publishes — `@jterrazz/test/oxlint`,
2756
+ * `@jterrazz/test/vitest`, … The root export (`.`) is not a subpath and is
2757
+ * absent from the list.
2758
+ *
2759
+ * An unreadable manifest yields an EMPTY list: with no contract to read, no
2760
+ * subpath can be vouched for, and F1's plain rule applies.
2761
+ *
2762
+ * @internal Exported for unit tests.
2763
+ */
2764
+ function declaredSubpaths(startDir) {
2765
+ if (startDir === void 0 && cached !== null) return cached;
2766
+ const exports = findOwnManifest(startDir ?? dirname(fileURLToPath(import.meta.url)))?.exports;
2767
+ const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2768
+ if (startDir === void 0) cached = subpaths;
2769
+ return subpaths;
2770
+ }
2771
+ //#endregion
2624
2772
  //#region src/lint/rules/f1-no-subpath-import.ts
2625
2773
  const PACKAGE$1 = "@jterrazz/test";
2626
- const TOOL_SUBPATH$2 = "@jterrazz/test/oxlint";
2627
2774
  /**
2628
- * CONVENTIONS F1 — everything is imported from `@jterrazz/test`; subpaths do
2629
- * not exist. Sanctioned exception: `@jterrazz/test/oxlint`, the tool-facing,
2630
- * zero-runtime plugin entry — exempt from any file (an oxlint config, a shared
2631
- * preset, whatever wires the lint layer).
2775
+ * CONVENTIONS F1 — everything a SPEC needs is imported from `@jterrazz/test`;
2776
+ * internal subpaths do not exist.
2777
+ *
2778
+ * The exception is not a list kept here: it is the package's own `exports` map
2779
+ * (`/oxlint`, the tool-facing plugin entry; `/vitest`, the runner-config
2780
+ * surface `vitest.config.ts` imports). A published subpath IS the public
2781
+ * contract, so reading the manifest is what keeps the rule from outlawing an
2782
+ * import the package itself prescribes. Each is exempt from any file — an
2783
+ * oxlint config, a shared preset, a vitest config.
2632
2784
  */
2633
2785
  const f1NoSubpathImport = {
2634
2786
  create(context) {
2787
+ const allowed = new Set(declaredSubpaths());
2635
2788
  return { ...importSourceVisitor(({ node, source }) => {
2636
- if (!source.startsWith(`${PACKAGE$1}/`)) return;
2637
- if (source === TOOL_SUBPATH$2) return;
2789
+ if (!source.startsWith(`${PACKAGE$1}/`) || allowed.has(source)) return;
2638
2790
  context.report({
2639
- data: { source },
2791
+ data: {
2792
+ published: allowed.size === 0 ? "none" : [...allowed].sort().join(", "),
2793
+ source
2794
+ },
2640
2795
  messageId: "subpath",
2641
2796
  node
2642
2797
  });
@@ -2644,7 +2799,7 @@ const f1NoSubpathImport = {
2644
2799
  },
2645
2800
  meta: {
2646
2801
  docs: RULE_DOCS["f1-no-subpath-import"],
2647
- 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)." },
2802
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2648
2803
  type: "problem"
2649
2804
  }
2650
2805
  };