@jterrazz/test 13.0.1 → 14.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/oxlint.js CHANGED
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
7
7
  *
8
8
  * Rules only ever look at directories NEXT TO files oxlint is already
9
- * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
9
+ * visiting (a feature's `_requests/`, `_seeds/`, `_expected/`, a sibling module
10
10
  * file), so the probe set is small — but several rules probe the same feature
11
11
  * directory, and oxlint lints many files per process. A module-level cache
12
12
  * keeps each `readdir`/`stat` to one syscall per lint run.
@@ -337,7 +337,7 @@ const RULE_DOCS = {
337
337
  },
338
338
  "c1-domain-structure": {
339
339
  channel: "statique",
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
+ 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`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans.",
341
341
  family: "C",
342
342
  id: "C1",
343
343
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
@@ -356,9 +356,16 @@ const RULE_DOCS = {
356
356
  id: "C11",
357
357
  rationale: "Une donnée n’existe que servie par un contrat — un fichier sans propriétaire est du poids mort qu’aucun test ne charge."
358
358
  },
359
+ "c13-underscored-ground": {
360
+ channel: "statique",
361
+ convention: "Sous `specs/`, le sol d’une spec porte un underscore initial : `_fixtures/`, `_expected/`, `_requests/`, `_seeds/`. Un dossier `fixtures/`, `expected/`, `requests/` ou `seeds/` est un nom d’avant la 14 — erreur nommant le renommage.",
362
+ family: "C",
363
+ id: "C13",
364
+ rationale: "Aucun résolveur ne lit plus le nom sans underscore : gardé tel quel, l’arbre devient invisible plutôt que faux, et l’échec arrive une étape plus loin que sa cause."
365
+ },
359
366
  "c2-http-only-requests": {
360
367
  channel: "statique",
361
- convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
368
+ convention: "`_requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
362
369
  family: "C",
363
370
  id: "C2",
364
371
  rationale: "Une entrée de requête est un `.http` complet — homogénéiser le dossier interdit les formats ad hoc."
@@ -375,11 +382,11 @@ const RULE_DOCS = {
375
382
  convention: "L’argument de `toMatch` porte son extension (`'help.txt'`), sauf pour les snapshots d’arborescence (dossiers) ; un sujet fichier sans extension est une erreur.",
376
383
  family: "C",
377
384
  id: "C6",
378
- rationale: "L’extension fait partie du nom du fichier attendu — l’omettre casse la résolution `expected/`."
385
+ rationale: "L’extension fait partie du nom du fichier attendu — l’omettre casse la résolution `_expected/`."
379
386
  },
380
387
  "c7-seeds-sql-only": {
381
388
  channel: "statique",
382
- convention: "`seeds/` ne contient que des `*.sql` ; tout autre fichier est une erreur.",
389
+ convention: "`_seeds/` ne contient que des `*.sql` ; tout autre fichier est une erreur.",
383
390
  family: "C",
384
391
  id: "C7",
385
392
  rationale: "`.seed()` porte l’état des bases uniquement — pas de seed-handler ni de dispatch par préfixe."
@@ -421,7 +428,7 @@ const RULE_DOCS = {
421
428
  },
422
429
  "d9w-single-use-ref": {
423
430
  channel: "statique",
424
- convention: "Une ref de capture (`match.ref`, `{{kind#ref}}`) qui n’apparaît qu’une seule fois dans tout le fichier (code + fixtures `expected/` référencées) porte un nom inutilement → warning.",
431
+ convention: "Une ref de capture (`match.ref`, `{{kind#ref}}`) qui n’apparaît qu’une seule fois dans tout le fichier (code + fixtures `_expected/` référencées) porte un nom inutilement → warning.",
425
432
  family: "D",
426
433
  id: "D9",
427
434
  rationale: "Une ref ne se justifie que si elle asserte l’égalité sur au moins deux occurrences."
@@ -563,7 +570,7 @@ const RULE_DOCS = {
563
570
  const CHECKER_PASSES = [
564
571
  {
565
572
  channel: "checker",
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.",
573
+ 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.",
567
574
  family: "D",
568
575
  id: "D4",
569
576
  name: "d4-unknown-token",
@@ -571,7 +578,7 @@ const CHECKER_PASSES = [
571
578
  },
572
579
  {
573
580
  channel: "checker",
574
- convention: "Une ref malformée d’un kind connu (`{{iso8601#}}`, `{{uuid #id}}`) dans un fichier texte sous `expected/` est une erreur.",
581
+ convention: "Une ref malformée d’un kind connu (`{{iso8601#}}`, `{{uuid #id}}`) dans un fichier texte sous `_expected/` est une erreur.",
575
582
  family: "D",
576
583
  id: "D4",
577
584
  name: "d4-malformed-ref",
@@ -579,7 +586,7 @@ const CHECKER_PASSES = [
579
586
  },
580
587
  {
581
588
  channel: "checker",
582
- convention: "La première ligne d’un `.http` de profondeur 1 suit sa grammaire : requête (`MÉTHODE /path`) sous `requests/`, statut (`HTTP/1.1 <status>`) sous `expected/`.",
589
+ convention: "La première ligne d’un `.http` de profondeur 1 suit sa grammaire : requête (`MÉTHODE /path`) sous `_requests/`, statut (`HTTP/1.1 <status>`) sous `_expected/`.",
583
590
  family: "D",
584
591
  id: "D4b",
585
592
  name: "d4b-http-first-line",
@@ -675,7 +682,7 @@ const CHECKER_PASSES = [
675
682
  },
676
683
  {
677
684
  channel: "checker",
678
- convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
685
+ convention: "Un token connu dans un fichier sous `_requests/` → warning : les requêtes sont des entrées, jamais matchées.",
679
686
  family: "D",
680
687
  id: "D10",
681
688
  name: "d10w-tokens-in-requests",
@@ -683,12 +690,28 @@ const CHECKER_PASSES = [
683
690
  },
684
691
  {
685
692
  channel: "checker",
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:`.",
693
+ 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:`.",
687
694
  family: "C",
688
695
  id: "C9",
689
696
  name: "c9-dead-fixtures",
690
697
  rationale: "Le miroir de C8 — une fixture que rien ne référence est du poids mort qui trompe le lecteur."
691
698
  },
699
+ {
700
+ channel: "checker",
701
+ convention: "Une fixture du pool est PARTAGÉE, ou elle est locale : un dossier de `<specs>/_fixtures/` référencé (`$FIXTURES/<nom>`) depuis un seul dossier de spec — deux documents d’une même feuille comptent pour un — doit vivre à côté de cette feuille, en `<feuille>/_fixtures/<nom>/`, référencé par la forme relative. Zéro référence reste l’erreur de fixture morte (C9). Corrigé par `--fix` : le dossier est déplacé et les littéraux réécrits.",
702
+ family: "C",
703
+ id: "C14",
704
+ name: "c14-pool-fixture-shared",
705
+ rationale: "Une fixture d’un seul scénario garée dans le pool se lit comme du sol partagé : chacun suppose qu’un autre en dépend et personne n’ose y toucher."
706
+ },
707
+ {
708
+ channel: "checker",
709
+ convention: "Une fixture locale ne se lit que depuis son propre dossier : un chemin `fixture:`/`.fixture()` contenant `..`, ou dont la cible sort du `_fixtures/` de la spec qui le nomme, est une erreur — le sol partagé par plusieurs feuilles va dans le pool `$FIXTURES`.",
710
+ family: "C",
711
+ id: "C15",
712
+ name: "c15-local-fixture-reach",
713
+ rationale: "Une feuille qui pioche dans les fixtures d’une voisine, c’est le pool par une autre porte, sans la visibilité du pool : la voisine casse une spec à deux dossiers de là sans le savoir."
714
+ },
692
715
  {
693
716
  channel: "checker",
694
717
  convention: "Canal principal de B5 : les runners docker-aware sont inférés de l’option `docker:` du fichier de specification importé, puis chaque résultat `.exec()` lié sans `await using` est signalé.",
@@ -1232,16 +1255,58 @@ const a9wRedundantRoot = {
1232
1255
  }
1233
1256
  };
1234
1257
  //#endregion
1258
+ //#region src/core/specification/shared/ground.ts
1259
+ /**
1260
+ * The GROUND a row of specs stands on — the naming law of a spec tree.
1261
+ *
1262
+ * In a row of specs, a MEMBER is a spec: `<aspect>.test.ts`, `<case>.spec.yaml`,
1263
+ * `<name>.specification.ts`, and the facet/domain folders that hold them.
1264
+ * Everything else in that row is what the members STAND ON — inert material the
1265
+ * framework resolves BY PATH, never imported, never executed — and it carries a
1266
+ * LEADING UNDERSCORE. A spec's own folder never does.
1267
+ *
1268
+ * Four names, and only four: `_fixtures/` (file state), `_expected/` (goldens),
1269
+ * `_requests/` (inputs), `_seeds/` (database state). `contracts/` is NOT ground —
1270
+ * a contract is TypeScript a spec imports, behaviour composed with the spec's own,
1271
+ * so it stays a member. `docker/` is not ground either: it sits at the PROJECT
1272
+ * root beside `package.json`, outside any row of specs.
1273
+ *
1274
+ * This module is the single home of those names. Every resolver, matcher, lint
1275
+ * rule and checker pass reads them from here, so the runtime and the static
1276
+ * channels cannot disagree about where a spec's ground lives.
1277
+ */
1278
+ /** Goldens: every expected fixture, flat, extension included. */
1279
+ const GROUND_EXPECTED = "_expected";
1280
+ /** File state copied into a working directory by `.fixture()`. */
1281
+ const GROUND_FIXTURES = "_fixtures";
1282
+ /** Inputs: complete `.http` request documents. */
1283
+ const GROUND_REQUESTS = "_requests";
1284
+ /** Database state: `.sql` fragments loaded by `.seed()`. */
1285
+ const GROUND_SEEDS = "_seeds";
1286
+ /**
1287
+ * The pre-14 name of each ground directory → the name it carries now. Kept for
1288
+ * ONE purpose: naming the rename in a diagnostic (`c13-underscored-ground`).
1289
+ * Nothing resolves through it — an un-underscored directory is invisible to
1290
+ * every resolver, which is exactly why it must be reported rather than tolerated.
1291
+ */
1292
+ const RENAMED_GROUND_DIRS = {
1293
+ expected: GROUND_EXPECTED,
1294
+ fixtures: GROUND_FIXTURES,
1295
+ requests: GROUND_REQUESTS,
1296
+ seeds: GROUND_SEEDS
1297
+ };
1298
+ //#endregion
1235
1299
  //#region src/core/specification/shared/fixtures.ts
1236
1300
  /**
1237
1301
  * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
1238
1302
  *
1239
1303
  * A fixture path is one of two shapes:
1240
1304
  *
1241
- * - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
1305
+ * - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/_fixtures/<rest>`,
1242
1306
  * where `<specs-root>` is the nearest ancestor directory named `specs`.
1243
- * - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
1307
+ * - `<path>` (no marker) — feature-local, at `<test-dir>/_fixtures/<path>`.
1244
1308
  *
1309
+ * The leading underscore is the ground rule of a spec tree ({@link GROUND_FIXTURES}).
1245
1310
  * Any other `$`-prefixed marker is a usage error (listed against the known
1246
1311
  * markers). Copy semantics mirror rsync's trailing-slash rule (see
1247
1312
  * {@link copyPlan}).
@@ -1576,7 +1641,12 @@ const DEFAULT_DEPTH = "facet-domain";
1576
1641
  * specs root. Specification files are unconstrained: a mirror has no facet
1577
1642
  * level to anchor them to.
1578
1643
  *
1579
- * `depth: 'off'` — no placement check; the tree's shape is guarded elsewhere.
1644
+ * `depth: 'off'` — no DEPTH check; the tree's shape is guarded elsewhere.
1645
+ *
1646
+ * The ground clause holds in every mode, `off` included: a directory whose name
1647
+ * carries a LEADING UNDERSCORE is ground — what the specs of a row stand on —
1648
+ * and never a domain, so no spec may live inside one. That is not a question of
1649
+ * depth, which is why no project's declared shape switches it off.
1580
1650
  *
1581
1651
  * Module tests under `src/` follow the neighbour rule (I2) and are out of scope
1582
1652
  * in every mode.
@@ -1584,12 +1654,23 @@ const DEFAULT_DEPTH = "facet-domain";
1584
1654
  const c1DomainStructure = {
1585
1655
  create(context) {
1586
1656
  const depth = context.options[0]?.depth ?? DEFAULT_DEPTH;
1587
- if (depth === "off") return {};
1588
1657
  return { Program(node) {
1589
1658
  const anchor = specsAnchor(context.filename);
1590
1659
  if (anchor === void 0) return;
1591
1660
  const base = anchor.relative.at(-1) ?? "";
1592
1661
  const nesting = anchor.relative.length - 1;
1662
+ if (base.endsWith(TEST_SUFFIX) || base.endsWith(SPECIFICATION_SUFFIX)) {
1663
+ const ground = anchor.relative.slice(0, -1).find((segment) => segment.startsWith("_"));
1664
+ if (ground !== void 0) {
1665
+ context.report({
1666
+ data: { ground },
1667
+ messageId: "specInGround",
1668
+ node
1669
+ });
1670
+ return;
1671
+ }
1672
+ }
1673
+ if (depth === "off") return;
1593
1674
  if (depth === "facet") {
1594
1675
  if (base.endsWith(TEST_SUFFIX)) {
1595
1676
  if (nesting < 1) context.report({
@@ -1648,6 +1729,7 @@ const c1DomainStructure = {
1648
1729
  defaultOptions: [{ depth: DEFAULT_DEPTH }],
1649
1730
  docs: RULE_DOCS["c1-domain-structure"],
1650
1731
  messages: {
1732
+ specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md).",
1651
1733
  specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1652
1734
  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).",
1653
1735
  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).",
@@ -1705,7 +1787,7 @@ const c10ContractsBoundary = {
1705
1787
  };
1706
1788
  //#endregion
1707
1789
  //#region src/lint/rules/c11-contract-data-pairing.ts
1708
- const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1790
+ const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/;
1709
1791
  /** The provider folders that may carry data files. */
1710
1792
  const PROVIDERS$1 = [
1711
1793
  "anthropic",
@@ -1729,7 +1811,7 @@ function ownerOf(entry) {
1729
1811
  const c11ContractDataPairing = {
1730
1812
  create(context) {
1731
1813
  const file = context.physicalFilename;
1732
- if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
1814
+ if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1733
1815
  return { Program(node) {
1734
1816
  const contractsDir = join(dirname(file), "contracts");
1735
1817
  for (const provider of PROVIDERS$1) {
@@ -1759,13 +1841,66 @@ const c11ContractDataPairing = {
1759
1841
  }
1760
1842
  };
1761
1843
  //#endregion
1844
+ //#region src/lint/rules/c13-underscored-ground.ts
1845
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1846
+ /**
1847
+ * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
1848
+ * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
1849
+ * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
1850
+ * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
1851
+ * fallback.
1852
+ *
1853
+ * The catch this rule exists for: a consumer that upgrades and keeps the old
1854
+ * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
1855
+ * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
1856
+ * and the tree becomes invisible rather than wrong. The failure that follows is
1857
+ * "fixture does not exist" on every spec at once, one step removed from the
1858
+ * cause; this rule states the cause and the rename.
1859
+ *
1860
+ * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
1861
+ * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
1862
+ * nearest specs root. A DOMAIN folder named after the ground it holds
1863
+ * (`specs/api/requests/_requests/`) is never a probe target — only what sits
1864
+ * beside a spec is.
1865
+ */
1866
+ const c13UnderscoredGround = {
1867
+ create(context) {
1868
+ const file = context.physicalFilename;
1869
+ const anchor = specsAnchor(file);
1870
+ if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
1871
+ return { Program(node) {
1872
+ const seen = /* @__PURE__ */ new Set();
1873
+ const probe = (parent, legacy) => {
1874
+ const path = join(parent, legacy);
1875
+ if (seen.has(path) || !isDirectory(path)) return;
1876
+ seen.add(path);
1877
+ context.report({
1878
+ data: {
1879
+ ground: RENAMED_GROUND_DIRS[legacy],
1880
+ legacy
1881
+ },
1882
+ messageId: "notUnderscored",
1883
+ node
1884
+ });
1885
+ };
1886
+ for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe(dirname(file), legacy);
1887
+ probe(anchor.directory, "fixtures");
1888
+ } };
1889
+ },
1890
+ meta: {
1891
+ docs: RULE_DOCS["c13-underscored-ground"],
1892
+ messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/10-linting.md)." },
1893
+ type: "problem"
1894
+ }
1895
+ };
1896
+ //#endregion
1762
1897
  //#region src/lint/rules/c2-http-only-requests.ts
1763
1898
  const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1764
1899
  /**
1765
- * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1900
+ * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1766
1901
  * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1767
1902
  * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1768
- * `requests/` directory is probed (cached readdir) and any non-`.http` entry
1903
+ * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1769
1904
  * is reported on the test file.
1770
1905
  */
1771
1906
  const c2HttpOnlyRequests = {
@@ -1773,7 +1908,7 @@ const c2HttpOnlyRequests = {
1773
1908
  const file = context.physicalFilename;
1774
1909
  if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1775
1910
  return { Program(node) {
1776
- const requestsDir = join(dirname(file), "requests");
1911
+ const requestsDir = join(dirname(file), GROUND_REQUESTS);
1777
1912
  for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1778
1913
  data: { entry },
1779
1914
  messageId: "notHttp",
@@ -1783,7 +1918,7 @@ const c2HttpOnlyRequests = {
1783
1918
  },
1784
1919
  meta: {
1785
1920
  docs: RULE_DOCS["c2-http-only-requests"],
1786
- messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1921
+ messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1787
1922
  type: "problem"
1788
1923
  }
1789
1924
  };
@@ -2041,11 +2176,11 @@ function subjectIsDirectory(toMatchCallee) {
2041
2176
  /**
2042
2177
  * CONVENTIONS C6 — the extension is part of the fixture name and mandatory in
2043
2178
  * `toMatch` (`'help.txt'`, never `'help'`) — except for directory-tree
2044
- * snapshots, which are directories under `expected/`.
2179
+ * snapshots, which are directories under `_expected/`.
2045
2180
  *
2046
2181
  * The directory exception is resolved two ways: statically, when the expect
2047
2182
  * subject visibly chains `.directory(…)`; else on disk, when
2048
- * `expected/<name>/` exists next to the spec (cached stat).
2183
+ * `_expected/<name>/` exists next to the spec (cached stat).
2049
2184
  */
2050
2185
  const c6ToMatchExtension = {
2051
2186
  create(context) {
@@ -2059,7 +2194,7 @@ const c6ToMatchExtension = {
2059
2194
  if (name === void 0 || name.length === 0) return;
2060
2195
  if ((name.split("/").at(-1) ?? "").includes(".")) return;
2061
2196
  if (subjectIsDirectory(callee)) return;
2062
- if (isDirectory(join(dirname(file), "expected", name))) return;
2197
+ if (isDirectory(join(dirname(file), "_expected", name))) return;
2063
2198
  context.report({
2064
2199
  data: { name },
2065
2200
  messageId: "missingExtension",
@@ -2069,7 +2204,7 @@ const c6ToMatchExtension = {
2069
2204
  },
2070
2205
  meta: {
2071
2206
  docs: RULE_DOCS["c6-tomatch-extension"],
2072
- messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (expected/<name>/) omit it (C6 — see docs/10-linting.md)." },
2207
+ messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (_expected/<name>/) omit it (C6 — see docs/10-linting.md)." },
2073
2208
  type: "problem"
2074
2209
  }
2075
2210
  };
@@ -2077,17 +2212,17 @@ const c6ToMatchExtension = {
2077
2212
  //#region src/lint/rules/c7-seeds-sql-only.ts
2078
2213
  const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2079
2214
  /**
2080
- * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `seeds/` contains
2215
+ * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
2081
2216
  * nothing but `*.sql` fragments. File state goes through `.fixture()`; there
2082
2217
  * is no seed handler and no prefix dispatch. Anchored on the feature's visited
2083
- * test file (cached readdir of the sibling `seeds/`).
2218
+ * test file (cached readdir of the sibling `_seeds/`).
2084
2219
  */
2085
2220
  const c7SeedsSqlOnly = {
2086
2221
  create(context) {
2087
2222
  const file = context.physicalFilename;
2088
2223
  if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2089
2224
  return { Program(node) {
2090
- const seedsDir = join(dirname(file), "seeds");
2225
+ const seedsDir = join(dirname(file), GROUND_SEEDS);
2091
2226
  for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
2092
2227
  data: { entry },
2093
2228
  messageId: "notSql",
@@ -2097,18 +2232,18 @@ const c7SeedsSqlOnly = {
2097
2232
  },
2098
2233
  meta: {
2099
2234
  docs: RULE_DOCS["c7-seeds-sql-only"],
2100
- messages: { notSql: "seeds/{{entry}} is not a .sql file — seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/10-linting.md)." },
2235
+ messages: { notSql: "_seeds/{{entry}} is not a .sql file — _seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/10-linting.md)." },
2101
2236
  type: "problem"
2102
2237
  }
2103
2238
  };
2104
2239
  //#endregion
2105
2240
  //#region src/lint/rules/c8-referenced-fixture-exists.ts
2106
2241
  const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/;
2107
- /** The conventional sibling directory each fixture-referencing verb reads from. */
2242
+ /** The ground directory each fixture-referencing verb reads from. */
2108
2243
  const VERB_ROOTS = {
2109
- request: "requests",
2110
- seed: "seeds",
2111
- toMatch: "expected"
2244
+ request: GROUND_REQUESTS,
2245
+ seed: GROUND_SEEDS,
2246
+ toMatch: GROUND_EXPECTED
2112
2247
  };
2113
2248
  /**
2114
2249
  * A fixture reference is a filename/path, never inline content. `.seed()` also
@@ -2120,15 +2255,15 @@ function looksLikePath(value) {
2120
2255
  }
2121
2256
  /** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
2122
2257
  function resolveFixture(argument, featureDir, specsDir) {
2123
- if (argument.startsWith("$FIXTURES/")) return join(specsDir, "fixtures", argument.slice(10));
2258
+ if (argument.startsWith("$FIXTURES/")) return join(specsDir, GROUND_FIXTURES, argument.slice(10));
2124
2259
  if (argument.startsWith("$")) return;
2125
- return join(featureDir, "fixtures", argument);
2260
+ return join(featureDir, GROUND_FIXTURES, argument);
2126
2261
  }
2127
2262
  /**
2128
2263
  * CONVENTIONS C8 — a fixture referenced by a literal must exist on disk under
2129
- * its conventional root: `.request(x)`→`requests/x`, `.seed(x)`→`seeds/x`,
2130
- * `.fixture(x)`→feature `fixtures/` (or the `$FIXTURES/` pool at the nearest
2131
- * `specs/fixtures/`), `toMatch(x)`→`expected/x` (file or tree snapshot). A typo
2264
+ * its ground root: `.request(x)`→`_requests/x`, `.seed(x)`→`_seeds/x`,
2265
+ * `.fixture(x)`→the leaf's `_fixtures/` (or the `$FIXTURES/` pool at the nearest
2266
+ * `specs/_fixtures/`), `toMatch(x)`→`_expected/x` (file or tree snapshot). A typo
2132
2267
  * that would fail only at runtime is caught statically. Non-literal arguments
2133
2268
  * are out of static reach and skipped.
2134
2269
  */
@@ -2150,7 +2285,7 @@ const c8ReferencedFixtureExists = {
2150
2285
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
2151
2286
  data: {
2152
2287
  path: value,
2153
- root: "fixtures"
2288
+ root: GROUND_FIXTURES
2154
2289
  },
2155
2290
  messageId: "missing",
2156
2291
  node: args[0]
@@ -2174,7 +2309,7 @@ const c8ReferencedFixtureExists = {
2174
2309
  },
2175
2310
  meta: {
2176
2311
  docs: RULE_DOCS["c8-referenced-fixture-exists"],
2177
- messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its conventional {{root}}/ root (C8 — see docs/10-linting.md). Create it or fix the reference." },
2312
+ messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its ground {{root}}/ root (C8 — see docs/10-linting.md). Create it or fix the reference." },
2178
2313
  type: "problem"
2179
2314
  }
2180
2315
  };
@@ -2588,7 +2723,7 @@ const d6wTransformTokenEquivalent = {
2588
2723
  },
2589
2724
  meta: {
2590
2725
  docs: RULE_DOCS["d6w-transform-token-equivalent"],
2591
- messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/10-linting.md)." },
2726
+ messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the _expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/10-linting.md)." },
2592
2727
  type: "suggestion"
2593
2728
  }
2594
2729
  };
@@ -2657,7 +2792,7 @@ function refsInFixture(target) {
2657
2792
  * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2658
2793
  * point of a capture is the equality check on later occurrences). Aggregating
2659
2794
  * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2660
- * `expected/` fixtures the file references — a ref that appears exactly once
2795
+ * `_expected/` fixtures the file references — a ref that appears exactly once
2661
2796
  * everywhere is a plain matcher wearing a name.
2662
2797
  */
2663
2798
  const d9wSingleUseRef = {
@@ -2685,7 +2820,7 @@ const d9wSingleUseRef = {
2685
2820
  if (memberPropertyName(node.callee) === "toMatch") {
2686
2821
  const name = stringValue(node.arguments?.[0]);
2687
2822
  if (name !== void 0) {
2688
- const target = join(featureDir, "expected", name.replace(/\/+$/, ""));
2823
+ const target = join(featureDir, GROUND_EXPECTED, name.replace(/\/+$/, ""));
2689
2824
  for (const ref of refsInFixture(target)) {
2690
2825
  bump(ref);
2691
2826
  if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
@@ -3539,6 +3674,7 @@ const plugin = {
3539
3674
  "c1-domain-structure": c1DomainStructure,
3540
3675
  "c10-contracts-boundary": c10ContractsBoundary,
3541
3676
  "c11-contract-data-pairing": c11ContractDataPairing,
3677
+ "c13-underscored-ground": c13UnderscoredGround,
3542
3678
  "c2-http-only-requests": c2HttpOnlyRequests,
3543
3679
  "c4-contract-shape": c4ContractShape,
3544
3680
  "c6-tomatch-extension": c6ToMatchExtension,
@@ -16,11 +16,51 @@ function detectIndent(text) {
16
16
  }
17
17
  return smallest === 0 ? 2 : smallest;
18
18
  }
19
+ /** The tag every string goes out through — key, plain value and block scalar alike. */
20
+ const STRING_TAG = "tag:yaml.org,2002:str";
21
+ /** An indentation indicator is ONE digit, so nine spaces is the deepest step statable. */
22
+ const DEEPEST_STATABLE_STEP = 9;
23
+ /**
24
+ * Restate a block scalar's indentation indicator from the indentation its
25
+ * content is actually written at.
26
+ *
27
+ * A block scalar whose first line opens on a space NEEDS that indicator: without
28
+ * it a reader takes the space for indentation and gives back a string shorter
29
+ * than the one that was written. The `yaml` writer knows when one is called for
30
+ * and adds it — but it always writes the digit `2`, its own default step, never
31
+ * the step it was asked to indent with. In a four-space document (oxfmt formats
32
+ * YAML at four) `|2` puts every line back two spaces short, so the golden update
33
+ * mode just wrote fails on its very next run.
34
+ *
35
+ * The digit belongs to the block, not to the writer's default: content sits one
36
+ * step below its key, so the file's step is what the indicator names — and `1`
37
+ * at the root, which YAML counts from -1.
38
+ */
39
+ function stateIndentation(written, ctx) {
40
+ if (!/^[|>][1-9]/.test(written)) return written;
41
+ const step = ctx.indent === "" ? 1 : ctx.indentStep.length;
42
+ if (step > DEEPEST_STATABLE_STEP) throw new Error(`A block scalar indented by ${step} spaces cannot state its indentation: YAML writes that indicator as a single digit, so ${DEEPEST_STATABLE_STEP} spaces is the deepest step a document may be written with.`);
43
+ return `${written[0]}${step}${written.slice(2)}`;
44
+ }
45
+ /** A scalar tag among the schema's, told from a collection one by what it may hold. */
46
+ function isScalarTag(tag) {
47
+ return typeof tag !== "string" && tag.collection === void 0;
48
+ }
49
+ /** The string tag, wrapped so every block scalar it writes states its own indentation. */
50
+ function statingItsIndentation(tag) {
51
+ const write = tag.stringify;
52
+ if (write === void 0) return tag;
53
+ return {
54
+ ...tag,
55
+ stringify: (item, ctx, onComment, onChompKeep) => stateIndentation(write(item, ctx, onComment, onChompKeep), ctx)
56
+ };
57
+ }
19
58
  /** Parse a YAML file into its document form, keeping comments and key order. */
20
59
  function parseYamlSource(text) {
21
60
  const lineCounter = new LineCounter();
22
61
  return {
23
62
  document: parseDocument(text, {
63
+ customTags: (tags) => tags.map((tag) => isScalarTag(tag) && tag.tag === STRING_TAG ? statingItsIndentation(tag) : tag),
24
64
  keepSourceTokens: true,
25
65
  lineCounter
26
66
  }),
@@ -38,8 +78,10 @@ function yamlSyntaxErrors(source) {
38
78
  /**
39
79
  * A literal block scalar. The `yaml` writer picks the chomping indicator from
40
80
  * the value itself — `|` when the text ends with a newline, `|-` when it does
41
- * not — and adds the indentation indicator (`|2`) when the first line starts
42
- * with a space, which is exactly the byte-exactness the format promises.
81
+ * not — and decides whether an indentation indicator is called for, which is
82
+ * when the first line opens on a space. What that indicator SAYS is restated on
83
+ * the way out by `stateIndentation` above, and together the two are the
84
+ * byte-exactness the format promises.
43
85
  */
44
86
  function blockScalar(text) {
45
87
  const scalar = new Scalar(text);
@@ -343,7 +385,7 @@ function readSpecFile(content, fileName) {
343
385
  /**
344
386
  * Every text of a document the `{{token}}` grammar governs: the streams a
345
387
  * comparison judges, and the `files:` texts it judges them with. `stdin:` is an
346
- * INPUT — like a `requests/` fixture, it is never matched — and a description or
388
+ * INPUT — like a `_requests/` fixture, it is never matched — and a description or
347
389
  * a command is prose, so neither appears here.
348
390
  */
349
391
  function assertedStreams(document) {
@@ -501,7 +543,7 @@ const SPEC_SCHEMA = {
501
543
  },
502
544
  fixture: {
503
545
  ...STRING_OR_LIST,
504
- description: "Fixture paths copied into the working directory, layering in order."
546
+ description: "Fixture paths copied into the working directory, layering in order. `$FIXTURES/<name>` is the shared pool at `<specs-root>/_fixtures/`; a bare path is the ground beside this document, at `_fixtures/<name>`."
505
547
  },
506
548
  kind: {
507
549
  default: "cli",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "13.0.1",
3
+ "version": "14.0.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",
@@ -136,7 +136,7 @@
136
136
  "type": "array"
137
137
  }
138
138
  ],
139
- "description": "Fixture paths copied into the working directory, layering in order."
139
+ "description": "Fixture paths copied into the working directory, layering in order. `$FIXTURES/<name>` is the shared pool at `<specs-root>/_fixtures/`; a bare path is the ground beside this document, at `_fixtures/<name>`."
140
140
  },
141
141
  "kind": {
142
142
  "default": "cli",