@jterrazz/test 11.0.0 → 12.0.1

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.cjs CHANGED
@@ -4,6 +4,63 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  let node_path = require("node:path");
6
6
  let node_fs = require("node:fs");
7
+ //#region src/lint/fs-cache.ts
8
+ /**
9
+ * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
10
+ *
11
+ * Rules only ever look at directories NEXT TO files oxlint is already
12
+ * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
13
+ * file), so the probe set is small — but several rules probe the same feature
14
+ * directory, and oxlint lints many files per process. A module-level cache
15
+ * keeps each `readdir`/`stat` to one syscall per lint run.
16
+ */
17
+ const dirListings = /* @__PURE__ */ new Map();
18
+ function listDirectory(dir) {
19
+ const cached = dirListings.get(dir);
20
+ if (cached !== void 0) return cached;
21
+ let result;
22
+ try {
23
+ result = (0, node_fs.readdirSync)(dir);
24
+ } catch {
25
+ result = null;
26
+ }
27
+ dirListings.set(dir, result);
28
+ return result;
29
+ }
30
+ const kinds = /* @__PURE__ */ new Map();
31
+ function kindOf(path) {
32
+ const cached = kinds.get(path);
33
+ if (cached !== void 0) return cached;
34
+ let result;
35
+ try {
36
+ result = (0, node_fs.statSync)(path).isDirectory() ? "dir" : "file";
37
+ } catch {
38
+ result = "missing";
39
+ }
40
+ kinds.set(path, result);
41
+ return result;
42
+ }
43
+ function isDirectory(path) {
44
+ return kindOf(path) === "dir";
45
+ }
46
+ function isFile(path) {
47
+ return kindOf(path) === "file";
48
+ }
49
+ const fileContents = /* @__PURE__ */ new Map();
50
+ /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
51
+ function readFileCached(path) {
52
+ const cached = fileContents.get(path);
53
+ if (cached !== void 0) return cached;
54
+ let result;
55
+ try {
56
+ result = (0, node_fs.readFileSync)(path, "utf8");
57
+ } catch {
58
+ result = null;
59
+ }
60
+ fileContents.set(path, result);
61
+ return result;
62
+ }
63
+ //#endregion
7
64
  //#region src/lint/ast.ts
8
65
  /**
9
66
  * Shared AST helpers for the rule files. Everything here is pure and
@@ -14,6 +71,39 @@ let node_fs = require("node:fs");
14
71
  function segments(path) {
15
72
  return path.split(/[/\\]/).filter(Boolean);
16
73
  }
74
+ /**
75
+ * The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
76
+ * searched no higher than the nearest `package.json`. `undefined` when the file
77
+ * is not under one.
78
+ *
79
+ * One anchor for every specs-aware rule. Rules used to hand-roll the search and
80
+ * disagreed with each other — `lastIndexOf('specs')` (C1) took the innermost
81
+ * match, `indexOf('specs')` (F3) the outermost, so one nested tree read as two
82
+ * different facets. And any bare "is `specs` a segment of this path" test
83
+ * matched directories that have nothing to do with the project: a checkout
84
+ * living under `~/specs/` turned the whole repository into a specs tree, which
85
+ * silently disabled F2's protection of production code. The package boundary
86
+ * makes the search stop where the project does.
87
+ */
88
+ function specsAnchor(filename) {
89
+ const relative = [(0, node_path.basename)(filename)];
90
+ let directory = (0, node_path.dirname)(filename);
91
+ for (;;) {
92
+ if ((0, node_path.basename)(directory) === "specs") return {
93
+ directory,
94
+ relative
95
+ };
96
+ if (isFile((0, node_path.join)(directory, "package.json"))) return;
97
+ const parent = (0, node_path.dirname)(directory);
98
+ if (parent === directory) return;
99
+ relative.unshift((0, node_path.basename)(directory));
100
+ directory = parent;
101
+ }
102
+ }
103
+ /** Is this file inside a `specs/` tree? — the predicate form of {@link specsAnchor}. */
104
+ function isUnderSpecs(filename) {
105
+ return specsAnchor(filename) !== void 0;
106
+ }
17
107
  /** The string value of a plain string literal (or a template with no holes). */
18
108
  function stringValue(node) {
19
109
  if (node === void 0) return;
@@ -250,7 +340,7 @@ const RULE_DOCS = {
250
340
  },
251
341
  "c1-domain-structure": {
252
342
  channel: "statique",
253
- convention: "Un `*.test.ts` vit à la profondeur facet/domain, un `*.specification.ts` au root de la facette ; toute autre profondeur est une erreur.",
343
+ 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`.",
254
344
  family: "C",
255
345
  id: "C1",
256
346
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
@@ -397,10 +487,10 @@ const RULE_DOCS = {
397
487
  },
398
488
  "i1-layer-boundaries": {
399
489
  channel: "statique",
400
- 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.",
490
+ 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.",
401
491
  family: "I",
402
492
  id: "I1",
403
- rationale: "Des frontières strictes gardent `core/` pur et chaque intégration confinée à sa dépendance."
493
+ rationale: "Une architecture appartient au projet : des frontières déclarées se vérifient, une architecture supposée se contourne."
404
494
  },
405
495
  "i2-sibling-test-naming": {
406
496
  channel: "statique",
@@ -411,7 +501,7 @@ const RULE_DOCS = {
411
501
  },
412
502
  "i4-no-vi-mock-in-src": {
413
503
  channel: "statique",
414
- convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset non-`.ts` depuis un `.test.ts` sont interdits.",
504
+ 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.",
415
505
  family: "I",
416
506
  id: "I4",
417
507
  rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
@@ -990,79 +1080,43 @@ const a6wRedundantComposeService = {
990
1080
  }
991
1081
  };
992
1082
  //#endregion
993
- //#region src/lint/fs-cache.ts
1083
+ //#region src/core/specification/shared/resolve.ts
1084
+ /** What makes a directory a project root — a manifest, or a test stack. */
1085
+ const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
994
1086
  /**
995
- * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
1087
+ * The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
1088
+ * `undefined` when none does.
996
1089
  *
997
- * Rules only ever look at directories NEXT TO files oxlint is already
998
- * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
999
- * file), so the probe set is small — but several rules probe the same feature
1000
- * directory, and oxlint lints many files per process. A module-level cache
1001
- * keeps each `readdir`/`stat` to one syscall per lint run.
1090
+ * Both markers are probed at each ancestor, in ONE walk. Two walks — the
1091
+ * compose file all the way up, then `package.json` all the way up — made the
1092
+ * further directory win: in a workspace, a `docker/compose.test.yaml` at the
1093
+ * repository root outranked the `package.json` of the very package being
1094
+ * tested, and every path the runner resolved was measured from the wrong unit.
1095
+ * The unit is the nearest thing that declares itself a project.
1096
+ *
1097
+ * The existence probe is injected so the lint layer can pass its cached one
1098
+ * (A9's `a9w-redundant-root` derives the same root, and must derive it the
1099
+ * same way).
1002
1100
  */
1003
- const dirListings = /* @__PURE__ */ new Map();
1004
- function listDirectory(dir) {
1005
- const cached = dirListings.get(dir);
1006
- if (cached !== void 0) return cached;
1007
- let result;
1008
- try {
1009
- result = (0, node_fs.readdirSync)(dir);
1010
- } catch {
1011
- result = null;
1012
- }
1013
- dirListings.set(dir, result);
1014
- return result;
1015
- }
1016
- const kinds = /* @__PURE__ */ new Map();
1017
- function kindOf(path) {
1018
- const cached = kinds.get(path);
1019
- if (cached !== void 0) return cached;
1020
- let result;
1021
- try {
1022
- result = (0, node_fs.statSync)(path).isDirectory() ? "dir" : "file";
1023
- } catch {
1024
- result = "missing";
1025
- }
1026
- kinds.set(path, result);
1027
- return result;
1028
- }
1029
- function isDirectory(path) {
1030
- return kindOf(path) === "dir";
1031
- }
1032
- function isFile(path) {
1033
- return kindOf(path) === "file";
1034
- }
1035
- const fileContents = /* @__PURE__ */ new Map();
1036
- /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
1037
- function readFileCached(path) {
1038
- const cached = fileContents.get(path);
1039
- if (cached !== void 0) return cached;
1040
- let result;
1041
- try {
1042
- result = (0, node_fs.readFileSync)(path, "utf8");
1043
- } catch {
1044
- result = null;
1101
+ function findRoot(startDir, exists) {
1102
+ let dir = startDir;
1103
+ for (;;) {
1104
+ if (ROOT_MARKERS.some((marker) => exists((0, node_path.resolve)(dir, marker)))) return dir;
1105
+ const parent = (0, node_path.dirname)(dir);
1106
+ if (parent === dir) return;
1107
+ dir = parent;
1045
1108
  }
1046
- fileContents.set(path, result);
1047
- return result;
1048
1109
  }
1049
1110
  //#endregion
1050
1111
  //#region src/lint/rules/a9w-redundant-root.ts
1051
1112
  /**
1052
- * The root the convention would derive (A9 — see docs/10-linting.md): walk up from the
1053
- * specification file to the first directory containing
1054
- * `docker/compose.test.yaml`, else the first containing `package.json`.
1113
+ * The root the convention would derive (A9 — see docs/10-linting.md): the
1114
+ * framework's own walk-up, run against this layer's cached fs probe. The rule
1115
+ * flags a `root` that the runner would have found by itself, so the two MUST
1116
+ * be the same function — a private copy of the walk drifted from it before.
1055
1117
  */
1056
1118
  function derivedRoot(startDir) {
1057
- for (const probe of ["docker/compose.test.yaml", "package.json"]) {
1058
- let dir = startDir;
1059
- for (;;) {
1060
- if (isFile((0, node_path.join)(dir, probe))) return dir;
1061
- const parent = (0, node_path.dirname)(dir);
1062
- if (parent === dir) break;
1063
- dir = parent;
1064
- }
1065
- }
1119
+ return findRoot(startDir, isFile);
1066
1120
  }
1067
1121
  /**
1068
1122
  * CONVENTIONS A9 (warning) — `root` is an override reserved for cases the
@@ -1394,8 +1448,16 @@ const b9wProductCommand = {
1394
1448
  //#region src/lint/rules/c1-domain-structure.ts
1395
1449
  const TEST_SUFFIX = ".test.ts";
1396
1450
  const SPECIFICATION_SUFFIX = ".specification.ts";
1451
+ const DEFAULT_DEPTH = "facet-domain";
1397
1452
  /**
1398
- * CONVENTIONS C1' — facet-root specifications, domain-nested tests.
1453
+ * CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
1454
+ *
1455
+ * A project states which shape its `specs/` tree has; the rule checks that one.
1456
+ * Before this option there was a single shape and the only way out was `off`,
1457
+ * which took the whole rule with it — a tree with a different, equally
1458
+ * deliberate shape had no way to be checked at all.
1459
+ *
1460
+ * `depth: 'facet-domain'` (the default, and the historical behaviour)
1399
1461
  *
1400
1462
  * A facet (`specs/<facet>/` — api, jobs, cli, integrations, lint, …) is the
1401
1463
  * master folder. It carries its runner(s) at its ROOT
@@ -1403,36 +1465,66 @@ const SPECIFICATION_SUFFIX = ".specification.ts";
1403
1465
  * product command/area with one or more `<aspect>.test.ts` files plus their
1404
1466
  * shared asset dirs.
1405
1467
  *
1406
- * Two placement invariants, checked from the filename:
1407
- * - a `*.test.ts` must sit at facet/domain depth — exactly
1468
+ * - a `*.test.ts` sits at facet/domain depth — exactly
1408
1469
  * `specs/<facet>/<domain>/<file>.test.ts`. A test directly at the facet root
1409
1470
  * (or nested deeper than a domain) is rejected.
1410
- * - a `*.specification.ts` must sit at the facet root — exactly
1471
+ * - a `*.specification.ts` sits at the facet root — exactly
1411
1472
  * `specs/<facet>/<file>.specification.ts`, never inside a domain.
1412
1473
  *
1413
- * Module tests under `src/` follow the neighbour rule (I2) and are out of scope.
1474
+ * `depth: 'mirror'`
1475
+ *
1476
+ * The tree mirrors something outside itself (a command tree, a source tree), so
1477
+ * the depth is that structure's and no fixed number can name it. A `*.test.ts`
1478
+ * sits at ANY depth of at least one directory under `specs/`, and is NAMED
1479
+ * AFTER the directory holding it — `<dir>/<dir>.test.ts`. That naming is what
1480
+ * keeps the shape checkable: one test per mirrored node, no test loose at the
1481
+ * specs root. Specification files are unconstrained: a mirror has no facet
1482
+ * level to anchor them to.
1483
+ *
1484
+ * `depth: 'off'` — no placement check; the tree's shape is guarded elsewhere.
1485
+ *
1486
+ * Module tests under `src/` follow the neighbour rule (I2) and are out of scope
1487
+ * in every mode.
1414
1488
  */
1415
1489
  const c1DomainStructure = {
1416
1490
  create(context) {
1491
+ const depth = context.options[0]?.depth ?? DEFAULT_DEPTH;
1492
+ if (depth === "off") return {};
1417
1493
  return { Program(node) {
1418
- const parts = segments(context.filename);
1419
- const base = parts.at(-1) ?? "";
1420
- const specsIndex = parts.lastIndexOf("specs");
1421
- if (specsIndex === -1) return;
1422
- const depth = parts.length - 1 - (specsIndex + 1);
1494
+ const anchor = specsAnchor(context.filename);
1495
+ if (anchor === void 0) return;
1496
+ const base = anchor.relative.at(-1) ?? "";
1497
+ const nesting = anchor.relative.length - 1;
1498
+ if (depth === "mirror") {
1499
+ if (!base.endsWith(TEST_SUFFIX)) return;
1500
+ if (nesting < 1) {
1501
+ context.report({
1502
+ messageId: "testAtSpecsRoot",
1503
+ node
1504
+ });
1505
+ return;
1506
+ }
1507
+ const directory = anchor.relative.at(-2) ?? "";
1508
+ if (base.slice(0, -8) !== directory) context.report({
1509
+ data: { directory },
1510
+ messageId: "testNotMirroringDirectory",
1511
+ node
1512
+ });
1513
+ return;
1514
+ }
1423
1515
  if (base.endsWith(TEST_SUFFIX)) {
1424
- if (depth < 2) context.report({
1516
+ if (nesting < 2) context.report({
1425
1517
  messageId: "testAtFacetRoot",
1426
1518
  node
1427
1519
  });
1428
- else if (depth > 2) context.report({
1520
+ else if (nesting > 2) context.report({
1429
1521
  messageId: "testTooDeep",
1430
1522
  node
1431
1523
  });
1432
1524
  return;
1433
1525
  }
1434
1526
  if (base.endsWith(SPECIFICATION_SUFFIX)) {
1435
- if (depth !== 1) context.report({
1527
+ if (nesting !== 1) context.report({
1436
1528
  messageId: "specNotAtFacetRoot",
1437
1529
  node
1438
1530
  });
@@ -1440,12 +1532,27 @@ const c1DomainStructure = {
1440
1532
  } };
1441
1533
  },
1442
1534
  meta: {
1535
+ defaultOptions: [{ depth: DEFAULT_DEPTH }],
1443
1536
  docs: RULE_DOCS["c1-domain-structure"],
1444
1537
  messages: {
1445
1538
  specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1446
1539
  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).",
1540
+ 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).",
1541
+ 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).",
1447
1542
  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)."
1448
1543
  },
1544
+ schema: [{
1545
+ additionalProperties: false,
1546
+ properties: { depth: {
1547
+ enum: [
1548
+ "facet-domain",
1549
+ "mirror",
1550
+ "off"
1551
+ ],
1552
+ type: "string"
1553
+ } },
1554
+ type: "object"
1555
+ }],
1449
1556
  type: "problem"
1450
1557
  }
1451
1558
  };
@@ -1895,22 +2002,9 @@ const VERB_ROOTS = {
1895
2002
  function looksLikePath(value) {
1896
2003
  return value.length > 0 && !/\s/u.test(value);
1897
2004
  }
1898
- /** Nearest ancestor directory named `specs` — the pool root for `$FIXTURES/`. */
1899
- function specsRoot(file) {
1900
- let dir = (0, node_path.dirname)(file);
1901
- for (;;) {
1902
- if ((0, node_path.basename)(dir) === "specs") return dir;
1903
- const parent = (0, node_path.dirname)(dir);
1904
- if (parent === dir) return;
1905
- dir = parent;
1906
- }
1907
- }
1908
2005
  /** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
1909
- function resolveFixture(argument, featureDir, file) {
1910
- if (argument.startsWith("$FIXTURES/")) {
1911
- const root = specsRoot(file);
1912
- return root === void 0 ? void 0 : (0, node_path.join)(root, "fixtures", argument.slice(10));
1913
- }
2006
+ function resolveFixture(argument, featureDir, specsDir) {
2007
+ if (argument.startsWith("$FIXTURES/")) return (0, node_path.join)(specsDir, "fixtures", argument.slice(10));
1914
2008
  if (argument.startsWith("$")) return;
1915
2009
  return (0, node_path.join)(featureDir, "fixtures", argument);
1916
2010
  }
@@ -1925,7 +2019,8 @@ function resolveFixture(argument, featureDir, file) {
1925
2019
  const c8ReferencedFixtureExists = {
1926
2020
  create(context) {
1927
2021
  const file = context.physicalFilename;
1928
- if (!TEST_FILE$6.test(file) || !segments(file).includes("specs")) return {};
2022
+ const anchor = specsAnchor(file);
2023
+ if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
1929
2024
  const featureDir = (0, node_path.dirname)(file);
1930
2025
  return { CallExpression(node) {
1931
2026
  const callee = node.callee;
@@ -1935,7 +2030,7 @@ const c8ReferencedFixtureExists = {
1935
2030
  if (verb === "fixture") {
1936
2031
  const value = stringValue(args[0]);
1937
2032
  if (value === void 0 || !looksLikePath(value)) return;
1938
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, file);
2033
+ const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
1939
2034
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
1940
2035
  data: {
1941
2036
  path: value,
@@ -2548,7 +2643,7 @@ const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2548
2643
  const f2NoTestImportsInProd = {
2549
2644
  create(context) {
2550
2645
  const file = context.filename;
2551
- if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || segments(file).includes("specs")) return {};
2646
+ if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || isUnderSpecs(file)) return {};
2552
2647
  return { ...importSourceVisitor(({ node, source }) => {
2553
2648
  if (source === TOOL_SUBPATH$1) return;
2554
2649
  if (source === "vitest" || source.startsWith("vitest/")) context.report({
@@ -2606,10 +2701,9 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
2606
2701
  */
2607
2702
  const f3SpecsPublicEntry = {
2608
2703
  create(context) {
2609
- const parts = segments(context.filename);
2610
- const specsIndex = parts.indexOf("specs");
2611
- if (specsIndex === -1) return {};
2612
- const underIntegrations = parts[specsIndex + 1] === "integrations";
2704
+ const anchor = specsAnchor(context.filename);
2705
+ if (anchor === void 0) return {};
2706
+ const underIntegrations = anchor.relative[0] === "integrations";
2613
2707
  return { ...importSourceVisitor(({ node, source }) => {
2614
2708
  if (source.startsWith(`${PACKAGE}/`)) {
2615
2709
  if (source === TOOL_SUBPATH) return;
@@ -2692,36 +2786,6 @@ const f5FixturesOnlyFromTests = {
2692
2786
  };
2693
2787
  //#endregion
2694
2788
  //#region src/lint/rules/i1-layer-boundaries.ts
2695
- /**
2696
- * One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
2697
- * `src/integrations/<folder>/` may import.
2698
- */
2699
- const INTEGRATION_DEPS = {
2700
- anthropic: ["@anthropic-ai/sdk"],
2701
- appium: ["webdriverio"],
2702
- compose: ["yaml"],
2703
- docker: [],
2704
- hono: ["hono", "@hono/node-server"],
2705
- msw: ["msw"],
2706
- openai: ["openai"],
2707
- playwright: ["playwright"],
2708
- postgres: ["pg"],
2709
- redis: ["redis"],
2710
- sqlite: ["better-sqlite3"],
2711
- testcontainers: ["testcontainers"]
2712
- };
2713
- /** External packages the vitest layer (ALL runner coupling) may import. */
2714
- const VITEST_DEPS = [
2715
- "vitest",
2716
- "vitest-mock-extended",
2717
- "mockdate"
2718
- ];
2719
- /** The pure core helpers the tool-facing lint layer may reach (I1). */
2720
- const LINT_CORE_WHITELIST = /* @__PURE__ */ new Set([
2721
- "core/matching/match",
2722
- "core/specification/shared/binding",
2723
- "core/specification/shared/fixtures"
2724
- ]);
2725
2789
  const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
2726
2790
  function matchesPackage(source, packages) {
2727
2791
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
@@ -2732,64 +2796,68 @@ function pathInsideSrc(path) {
2732
2796
  const srcIndex = parts.lastIndexOf("src");
2733
2797
  return srcIndex === -1 ? void 0 : parts.slice(srcIndex + 1).join("/");
2734
2798
  }
2735
- /** Strip a `.js`/`.ts`-style extension for whitelist comparison. */
2799
+ /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
2736
2800
  function withoutExtension(path) {
2737
2801
  return path.replace(/\.[cm]?[jt]sx?$/, "");
2738
2802
  }
2803
+ /** Does `target` match one of the declared paths (prefix, or exact module)? */
2804
+ function matchesPath(target, patterns) {
2805
+ return patterns.some((pattern) => pattern.endsWith("/") ? target.startsWith(pattern) : withoutExtension(target) === withoutExtension(pattern));
2806
+ }
2739
2807
  /**
2740
- * CONVENTIONS I1 — the four layers under `src/` and their sanctioned edges:
2808
+ * CONVENTIONS I1 — the layers under `src/` and their sanctioned edges, DECLARED
2809
+ * by the project:
2810
+ *
2811
+ * 'jterrazz/i1-layer-boundaries': ['error', {
2812
+ * layers: {
2813
+ * core: { imports: ['core/', 'integrations/docker/'] },
2814
+ * integrations: { folders: { postgres: ['pg'] }, imports: ['core/'] },
2815
+ * },
2816
+ * }]
2817
+ *
2818
+ * With no layer map the rule is INERT. It used to ship the framework's own
2819
+ * architecture as its law — four layers named `core` / `integrations` / `lint`
2820
+ * / `vitest`, and the framework's own dependency table — and applied it to every
2821
+ * consumer that enabled the catalogue. A consumer with those directory names
2822
+ * was judged against a map describing a different package; a consumer with any
2823
+ * other architecture got a rule that could never say anything true. An
2824
+ * architecture is the project's to state.
2741
2825
  *
2742
- * - `core/` — zero external imports; may reach `integrations/docker`,
2743
- * `integrations/hono`, `vitest/matchers`, and (from `builder.ts` only, the
2744
- * lazy MSW seam) `integrations/msw`.
2745
- * - `integrations/<dep>/` — its own external dependency and `core/` only.
2746
- * - `vitest/` — the runner coupling: `vitest`, `vitest-mock-extended`,
2747
- * `mockdate`, plus `core/` and `integrations/docker` (the matchers recognise
2748
- * the zero-dependency ContainerAccessor subject).
2749
- * - `lint/` — zero runtime imports: no external packages, and from `core/`
2750
- * only the pure helpers (token list, case conversions, fixture markers).
2826
+ * Per layer: `packages` are the external dependencies it may import, `imports`
2827
+ * the paths inside `src/` it may reach (a `foo/` prefix, or an exact module
2828
+ * path), `folders` expresses "one folder = one external dependency" (each child
2829
+ * folder may import its own packages and its own files), and `seams` opens a
2830
+ * named module to an extra edge — the lazy import a layer allows exactly once.
2751
2831
  *
2752
- * Module tests and fixtures files are exempt (their imports are governed by
2753
- * F2/I4); `src/index.ts` is the composition root and lives above the layers.
2832
+ * A file under a layer the map does not name is out of scope, as are module
2833
+ * tests and `*.fixtures.ts` files (F2/I4 govern those), and any file outside
2834
+ * `src/`.
2754
2835
  */
2755
2836
  const i1LayerBoundaries = {
2756
2837
  create(context) {
2838
+ const layers = context.options[0]?.layers;
2839
+ if (layers === void 0 || Object.keys(layers).length === 0) return {};
2757
2840
  const file = context.physicalFilename;
2758
2841
  if (TEST_OR_FIXTURES.test(file)) return {};
2759
2842
  const inside = pathInsideSrc(file);
2760
2843
  const layer = inside?.split("/")[0];
2761
- if (inside === void 0 || layer === void 0 || ![
2762
- "core",
2763
- "integrations",
2764
- "lint",
2765
- "vitest"
2766
- ].includes(layer)) return {};
2767
- const integrationFolder = layer === "integrations" ? inside.split("/")[1] : void 0;
2768
- const checkExternal = (source) => {
2769
- if (layer === "core") return "coreExternal";
2770
- if (layer === "lint") return "lintRuntime";
2771
- if (layer === "vitest") return matchesPackage(source, VITEST_DEPS) ? null : "foreignDependency";
2772
- return matchesPackage(source, INTEGRATION_DEPS[integrationFolder ?? ""] ?? []) ? null : "foreignDependency";
2773
- };
2774
- const checkInternal = (target) => {
2775
- if (layer === "core") {
2776
- if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2777
- if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2778
- if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2779
- if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
2780
- return "crossLayer";
2781
- }
2782
- if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
2783
- if (layer === "vitest") return target.startsWith("core/") || target.startsWith("vitest/") || target.startsWith("integrations/docker/") ? null : "crossLayer";
2784
- return target.startsWith("lint/") || LINT_CORE_WHITELIST.has(withoutExtension(target)) ? null : "lintRuntime";
2844
+ const rules = layer === void 0 ? void 0 : layers[layer];
2845
+ if (inside === void 0 || layer === void 0 || rules === void 0) return {};
2846
+ const folder = rules.folders === void 0 ? void 0 : inside.split("/")[1];
2847
+ const ownPackages = [...rules.packages ?? [], ...rules.folders?.[folder ?? ""] ?? []];
2848
+ const ownImports = [...rules.imports ?? [], ...rules.seams?.[inside] ?? []];
2849
+ const allowsExternal = (source) => matchesPackage(source, ownPackages);
2850
+ const allowsInternal = (target) => {
2851
+ if (matchesPath(target, ownImports)) return true;
2852
+ return folder !== void 0 && target.startsWith(`${layer}/${folder}/`);
2785
2853
  };
2786
2854
  return { ...importSourceVisitor(({ node, source }) => {
2787
2855
  if (source.startsWith("node:")) return;
2788
2856
  let messageId;
2789
2857
  if (source.startsWith(".")) {
2790
2858
  const target = pathInsideSrc((0, node_path.resolve)((0, node_path.dirname)(file), source));
2791
- messageId = target === void 0 ? "crossLayer" : checkInternal(target);
2792
- } else messageId = checkExternal(source);
2859
+ messageId = target === void 0 || !allowsInternal(target) ? "crossLayer" : null;
2860
+ } else messageId = allowsExternal(source) ? null : "foreignDependency";
2793
2861
  if (messageId !== null) context.report({
2794
2862
  data: {
2795
2863
  layer,
@@ -2801,13 +2869,47 @@ const i1LayerBoundaries = {
2801
2869
  }) };
2802
2870
  },
2803
2871
  meta: {
2872
+ defaultOptions: [{ layers: {} }],
2804
2873
  docs: RULE_DOCS["i1-layer-boundaries"],
2805
2874
  messages: {
2806
- coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (I1 — see docs/10-linting.md).",
2807
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (I1 — see docs/10-linting.md).",
2808
- foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
2809
- lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (I1 — see docs/10-linting.md)."
2875
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/10-linting.md).",
2876
+ foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/10-linting.md)."
2810
2877
  },
2878
+ schema: [{
2879
+ additionalProperties: false,
2880
+ properties: { layers: {
2881
+ additionalProperties: {
2882
+ additionalProperties: false,
2883
+ properties: {
2884
+ folders: {
2885
+ additionalProperties: {
2886
+ items: { type: "string" },
2887
+ type: "array"
2888
+ },
2889
+ type: "object"
2890
+ },
2891
+ imports: {
2892
+ items: { type: "string" },
2893
+ type: "array"
2894
+ },
2895
+ packages: {
2896
+ items: { type: "string" },
2897
+ type: "array"
2898
+ },
2899
+ seams: {
2900
+ additionalProperties: {
2901
+ items: { type: "string" },
2902
+ type: "array"
2903
+ },
2904
+ type: "object"
2905
+ }
2906
+ },
2907
+ type: "object"
2908
+ },
2909
+ type: "object"
2910
+ } },
2911
+ type: "object"
2912
+ }],
2811
2913
  type: "problem"
2812
2914
  }
2813
2915
  };
@@ -2869,10 +2971,48 @@ const i2SiblingTestNaming = {
2869
2971
  //#endregion
2870
2972
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
2871
2973
  const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
2872
- /** Module-ish sources a src test may import; anything else with an extension is a data asset. */
2873
- const CODE_IMPORT = /\.[cm]?[jt]sx?$/;
2874
- /** Does this import path even carry an extension? Bare/extension-less ones are code. */
2875
- const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2974
+ const DATA_ASSET = new RegExp(`\\.(?:${[
2975
+ "avif",
2976
+ "bin",
2977
+ "bmp",
2978
+ "csv",
2979
+ "gif",
2980
+ "graphql",
2981
+ "gql",
2982
+ "gz",
2983
+ "htm",
2984
+ "html",
2985
+ "ico",
2986
+ "ini",
2987
+ "jpeg",
2988
+ "jpg",
2989
+ "json",
2990
+ "json5",
2991
+ "jsonc",
2992
+ "jsonl",
2993
+ "md",
2994
+ "mdx",
2995
+ "ndjson",
2996
+ "pdf",
2997
+ "png",
2998
+ "proto",
2999
+ "sql",
3000
+ "svg",
3001
+ "tar",
3002
+ "toml",
3003
+ "tsv",
3004
+ "txt",
3005
+ "wasm",
3006
+ "webp",
3007
+ "xml",
3008
+ "yaml",
3009
+ "yml",
3010
+ "zip"
3011
+ ].join("|")})$`, "i");
3012
+ /** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
3013
+ function withoutSuffix(source) {
3014
+ return source.replace(/[?#].*$/, "");
3015
+ }
2876
3016
  /**
2877
3017
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
2878
3018
  * `mockOf`/`mockOfDate` inline, large payloads in a `*.fixtures.ts` neighbour.
@@ -2880,8 +3020,11 @@ const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2880
3020
  *
2881
3021
  * - `vi.mock(…)` calls (module mocking) in any file;
2882
3022
  * - files living in a `__mocks__/` or `__fixtures__/` directory;
2883
- * - a `*.test.ts` importing a non-code asset (`.json`, `.txt`, `.sql`, …) —
3023
+ * - a `*.test.ts` importing a known data asset (`.json`, `.txt`, `.sql`, …) —
2884
3024
  * a test needing a real file is a specification and belongs in `specs/`.
3025
+ *
3026
+ * A specifier whose extension is not on the data list is CODE, dotted or not:
3027
+ * `<subject>.<role>` module names are a naming convention, not a file type.
2885
3028
  */
2886
3029
  const i4NoViMockInSrc = {
2887
3030
  create(context) {
@@ -2909,7 +3052,7 @@ const i4NoViMockInSrc = {
2909
3052
  }
2910
3053
  };
2911
3054
  if (isTest) Object.assign(visitor, importSourceVisitor(({ node, source }) => {
2912
- if (HAS_EXTENSION.test(source) && !CODE_IMPORT.test(source)) context.report({
3055
+ if (DATA_ASSET.test(withoutSuffix(source))) context.report({
2913
3056
  data: { source },
2914
3057
  messageId: "assetImport",
2915
3058
  node
@@ -2980,7 +3123,7 @@ const j1NoOnlySkip = {
2980
3123
  */
2981
3124
  const j2NoSleepInSpecs = {
2982
3125
  create(context) {
2983
- if (!segments(context.filename).includes("specs")) return {};
3126
+ if (!isUnderSpecs(context.filename)) return {};
2984
3127
  return {
2985
3128
  CallExpression(node) {
2986
3129
  const callee = node.callee;