@jterrazz/test 10.1.0 → 12.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
@@ -1,5 +1,62 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ //#region src/lint/fs-cache.ts
4
+ /**
5
+ * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
6
+ *
7
+ * Rules only ever look at directories NEXT TO files oxlint is already
8
+ * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
9
+ * file), so the probe set is small — but several rules probe the same feature
10
+ * directory, and oxlint lints many files per process. A module-level cache
11
+ * keeps each `readdir`/`stat` to one syscall per lint run.
12
+ */
13
+ const dirListings = /* @__PURE__ */ new Map();
14
+ function listDirectory(dir) {
15
+ const cached = dirListings.get(dir);
16
+ if (cached !== void 0) return cached;
17
+ let result;
18
+ try {
19
+ result = readdirSync(dir);
20
+ } catch {
21
+ result = null;
22
+ }
23
+ dirListings.set(dir, result);
24
+ return result;
25
+ }
26
+ const kinds = /* @__PURE__ */ new Map();
27
+ function kindOf(path) {
28
+ const cached = kinds.get(path);
29
+ if (cached !== void 0) return cached;
30
+ let result;
31
+ try {
32
+ result = statSync(path).isDirectory() ? "dir" : "file";
33
+ } catch {
34
+ result = "missing";
35
+ }
36
+ kinds.set(path, result);
37
+ return result;
38
+ }
39
+ function isDirectory(path) {
40
+ return kindOf(path) === "dir";
41
+ }
42
+ function isFile(path) {
43
+ return kindOf(path) === "file";
44
+ }
45
+ const fileContents = /* @__PURE__ */ new Map();
46
+ /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
47
+ function readFileCached(path) {
48
+ const cached = fileContents.get(path);
49
+ if (cached !== void 0) return cached;
50
+ let result;
51
+ try {
52
+ result = readFileSync(path, "utf8");
53
+ } catch {
54
+ result = null;
55
+ }
56
+ fileContents.set(path, result);
57
+ return result;
58
+ }
59
+ //#endregion
3
60
  //#region src/lint/ast.ts
4
61
  /**
5
62
  * Shared AST helpers for the rule files. Everything here is pure and
@@ -10,6 +67,39 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
10
67
  function segments(path) {
11
68
  return path.split(/[/\\]/).filter(Boolean);
12
69
  }
70
+ /**
71
+ * The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
72
+ * searched no higher than the nearest `package.json`. `undefined` when the file
73
+ * is not under one.
74
+ *
75
+ * One anchor for every specs-aware rule. Rules used to hand-roll the search and
76
+ * disagreed with each other — `lastIndexOf('specs')` (C1) took the innermost
77
+ * match, `indexOf('specs')` (F3) the outermost, so one nested tree read as two
78
+ * different facets. And any bare "is `specs` a segment of this path" test
79
+ * matched directories that have nothing to do with the project: a checkout
80
+ * living under `~/specs/` turned the whole repository into a specs tree, which
81
+ * silently disabled F2's protection of production code. The package boundary
82
+ * makes the search stop where the project does.
83
+ */
84
+ function specsAnchor(filename) {
85
+ const relative = [basename(filename)];
86
+ let directory = dirname(filename);
87
+ for (;;) {
88
+ if (basename(directory) === "specs") return {
89
+ directory,
90
+ relative
91
+ };
92
+ if (isFile(join(directory, "package.json"))) return;
93
+ const parent = dirname(directory);
94
+ if (parent === directory) return;
95
+ relative.unshift(basename(directory));
96
+ directory = parent;
97
+ }
98
+ }
99
+ /** Is this file inside a `specs/` tree? — the predicate form of {@link specsAnchor}. */
100
+ function isUnderSpecs(filename) {
101
+ return specsAnchor(filename) !== void 0;
102
+ }
13
103
  /** The string value of a plain string literal (or a template with no holes). */
14
104
  function stringValue(node) {
15
105
  if (node === void 0) return;
@@ -246,11 +336,25 @@ const RULE_DOCS = {
246
336
  },
247
337
  "c1-domain-structure": {
248
338
  channel: "statique",
249
- convention: "Un `*.test.ts` vit à la profondeur facet/domain, un `*.specification.ts` au root de la facette ; toute autre profondeur est une erreur.",
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`.",
250
340
  family: "C",
251
341
  id: "C1",
252
342
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
253
343
  },
344
+ "c10-contracts-boundary": {
345
+ channel: "statique",
346
+ convention: "Hors de `specs/**/contracts/`, un import qui résout dans un dossier de provider (`contracts/{http,openai,anthropic}/…`) est une erreur : un test n’importe que les façades `*.contracts.ts`.",
347
+ family: "C",
348
+ id: "C10",
349
+ rationale: "Les contrats unitaires sont des détails de composition ; passer par la façade garde chaque scénario nommé au même endroit que le monde dont il dérive."
350
+ },
351
+ "c11-contract-data-pairing": {
352
+ channel: "statique",
353
+ convention: "Dans un dossier de provider, tout `*.response.json` et tout `*.request.ts` a un frère `<souche>.ts` — la souche est le nom jusqu’au PREMIER point (`events.fr.response.json` → `events.ts`) ; une donnée orpheline est une erreur.",
354
+ family: "C",
355
+ id: "C11",
356
+ 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."
357
+ },
254
358
  "c2-http-only-requests": {
255
359
  channel: "statique",
256
360
  convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
@@ -260,10 +364,10 @@ const RULE_DOCS = {
260
364
  },
261
365
  "c4-contract-shape": {
262
366
  channel: "statique",
263
- convention: "Un fichier de `contracts/` respecte `<nom>.<provider>.ts` (provider ∈ openai|anthropic|http), à plat, `export default defineContract(...)`, imports depuis le point d’entrée public.",
367
+ convention: "Sous `specs/**/contracts/` : la RACINE ne porte que des façades `*.contracts.ts` (export default construit par `defineContracts(...)` ou une re-export de composition ; exports nommés = scénarios) et les dossiers de provider `http` | `openai` | `anthropic`. Un contrat unitaire est `<provider>/<nom-kebab>.ts` avec un `export default defineContract(...)` (ou une factory qui en retourne un), et son builder de `request` doit désigner le provider de son dossier. Un dossier de provider est plat et ne contient que des `*.ts` et des `*.response.json`.",
264
368
  family: "C",
265
369
  id: "C4",
266
- rationale: "Une forme figée rend les contrats découvrables et typables sans convention locale."
370
+ rationale: "Une arborescence figée sépare la façade publique des unités internes et fait porter le provider par le DOSSIER — les noms de fichiers redeviennent du métier, et la donnée volumineuse se range à côté du contrat qui la sert."
267
371
  },
268
372
  "c6-tomatch-extension": {
269
373
  channel: "statique",
@@ -379,10 +483,10 @@ const RULE_DOCS = {
379
483
  },
380
484
  "i1-layer-boundaries": {
381
485
  channel: "statique",
382
- convention: "Quatre couches sous `src/` (core/integrations/vitest/lint) ; un import externe depuis `core/`, une integration important une dépendance qui n’est pas la sienne, ou un import hors whitelist entre couches est une erreur.",
486
+ convention: "Le projet DÉCLARE ses couches sous `src/` via l’option `layers` (paquets autorisés, imports internes, un dossier = une dépendance, seams) ; tout import hors des arêtes déclarées est une erreur. Sans carte de couches, la règle est inerte.",
383
487
  family: "I",
384
488
  id: "I1",
385
- rationale: "Des frontières strictes gardent `core/` pur et chaque intégration confinée à sa dépendance."
489
+ rationale: "Une architecture appartient au projet : des frontières déclarées se vérifient, une architecture supposée se contourne."
386
490
  },
387
491
  "i2-sibling-test-naming": {
388
492
  channel: "statique",
@@ -393,7 +497,7 @@ const RULE_DOCS = {
393
497
  },
394
498
  "i4-no-vi-mock-in-src": {
395
499
  channel: "statique",
396
- convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset non-`.ts` depuis un `.test.ts` sont interdits.",
500
+ convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset de données (`.json`, `.sql`, `.yaml`, …) depuis un `.test.ts` sont interdits ; un spécifieur pointé (`./dashboard.post`) reste du code.",
397
501
  family: "I",
398
502
  id: "I4",
399
503
  rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
@@ -490,7 +594,7 @@ const CHECKER_PASSES = [
490
594
  },
491
595
  {
492
596
  channel: "checker",
493
- convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`intercepts/`/`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).",
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).",
494
598
  family: "C",
495
599
  id: "C9",
496
600
  name: "c9-dead-fixtures",
@@ -972,79 +1076,43 @@ const a6wRedundantComposeService = {
972
1076
  }
973
1077
  };
974
1078
  //#endregion
975
- //#region src/lint/fs-cache.ts
1079
+ //#region src/core/specification/shared/resolve.ts
1080
+ /** What makes a directory a project root — a manifest, or a test stack. */
1081
+ const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
976
1082
  /**
977
- * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
1083
+ * The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
1084
+ * `undefined` when none does.
978
1085
  *
979
- * Rules only ever look at directories NEXT TO files oxlint is already
980
- * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
981
- * file), so the probe set is small — but several rules probe the same feature
982
- * directory, and oxlint lints many files per process. A module-level cache
983
- * keeps each `readdir`/`stat` to one syscall per lint run.
1086
+ * Both markers are probed at each ancestor, in ONE walk. Two walks — the
1087
+ * compose file all the way up, then `package.json` all the way up — made the
1088
+ * further directory win: in a workspace, a `docker/compose.test.yaml` at the
1089
+ * repository root outranked the `package.json` of the very package being
1090
+ * tested, and every path the runner resolved was measured from the wrong unit.
1091
+ * The unit is the nearest thing that declares itself a project.
1092
+ *
1093
+ * The existence probe is injected so the lint layer can pass its cached one
1094
+ * (A9's `a9w-redundant-root` derives the same root, and must derive it the
1095
+ * same way).
984
1096
  */
985
- const dirListings = /* @__PURE__ */ new Map();
986
- function listDirectory(dir) {
987
- const cached = dirListings.get(dir);
988
- if (cached !== void 0) return cached;
989
- let result;
990
- try {
991
- result = readdirSync(dir);
992
- } catch {
993
- result = null;
994
- }
995
- dirListings.set(dir, result);
996
- return result;
997
- }
998
- const kinds = /* @__PURE__ */ new Map();
999
- function kindOf(path) {
1000
- const cached = kinds.get(path);
1001
- if (cached !== void 0) return cached;
1002
- let result;
1003
- try {
1004
- result = statSync(path).isDirectory() ? "dir" : "file";
1005
- } catch {
1006
- result = "missing";
1007
- }
1008
- kinds.set(path, result);
1009
- return result;
1010
- }
1011
- function isDirectory(path) {
1012
- return kindOf(path) === "dir";
1013
- }
1014
- function isFile(path) {
1015
- return kindOf(path) === "file";
1016
- }
1017
- const fileContents = /* @__PURE__ */ new Map();
1018
- /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
1019
- function readFileCached(path) {
1020
- const cached = fileContents.get(path);
1021
- if (cached !== void 0) return cached;
1022
- let result;
1023
- try {
1024
- result = readFileSync(path, "utf8");
1025
- } catch {
1026
- result = null;
1097
+ function findRoot(startDir, exists) {
1098
+ let dir = startDir;
1099
+ for (;;) {
1100
+ if (ROOT_MARKERS.some((marker) => exists(resolve(dir, marker)))) return dir;
1101
+ const parent = dirname(dir);
1102
+ if (parent === dir) return;
1103
+ dir = parent;
1027
1104
  }
1028
- fileContents.set(path, result);
1029
- return result;
1030
1105
  }
1031
1106
  //#endregion
1032
1107
  //#region src/lint/rules/a9w-redundant-root.ts
1033
1108
  /**
1034
- * The root the convention would derive (A9 — see docs/10-linting.md): walk up from the
1035
- * specification file to the first directory containing
1036
- * `docker/compose.test.yaml`, else the first containing `package.json`.
1109
+ * The root the convention would derive (A9 — see docs/10-linting.md): the
1110
+ * framework's own walk-up, run against this layer's cached fs probe. The rule
1111
+ * flags a `root` that the runner would have found by itself, so the two MUST
1112
+ * be the same function — a private copy of the walk drifted from it before.
1037
1113
  */
1038
1114
  function derivedRoot(startDir) {
1039
- for (const probe of ["docker/compose.test.yaml", "package.json"]) {
1040
- let dir = startDir;
1041
- for (;;) {
1042
- if (isFile(join(dir, probe))) return dir;
1043
- const parent = dirname(dir);
1044
- if (parent === dir) break;
1045
- dir = parent;
1046
- }
1047
- }
1115
+ return findRoot(startDir, isFile);
1048
1116
  }
1049
1117
  /**
1050
1118
  * CONVENTIONS A9 (warning) — `root` is an override reserved for cases the
@@ -1376,8 +1444,16 @@ const b9wProductCommand = {
1376
1444
  //#region src/lint/rules/c1-domain-structure.ts
1377
1445
  const TEST_SUFFIX = ".test.ts";
1378
1446
  const SPECIFICATION_SUFFIX = ".specification.ts";
1447
+ const DEFAULT_DEPTH = "facet-domain";
1379
1448
  /**
1380
- * CONVENTIONS C1' — facet-root specifications, domain-nested tests.
1449
+ * CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
1450
+ *
1451
+ * A project states which shape its `specs/` tree has; the rule checks that one.
1452
+ * Before this option there was a single shape and the only way out was `off`,
1453
+ * which took the whole rule with it — a tree with a different, equally
1454
+ * deliberate shape had no way to be checked at all.
1455
+ *
1456
+ * `depth: 'facet-domain'` (the default, and the historical behaviour)
1381
1457
  *
1382
1458
  * A facet (`specs/<facet>/` — api, jobs, cli, integrations, lint, …) is the
1383
1459
  * master folder. It carries its runner(s) at its ROOT
@@ -1385,36 +1461,66 @@ const SPECIFICATION_SUFFIX = ".specification.ts";
1385
1461
  * product command/area with one or more `<aspect>.test.ts` files plus their
1386
1462
  * shared asset dirs.
1387
1463
  *
1388
- * Two placement invariants, checked from the filename:
1389
- * - a `*.test.ts` must sit at facet/domain depth — exactly
1464
+ * - a `*.test.ts` sits at facet/domain depth — exactly
1390
1465
  * `specs/<facet>/<domain>/<file>.test.ts`. A test directly at the facet root
1391
1466
  * (or nested deeper than a domain) is rejected.
1392
- * - a `*.specification.ts` must sit at the facet root — exactly
1467
+ * - a `*.specification.ts` sits at the facet root — exactly
1393
1468
  * `specs/<facet>/<file>.specification.ts`, never inside a domain.
1394
1469
  *
1395
- * Module tests under `src/` follow the neighbour rule (I2) and are out of scope.
1470
+ * `depth: 'mirror'`
1471
+ *
1472
+ * The tree mirrors something outside itself (a command tree, a source tree), so
1473
+ * the depth is that structure's and no fixed number can name it. A `*.test.ts`
1474
+ * sits at ANY depth of at least one directory under `specs/`, and is NAMED
1475
+ * AFTER the directory holding it — `<dir>/<dir>.test.ts`. That naming is what
1476
+ * keeps the shape checkable: one test per mirrored node, no test loose at the
1477
+ * specs root. Specification files are unconstrained: a mirror has no facet
1478
+ * level to anchor them to.
1479
+ *
1480
+ * `depth: 'off'` — no placement check; the tree's shape is guarded elsewhere.
1481
+ *
1482
+ * Module tests under `src/` follow the neighbour rule (I2) and are out of scope
1483
+ * in every mode.
1396
1484
  */
1397
1485
  const c1DomainStructure = {
1398
1486
  create(context) {
1487
+ const depth = context.options[0]?.depth ?? DEFAULT_DEPTH;
1488
+ if (depth === "off") return {};
1399
1489
  return { Program(node) {
1400
- const parts = segments(context.filename);
1401
- const base = parts.at(-1) ?? "";
1402
- const specsIndex = parts.lastIndexOf("specs");
1403
- if (specsIndex === -1) return;
1404
- const depth = parts.length - 1 - (specsIndex + 1);
1490
+ const anchor = specsAnchor(context.filename);
1491
+ if (anchor === void 0) return;
1492
+ const base = anchor.relative.at(-1) ?? "";
1493
+ const nesting = anchor.relative.length - 1;
1494
+ if (depth === "mirror") {
1495
+ if (!base.endsWith(TEST_SUFFIX)) return;
1496
+ if (nesting < 1) {
1497
+ context.report({
1498
+ messageId: "testAtSpecsRoot",
1499
+ node
1500
+ });
1501
+ return;
1502
+ }
1503
+ const directory = anchor.relative.at(-2) ?? "";
1504
+ if (base.slice(0, -8) !== directory) context.report({
1505
+ data: { directory },
1506
+ messageId: "testNotMirroringDirectory",
1507
+ node
1508
+ });
1509
+ return;
1510
+ }
1405
1511
  if (base.endsWith(TEST_SUFFIX)) {
1406
- if (depth < 2) context.report({
1512
+ if (nesting < 2) context.report({
1407
1513
  messageId: "testAtFacetRoot",
1408
1514
  node
1409
1515
  });
1410
- else if (depth > 2) context.report({
1516
+ else if (nesting > 2) context.report({
1411
1517
  messageId: "testTooDeep",
1412
1518
  node
1413
1519
  });
1414
1520
  return;
1415
1521
  }
1416
1522
  if (base.endsWith(SPECIFICATION_SUFFIX)) {
1417
- if (depth !== 1) context.report({
1523
+ if (nesting !== 1) context.report({
1418
1524
  messageId: "specNotAtFacetRoot",
1419
1525
  node
1420
1526
  });
@@ -1422,18 +1528,119 @@ const c1DomainStructure = {
1422
1528
  } };
1423
1529
  },
1424
1530
  meta: {
1531
+ defaultOptions: [{ depth: DEFAULT_DEPTH }],
1425
1532
  docs: RULE_DOCS["c1-domain-structure"],
1426
1533
  messages: {
1427
1534
  specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1428
1535
  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
+ 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
+ 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).",
1429
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)."
1430
1539
  },
1540
+ schema: [{
1541
+ additionalProperties: false,
1542
+ properties: { depth: {
1543
+ enum: [
1544
+ "facet-domain",
1545
+ "mirror",
1546
+ "off"
1547
+ ],
1548
+ type: "string"
1549
+ } },
1550
+ type: "object"
1551
+ }],
1552
+ type: "problem"
1553
+ }
1554
+ };
1555
+ //#endregion
1556
+ //#region src/lint/rules/c10-contracts-boundary.ts
1557
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
1558
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1559
+ /**
1560
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1561
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1562
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1563
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1564
+ * importing a unit is an error — a test routes through the facade, so a
1565
+ * scenario is named once, next to the world it derives from.
1566
+ */
1567
+ const c10ContractsBoundary = {
1568
+ create(context) {
1569
+ const parts = segments(context.physicalFilename);
1570
+ if (!parts.includes("specs")) return {};
1571
+ if (parts.slice(0, -1).includes("contracts")) return {};
1572
+ return importSourceVisitor(({ node, source }) => {
1573
+ if (INTERNAL_UNIT.test(source)) context.report({
1574
+ data: { source },
1575
+ messageId: "internal",
1576
+ node
1577
+ });
1578
+ });
1579
+ },
1580
+ meta: {
1581
+ docs: RULE_DOCS["c10-contracts-boundary"],
1582
+ messages: { internal: "Import \"{{source}}\" reaches into a provider folder — only contracts/*.contracts.ts is importable from a test; add a named scenario export there instead (C10 — see docs/10-linting.md)." },
1583
+ type: "problem"
1584
+ }
1585
+ };
1586
+ //#endregion
1587
+ //#region src/lint/rules/c11-contract-data-pairing.ts
1588
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1589
+ /** The provider folders that may carry data files. */
1590
+ const PROVIDERS$1 = [
1591
+ "anthropic",
1592
+ "http",
1593
+ "openai"
1594
+ ];
1595
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
1596
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
1597
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1598
+ function ownerOf(entry) {
1599
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
1600
+ }
1601
+ /**
1602
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1603
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1604
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1605
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1606
+ * file with no owner is dead weight no test can reach — reported on the feature's
1607
+ * test file, since oxlint never visits a `.json`.
1608
+ */
1609
+ const c11ContractDataPairing = {
1610
+ create(context) {
1611
+ const file = context.physicalFilename;
1612
+ if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
1613
+ return { Program(node) {
1614
+ const contractsDir = join(dirname(file), "contracts");
1615
+ for (const provider of PROVIDERS$1) {
1616
+ const entries = listDirectory(join(contractsDir, provider));
1617
+ if (entries === null) continue;
1618
+ const present = new Set(entries);
1619
+ for (const entry of entries) {
1620
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1621
+ const owner = ownerOf(entry);
1622
+ if (!present.has(owner)) context.report({
1623
+ data: {
1624
+ entry,
1625
+ owner,
1626
+ provider
1627
+ },
1628
+ messageId: "orphan",
1629
+ node
1630
+ });
1631
+ }
1632
+ }
1633
+ } };
1634
+ },
1635
+ meta: {
1636
+ docs: RULE_DOCS["c11-contract-data-pairing"],
1637
+ messages: { orphan: "contracts/{{provider}}/{{entry}} has no owning contract — data pairs with the sibling {{owner}} (stem = name up to the first dot) (C11 — see docs/10-linting.md)." },
1431
1638
  type: "problem"
1432
1639
  }
1433
1640
  };
1434
1641
  //#endregion
1435
1642
  //#region src/lint/rules/c2-http-only-requests.ts
1436
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1643
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1437
1644
  /**
1438
1645
  * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1439
1646
  * a COMPLETE request: method, path, headers, body). Anchored on the feature's
@@ -1444,7 +1651,7 @@ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1444
1651
  const c2HttpOnlyRequests = {
1445
1652
  create(context) {
1446
1653
  const file = context.physicalFilename;
1447
- if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
1654
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1448
1655
  return { Program(node) {
1449
1656
  const requestsDir = join(dirname(file), "requests");
1450
1657
  for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
@@ -1462,65 +1669,217 @@ const c2HttpOnlyRequests = {
1462
1669
  };
1463
1670
  //#endregion
1464
1671
  //#region src/lint/rules/c4-contract-shape.ts
1465
- /** Providers a contract file may declare (C4 — see docs/10-linting.md). */
1672
+ /** The only directories a `contracts/` root may hold — the provider carriers. */
1466
1673
  const PROVIDERS = /* @__PURE__ */ new Set([
1467
1674
  "anthropic",
1468
1675
  "http",
1469
1676
  "openai"
1470
1677
  ]);
1471
- const CONTRACT_NAME = /^[A-Za-z0-9][\w-]*\.(?<provider>[a-z]+)\.[cm]?ts$/;
1472
- /** Is the import source the framework's public entry (`@jterrazz/test` / `src/index`)? */
1473
- function isPublicEntry(source) {
1474
- return source === "@jterrazz/test" || source.endsWith("/src/index.js") || source.endsWith("/src/index.ts");
1678
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1679
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1680
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1681
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1682
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1683
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1684
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1685
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1686
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1687
+ /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1688
+ const RESPONSE_DATA_FILE = /\.response\.json$/;
1689
+ /** The name a specifier/declaration exports under, when it is `default`. */
1690
+ function exportsDefault(node) {
1691
+ return (node.specifiers ?? []).some((specifier) => {
1692
+ const exported = specifier.exported;
1693
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1694
+ });
1695
+ }
1696
+ /** Is this node a `defineContract(...)` call? */
1697
+ function isDefineContract(node) {
1698
+ const callee = node.callee;
1699
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1700
+ }
1701
+ /** A default export that may legitimately produce a contract (value or factory). */
1702
+ function producesContract(declaration) {
1703
+ if (declaration === void 0) return false;
1704
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1475
1705
  }
1476
1706
  /**
1477
- * CONVENTIONS C4 — a contract file (`contracts/<name>.<provider>.ts`) has a
1478
- * rigid shape: flat under `contracts/` (no subfolders), `provider ∈ { openai,
1479
- * anthropic, http }`, a single `export default defineContract(...)`, no named
1480
- * exports, and imports only from the public entry. The runtime channel (the
1481
- * loader) only catches a bad default export; this rule closes the rest.
1707
+ * The provider a `request:` value implies, when the builder is written as
1708
+ * `<provider>.<verb>(…)` — the only statically decidable form.
1482
1709
  */
1483
- const c4ContractShape = {
1484
- create(context) {
1485
- const parts = segments(context.physicalFilename);
1486
- const contractsIndex = parts.lastIndexOf("contracts");
1487
- if (contractsIndex === -1 || contractsIndex >= parts.length - 1 || !parts.includes("specs")) return {};
1488
- const base = parts.at(-1) ?? "";
1489
- return { Program(node) {
1490
- if (contractsIndex !== parts.length - 2) context.report({
1491
- data: { base },
1492
- messageId: "subfolder",
1710
+ function requestProvider(request) {
1711
+ if (request?.type !== "CallExpression") return;
1712
+ const callee = request.callee;
1713
+ if (callee?.type !== "MemberExpression") return;
1714
+ const object = callee.object;
1715
+ return object?.type === "Identifier" ? object.name : void 0;
1716
+ }
1717
+ /**
1718
+ * The layout half — walks the feature's `contracts/` tree from the test file
1719
+ * that owns it. Covers what oxlint never visits: a stray `.json` at the root,
1720
+ * a directory that is not a provider, a nested folder or a foreign extension
1721
+ * inside a provider folder.
1722
+ */
1723
+ function checkLayout(context, node, contractsDir) {
1724
+ const entries = listDirectory(contractsDir);
1725
+ if (entries === null) return;
1726
+ for (const entry of entries) {
1727
+ const path = join(contractsDir, entry);
1728
+ if (!isDirectory(path)) {
1729
+ if (!SOURCE_FILE.test(entry)) context.report({
1730
+ data: { entry },
1731
+ messageId: "rootEntry",
1493
1732
  node
1494
1733
  });
1495
- const match = CONTRACT_NAME.exec(base);
1496
- if (match === null || !PROVIDERS.has(match.groups?.provider ?? "")) context.report({
1497
- data: { base },
1498
- messageId: "badName",
1734
+ continue;
1735
+ }
1736
+ if (!PROVIDERS.has(entry)) {
1737
+ context.report({
1738
+ data: { entry },
1739
+ messageId: "badProviderDir",
1499
1740
  node
1500
1741
  });
1501
- let hasDefault = false;
1502
- for (const statement of node.body ?? []) if (statement.type === "ImportDeclaration") {
1503
- const source = stringValue(statement.source);
1504
- if (source !== void 0 && !isPublicEntry(source)) context.report({
1505
- data: { source },
1506
- messageId: "foreignImport",
1507
- node: statement
1508
- });
1509
- } else if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") context.report({
1510
- messageId: "namedExport",
1511
- node: statement
1512
- });
1513
- else if (statement.type === "ExportDefaultDeclaration") {
1742
+ continue;
1743
+ }
1744
+ for (const child of listDirectory(path) ?? []) if (isDirectory(join(path, child))) context.report({
1745
+ data: {
1746
+ child,
1747
+ provider: entry
1748
+ },
1749
+ messageId: "providerSubfolder",
1750
+ node
1751
+ });
1752
+ else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1753
+ data: {
1754
+ child,
1755
+ provider: entry
1756
+ },
1757
+ messageId: "providerEntry",
1758
+ node
1759
+ });
1760
+ }
1761
+ }
1762
+ /** The facade: `<kebab>.contracts.ts`, default-exporting a composition. */
1763
+ function checkComposite(context, program, base) {
1764
+ if (!COMPOSITE_FILE.test(base)) {
1765
+ context.report({
1766
+ data: { base },
1767
+ messageId: "rootFile",
1768
+ node: program
1769
+ });
1770
+ return;
1771
+ }
1772
+ let hasDefault = false;
1773
+ let composed = false;
1774
+ walk(program, (node) => {
1775
+ if (node.type === "ExportDefaultDeclaration") hasDefault = true;
1776
+ else if (node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") {
1777
+ if (exportsDefault(node)) {
1514
1778
  hasDefault = true;
1515
- const declaration = statement.declaration;
1516
- const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
1517
- if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
1518
- messageId: "notDefineContract",
1519
- node: statement
1520
- });
1779
+ composed = stringValue(node.source) !== void 0 || composed;
1521
1780
  }
1522
- if (!hasDefault) context.report({
1523
- messageId: "missingDefault",
1781
+ } else if (node.type === "CallExpression") {
1782
+ const callee = node.callee;
1783
+ if (callee?.type === "Identifier" && callee.name === "defineContracts" || callee !== void 0 && memberPropertyName(callee) === "with") composed = true;
1784
+ }
1785
+ });
1786
+ if (!hasDefault) context.report({
1787
+ data: { base },
1788
+ messageId: "missingComposite",
1789
+ node: program
1790
+ });
1791
+ else if (!composed) context.report({
1792
+ data: { base },
1793
+ messageId: "notDefineContracts",
1794
+ node: program
1795
+ });
1796
+ }
1797
+ /** A unit contract under its provider folder. */
1798
+ function checkUnit(context, program, base, provider) {
1799
+ if (REQUEST_DATA_FILE.test(base)) return;
1800
+ if (!UNIT_FILE.test(base)) {
1801
+ context.report({
1802
+ data: { base },
1803
+ messageId: "badName",
1804
+ node: program
1805
+ });
1806
+ return;
1807
+ }
1808
+ let hasDefault = false;
1809
+ let wellFormed = false;
1810
+ walk(program, (node) => {
1811
+ if (node.type === "ExportDefaultDeclaration") {
1812
+ hasDefault = true;
1813
+ wellFormed = producesContract(node.declaration) || wellFormed;
1814
+ } else if (node.type === "ExportNamedDeclaration" && exportsDefault(node)) {
1815
+ hasDefault = true;
1816
+ wellFormed = true;
1817
+ }
1818
+ if (isDefineContract(node)) {
1819
+ const argument = node.arguments?.[0];
1820
+ const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
1821
+ if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1822
+ data: {
1823
+ declared,
1824
+ provider
1825
+ },
1826
+ messageId: "providerMismatch",
1827
+ node
1828
+ });
1829
+ }
1830
+ });
1831
+ if (!hasDefault) context.report({
1832
+ data: { base },
1833
+ messageId: "missingDefault",
1834
+ node: program
1835
+ });
1836
+ else if (!wellFormed) context.report({
1837
+ data: { base },
1838
+ messageId: "notDefineContract",
1839
+ node: program
1840
+ });
1841
+ }
1842
+ /**
1843
+ * CONVENTIONS C4 — the structure of a feature's `contracts/` tree. The ROOT
1844
+ * holds only `*.contracts.ts` facades (default export = a `defineContracts`
1845
+ * composition, named exports = scenario factories) and the provider
1846
+ * directories `http` / `openai` / `anthropic`. A provider directory holds
1847
+ * `<kebab>.ts` unit contracts (default export = `defineContract(...)` or a
1848
+ * factory returning one) plus their sibling data (`*.response.json`,
1849
+ * `*.request.ts`) — the FOLDER carries the provider, so a unit whose `request`
1850
+ * builder names another provider is an error.
1851
+ *
1852
+ * Two halves: the AST half reports on each contract file it visits, the layout
1853
+ * half walks the tree from the feature's test file (oxlint never visits a
1854
+ * `.json` fixture or an empty directory).
1855
+ */
1856
+ const c4ContractShape = {
1857
+ create(context) {
1858
+ const file = context.physicalFilename;
1859
+ const parts = segments(file);
1860
+ if (!parts.includes("specs")) return {};
1861
+ const contractsIndex = parts.lastIndexOf("contracts");
1862
+ if (contractsIndex === -1) {
1863
+ if (!TEST_FILE$9.test(file)) return {};
1864
+ return { Program(node) {
1865
+ checkLayout(context, node, join(dirname(file), "contracts"));
1866
+ } };
1867
+ }
1868
+ const base = parts.at(-1) ?? "";
1869
+ const depth = parts.length - 1 - contractsIndex;
1870
+ if (depth === 1) return { Program(node) {
1871
+ checkComposite(context, node, base);
1872
+ } };
1873
+ if (depth === 2) {
1874
+ const provider = parts[contractsIndex + 1];
1875
+ return PROVIDERS.has(provider) ? { Program(node) {
1876
+ checkUnit(context, node, base, provider);
1877
+ } } : {};
1878
+ }
1879
+ return { Program(node) {
1880
+ context.report({
1881
+ data: { base },
1882
+ messageId: "tooDeep",
1524
1883
  node
1525
1884
  });
1526
1885
  } };
@@ -1528,12 +1887,18 @@ const c4ContractShape = {
1528
1887
  meta: {
1529
1888
  docs: RULE_DOCS["c4-contract-shape"],
1530
1889
  messages: {
1531
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (C4 — see docs/10-linting.md).",
1532
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1533
- missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1534
- namedExport: "Contract files have no named exports — only `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1535
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1536
- subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1890
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/10-linting.md).",
1891
+ badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/10-linting.md).",
1892
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
1893
+ missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
1894
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
1895
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
1896
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
1897
+ providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/10-linting.md).",
1898
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
1899
+ rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/10-linting.md).",
1900
+ rootFile: "Contract file \"{{base}}\" sits at the contracts/ root, which holds only *.contracts.ts facades — a unit contract belongs in http/, openai/ or anthropic/ (C4 — see docs/10-linting.md).",
1901
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
1537
1902
  },
1538
1903
  type: "problem"
1539
1904
  }
@@ -1633,22 +1998,9 @@ const VERB_ROOTS = {
1633
1998
  function looksLikePath(value) {
1634
1999
  return value.length > 0 && !/\s/u.test(value);
1635
2000
  }
1636
- /** Nearest ancestor directory named `specs` — the pool root for `$FIXTURES/`. */
1637
- function specsRoot(file) {
1638
- let dir = dirname(file);
1639
- for (;;) {
1640
- if (basename(dir) === "specs") return dir;
1641
- const parent = dirname(dir);
1642
- if (parent === dir) return;
1643
- dir = parent;
1644
- }
1645
- }
1646
2001
  /** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
1647
- function resolveFixture(argument, featureDir, file) {
1648
- if (argument.startsWith("$FIXTURES/")) {
1649
- const root = specsRoot(file);
1650
- return root === void 0 ? void 0 : join(root, "fixtures", argument.slice(10));
1651
- }
2002
+ function resolveFixture(argument, featureDir, specsDir) {
2003
+ if (argument.startsWith("$FIXTURES/")) return join(specsDir, "fixtures", argument.slice(10));
1652
2004
  if (argument.startsWith("$")) return;
1653
2005
  return join(featureDir, "fixtures", argument);
1654
2006
  }
@@ -1663,7 +2015,8 @@ function resolveFixture(argument, featureDir, file) {
1663
2015
  const c8ReferencedFixtureExists = {
1664
2016
  create(context) {
1665
2017
  const file = context.physicalFilename;
1666
- if (!TEST_FILE$6.test(file) || !segments(file).includes("specs")) return {};
2018
+ const anchor = specsAnchor(file);
2019
+ if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
1667
2020
  const featureDir = dirname(file);
1668
2021
  return { CallExpression(node) {
1669
2022
  const callee = node.callee;
@@ -1673,7 +2026,7 @@ const c8ReferencedFixtureExists = {
1673
2026
  if (verb === "fixture") {
1674
2027
  const value = stringValue(args[0]);
1675
2028
  if (value === void 0 || !looksLikePath(value)) return;
1676
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, file);
2029
+ const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
1677
2030
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
1678
2031
  data: {
1679
2032
  path: value,
@@ -2286,7 +2639,7 @@ const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2286
2639
  const f2NoTestImportsInProd = {
2287
2640
  create(context) {
2288
2641
  const file = context.filename;
2289
- if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || segments(file).includes("specs")) return {};
2642
+ if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || isUnderSpecs(file)) return {};
2290
2643
  return { ...importSourceVisitor(({ node, source }) => {
2291
2644
  if (source === TOOL_SUBPATH$1) return;
2292
2645
  if (source === "vitest" || source.startsWith("vitest/")) context.report({
@@ -2344,10 +2697,9 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
2344
2697
  */
2345
2698
  const f3SpecsPublicEntry = {
2346
2699
  create(context) {
2347
- const parts = segments(context.filename);
2348
- const specsIndex = parts.indexOf("specs");
2349
- if (specsIndex === -1) return {};
2350
- const underIntegrations = parts[specsIndex + 1] === "integrations";
2700
+ const anchor = specsAnchor(context.filename);
2701
+ if (anchor === void 0) return {};
2702
+ const underIntegrations = anchor.relative[0] === "integrations";
2351
2703
  return { ...importSourceVisitor(({ node, source }) => {
2352
2704
  if (source.startsWith(`${PACKAGE}/`)) {
2353
2705
  if (source === TOOL_SUBPATH) return;
@@ -2430,36 +2782,6 @@ const f5FixturesOnlyFromTests = {
2430
2782
  };
2431
2783
  //#endregion
2432
2784
  //#region src/lint/rules/i1-layer-boundaries.ts
2433
- /**
2434
- * One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
2435
- * `src/integrations/<folder>/` may import.
2436
- */
2437
- const INTEGRATION_DEPS = {
2438
- anthropic: ["@anthropic-ai/sdk"],
2439
- appium: ["webdriverio"],
2440
- compose: ["yaml"],
2441
- docker: [],
2442
- hono: ["hono", "@hono/node-server"],
2443
- msw: ["msw"],
2444
- openai: ["openai"],
2445
- playwright: ["playwright"],
2446
- postgres: ["pg"],
2447
- redis: ["redis"],
2448
- sqlite: ["better-sqlite3"],
2449
- testcontainers: ["testcontainers"]
2450
- };
2451
- /** External packages the vitest layer (ALL runner coupling) may import. */
2452
- const VITEST_DEPS = [
2453
- "vitest",
2454
- "vitest-mock-extended",
2455
- "mockdate"
2456
- ];
2457
- /** The pure core helpers the tool-facing lint layer may reach (I1). */
2458
- const LINT_CORE_WHITELIST = /* @__PURE__ */ new Set([
2459
- "core/matching/match",
2460
- "core/specification/shared/binding",
2461
- "core/specification/shared/fixtures"
2462
- ]);
2463
2785
  const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
2464
2786
  function matchesPackage(source, packages) {
2465
2787
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
@@ -2470,64 +2792,68 @@ function pathInsideSrc(path) {
2470
2792
  const srcIndex = parts.lastIndexOf("src");
2471
2793
  return srcIndex === -1 ? void 0 : parts.slice(srcIndex + 1).join("/");
2472
2794
  }
2473
- /** Strip a `.js`/`.ts`-style extension for whitelist comparison. */
2795
+ /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
2474
2796
  function withoutExtension(path) {
2475
2797
  return path.replace(/\.[cm]?[jt]sx?$/, "");
2476
2798
  }
2799
+ /** Does `target` match one of the declared paths (prefix, or exact module)? */
2800
+ function matchesPath(target, patterns) {
2801
+ return patterns.some((pattern) => pattern.endsWith("/") ? target.startsWith(pattern) : withoutExtension(target) === withoutExtension(pattern));
2802
+ }
2477
2803
  /**
2478
- * CONVENTIONS I1 — the four layers under `src/` and their sanctioned edges:
2804
+ * CONVENTIONS I1 — the layers under `src/` and their sanctioned edges, DECLARED
2805
+ * by the project:
2806
+ *
2807
+ * 'jterrazz/i1-layer-boundaries': ['error', {
2808
+ * layers: {
2809
+ * core: { imports: ['core/', 'integrations/docker/'] },
2810
+ * integrations: { folders: { postgres: ['pg'] }, imports: ['core/'] },
2811
+ * },
2812
+ * }]
2479
2813
  *
2480
- * - `core/` — zero external imports; may reach `integrations/docker`,
2481
- * `integrations/hono`, `vitest/matchers`, and (from `builder.ts` only, the
2482
- * lazy MSW seam) `integrations/msw`.
2483
- * - `integrations/<dep>/` — its own external dependency and `core/` only.
2484
- * - `vitest/` — the runner coupling: `vitest`, `vitest-mock-extended`,
2485
- * `mockdate`, plus `core/` and `integrations/docker` (the matchers recognise
2486
- * the zero-dependency ContainerAccessor subject).
2487
- * - `lint/` — zero runtime imports: no external packages, and from `core/`
2488
- * only the pure helpers (token list, case conversions, fixture markers).
2814
+ * With no layer map the rule is INERT. It used to ship the framework's own
2815
+ * architecture as its law — four layers named `core` / `integrations` / `lint`
2816
+ * / `vitest`, and the framework's own dependency table — and applied it to every
2817
+ * consumer that enabled the catalogue. A consumer with those directory names
2818
+ * was judged against a map describing a different package; a consumer with any
2819
+ * other architecture got a rule that could never say anything true. An
2820
+ * architecture is the project's to state.
2489
2821
  *
2490
- * Module tests and fixtures files are exempt (their imports are governed by
2491
- * F2/I4); `src/index.ts` is the composition root and lives above the layers.
2822
+ * Per layer: `packages` are the external dependencies it may import, `imports`
2823
+ * the paths inside `src/` it may reach (a `foo/` prefix, or an exact module
2824
+ * path), `folders` expresses "one folder = one external dependency" (each child
2825
+ * folder may import its own packages and its own files), and `seams` opens a
2826
+ * named module to an extra edge — the lazy import a layer allows exactly once.
2827
+ *
2828
+ * A file under a layer the map does not name is out of scope, as are module
2829
+ * tests and `*.fixtures.ts` files (F2/I4 govern those), and any file outside
2830
+ * `src/`.
2492
2831
  */
2493
2832
  const i1LayerBoundaries = {
2494
2833
  create(context) {
2834
+ const layers = context.options[0]?.layers;
2835
+ if (layers === void 0 || Object.keys(layers).length === 0) return {};
2495
2836
  const file = context.physicalFilename;
2496
2837
  if (TEST_OR_FIXTURES.test(file)) return {};
2497
2838
  const inside = pathInsideSrc(file);
2498
2839
  const layer = inside?.split("/")[0];
2499
- if (inside === void 0 || layer === void 0 || ![
2500
- "core",
2501
- "integrations",
2502
- "lint",
2503
- "vitest"
2504
- ].includes(layer)) return {};
2505
- const integrationFolder = layer === "integrations" ? inside.split("/")[1] : void 0;
2506
- const checkExternal = (source) => {
2507
- if (layer === "core") return "coreExternal";
2508
- if (layer === "lint") return "lintRuntime";
2509
- if (layer === "vitest") return matchesPackage(source, VITEST_DEPS) ? null : "foreignDependency";
2510
- return matchesPackage(source, INTEGRATION_DEPS[integrationFolder ?? ""] ?? []) ? null : "foreignDependency";
2511
- };
2512
- const checkInternal = (target) => {
2513
- if (layer === "core") {
2514
- if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2515
- if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2516
- if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2517
- if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
2518
- return "crossLayer";
2519
- }
2520
- if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
2521
- if (layer === "vitest") return target.startsWith("core/") || target.startsWith("vitest/") || target.startsWith("integrations/docker/") ? null : "crossLayer";
2522
- return target.startsWith("lint/") || LINT_CORE_WHITELIST.has(withoutExtension(target)) ? null : "lintRuntime";
2840
+ const rules = layer === void 0 ? void 0 : layers[layer];
2841
+ if (inside === void 0 || layer === void 0 || rules === void 0) return {};
2842
+ const folder = rules.folders === void 0 ? void 0 : inside.split("/")[1];
2843
+ const ownPackages = [...rules.packages ?? [], ...rules.folders?.[folder ?? ""] ?? []];
2844
+ const ownImports = [...rules.imports ?? [], ...rules.seams?.[inside] ?? []];
2845
+ const allowsExternal = (source) => matchesPackage(source, ownPackages);
2846
+ const allowsInternal = (target) => {
2847
+ if (matchesPath(target, ownImports)) return true;
2848
+ return folder !== void 0 && target.startsWith(`${layer}/${folder}/`);
2523
2849
  };
2524
2850
  return { ...importSourceVisitor(({ node, source }) => {
2525
2851
  if (source.startsWith("node:")) return;
2526
2852
  let messageId;
2527
2853
  if (source.startsWith(".")) {
2528
2854
  const target = pathInsideSrc(resolve(dirname(file), source));
2529
- messageId = target === void 0 ? "crossLayer" : checkInternal(target);
2530
- } else messageId = checkExternal(source);
2855
+ messageId = target === void 0 || !allowsInternal(target) ? "crossLayer" : null;
2856
+ } else messageId = allowsExternal(source) ? null : "foreignDependency";
2531
2857
  if (messageId !== null) context.report({
2532
2858
  data: {
2533
2859
  layer,
@@ -2539,13 +2865,47 @@ const i1LayerBoundaries = {
2539
2865
  }) };
2540
2866
  },
2541
2867
  meta: {
2868
+ defaultOptions: [{ layers: {} }],
2542
2869
  docs: RULE_DOCS["i1-layer-boundaries"],
2543
2870
  messages: {
2544
- coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (I1 — see docs/10-linting.md).",
2545
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (I1 — see docs/10-linting.md).",
2546
- foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
2547
- lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (I1 — see docs/10-linting.md)."
2871
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/10-linting.md).",
2872
+ foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/10-linting.md)."
2548
2873
  },
2874
+ schema: [{
2875
+ additionalProperties: false,
2876
+ properties: { layers: {
2877
+ additionalProperties: {
2878
+ additionalProperties: false,
2879
+ properties: {
2880
+ folders: {
2881
+ additionalProperties: {
2882
+ items: { type: "string" },
2883
+ type: "array"
2884
+ },
2885
+ type: "object"
2886
+ },
2887
+ imports: {
2888
+ items: { type: "string" },
2889
+ type: "array"
2890
+ },
2891
+ packages: {
2892
+ items: { type: "string" },
2893
+ type: "array"
2894
+ },
2895
+ seams: {
2896
+ additionalProperties: {
2897
+ items: { type: "string" },
2898
+ type: "array"
2899
+ },
2900
+ type: "object"
2901
+ }
2902
+ },
2903
+ type: "object"
2904
+ },
2905
+ type: "object"
2906
+ } },
2907
+ type: "object"
2908
+ }],
2549
2909
  type: "problem"
2550
2910
  }
2551
2911
  };
@@ -2607,10 +2967,48 @@ const i2SiblingTestNaming = {
2607
2967
  //#endregion
2608
2968
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
2609
2969
  const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
2610
- /** Module-ish sources a src test may import; anything else with an extension is a data asset. */
2611
- const CODE_IMPORT = /\.[cm]?[jt]sx?$/;
2612
- /** Does this import path even carry an extension? Bare/extension-less ones are code. */
2613
- const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2970
+ const DATA_ASSET = new RegExp(`\\.(?:${[
2971
+ "avif",
2972
+ "bin",
2973
+ "bmp",
2974
+ "csv",
2975
+ "gif",
2976
+ "graphql",
2977
+ "gql",
2978
+ "gz",
2979
+ "htm",
2980
+ "html",
2981
+ "ico",
2982
+ "ini",
2983
+ "jpeg",
2984
+ "jpg",
2985
+ "json",
2986
+ "json5",
2987
+ "jsonc",
2988
+ "jsonl",
2989
+ "md",
2990
+ "mdx",
2991
+ "ndjson",
2992
+ "pdf",
2993
+ "png",
2994
+ "proto",
2995
+ "sql",
2996
+ "svg",
2997
+ "tar",
2998
+ "toml",
2999
+ "tsv",
3000
+ "txt",
3001
+ "wasm",
3002
+ "webp",
3003
+ "xml",
3004
+ "yaml",
3005
+ "yml",
3006
+ "zip"
3007
+ ].join("|")})$`, "i");
3008
+ /** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
3009
+ function withoutSuffix(source) {
3010
+ return source.replace(/[?#].*$/, "");
3011
+ }
2614
3012
  /**
2615
3013
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
2616
3014
  * `mockOf`/`mockOfDate` inline, large payloads in a `*.fixtures.ts` neighbour.
@@ -2618,8 +3016,11 @@ const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2618
3016
  *
2619
3017
  * - `vi.mock(…)` calls (module mocking) in any file;
2620
3018
  * - files living in a `__mocks__/` or `__fixtures__/` directory;
2621
- * - a `*.test.ts` importing a non-code asset (`.json`, `.txt`, `.sql`, …) —
3019
+ * - a `*.test.ts` importing a known data asset (`.json`, `.txt`, `.sql`, …) —
2622
3020
  * a test needing a real file is a specification and belongs in `specs/`.
3021
+ *
3022
+ * A specifier whose extension is not on the data list is CODE, dotted or not:
3023
+ * `<subject>.<role>` module names are a naming convention, not a file type.
2623
3024
  */
2624
3025
  const i4NoViMockInSrc = {
2625
3026
  create(context) {
@@ -2647,7 +3048,7 @@ const i4NoViMockInSrc = {
2647
3048
  }
2648
3049
  };
2649
3050
  if (isTest) Object.assign(visitor, importSourceVisitor(({ node, source }) => {
2650
- if (HAS_EXTENSION.test(source) && !CODE_IMPORT.test(source)) context.report({
3051
+ if (DATA_ASSET.test(withoutSuffix(source))) context.report({
2651
3052
  data: { source },
2652
3053
  messageId: "assetImport",
2653
3054
  node
@@ -2718,7 +3119,7 @@ const j1NoOnlySkip = {
2718
3119
  */
2719
3120
  const j2NoSleepInSpecs = {
2720
3121
  create(context) {
2721
- if (!segments(context.filename).includes("specs")) return {};
3122
+ if (!isUnderSpecs(context.filename)) return {};
2722
3123
  return {
2723
3124
  CallExpression(node) {
2724
3125
  const callee = node.callee;
@@ -2950,6 +3351,8 @@ const plugin = {
2950
3351
  "b8-kebab-trigger": b8KebabTrigger,
2951
3352
  "b9w-product-command": b9wProductCommand,
2952
3353
  "c1-domain-structure": c1DomainStructure,
3354
+ "c10-contracts-boundary": c10ContractsBoundary,
3355
+ "c11-contract-data-pairing": c11ContractDataPairing,
2953
3356
  "c2-http-only-requests": c2HttpOnlyRequests,
2954
3357
  "c4-contract-shape": c4ContractShape,
2955
3358
  "c6-tomatch-extension": c6ToMatchExtension,