@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.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,7 +336,7 @@ 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."
@@ -393,10 +483,10 @@ const RULE_DOCS = {
393
483
  },
394
484
  "i1-layer-boundaries": {
395
485
  channel: "statique",
396
- 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.",
397
487
  family: "I",
398
488
  id: "I1",
399
- 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."
400
490
  },
401
491
  "i2-sibling-test-naming": {
402
492
  channel: "statique",
@@ -407,7 +497,7 @@ const RULE_DOCS = {
407
497
  },
408
498
  "i4-no-vi-mock-in-src": {
409
499
  channel: "statique",
410
- 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.",
411
501
  family: "I",
412
502
  id: "I4",
413
503
  rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
@@ -986,79 +1076,43 @@ const a6wRedundantComposeService = {
986
1076
  }
987
1077
  };
988
1078
  //#endregion
989
- //#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"];
990
1082
  /**
991
- * 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.
992
1085
  *
993
- * Rules only ever look at directories NEXT TO files oxlint is already
994
- * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
995
- * file), so the probe set is small — but several rules probe the same feature
996
- * directory, and oxlint lints many files per process. A module-level cache
997
- * 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).
998
1096
  */
999
- const dirListings = /* @__PURE__ */ new Map();
1000
- function listDirectory(dir) {
1001
- const cached = dirListings.get(dir);
1002
- if (cached !== void 0) return cached;
1003
- let result;
1004
- try {
1005
- result = readdirSync(dir);
1006
- } catch {
1007
- result = null;
1008
- }
1009
- dirListings.set(dir, result);
1010
- return result;
1011
- }
1012
- const kinds = /* @__PURE__ */ new Map();
1013
- function kindOf(path) {
1014
- const cached = kinds.get(path);
1015
- if (cached !== void 0) return cached;
1016
- let result;
1017
- try {
1018
- result = statSync(path).isDirectory() ? "dir" : "file";
1019
- } catch {
1020
- result = "missing";
1021
- }
1022
- kinds.set(path, result);
1023
- return result;
1024
- }
1025
- function isDirectory(path) {
1026
- return kindOf(path) === "dir";
1027
- }
1028
- function isFile(path) {
1029
- return kindOf(path) === "file";
1030
- }
1031
- const fileContents = /* @__PURE__ */ new Map();
1032
- /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
1033
- function readFileCached(path) {
1034
- const cached = fileContents.get(path);
1035
- if (cached !== void 0) return cached;
1036
- let result;
1037
- try {
1038
- result = readFileSync(path, "utf8");
1039
- } catch {
1040
- 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;
1041
1104
  }
1042
- fileContents.set(path, result);
1043
- return result;
1044
1105
  }
1045
1106
  //#endregion
1046
1107
  //#region src/lint/rules/a9w-redundant-root.ts
1047
1108
  /**
1048
- * The root the convention would derive (A9 — see docs/10-linting.md): walk up from the
1049
- * specification file to the first directory containing
1050
- * `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.
1051
1113
  */
1052
1114
  function derivedRoot(startDir) {
1053
- for (const probe of ["docker/compose.test.yaml", "package.json"]) {
1054
- let dir = startDir;
1055
- for (;;) {
1056
- if (isFile(join(dir, probe))) return dir;
1057
- const parent = dirname(dir);
1058
- if (parent === dir) break;
1059
- dir = parent;
1060
- }
1061
- }
1115
+ return findRoot(startDir, isFile);
1062
1116
  }
1063
1117
  /**
1064
1118
  * CONVENTIONS A9 (warning) — `root` is an override reserved for cases the
@@ -1390,8 +1444,16 @@ const b9wProductCommand = {
1390
1444
  //#region src/lint/rules/c1-domain-structure.ts
1391
1445
  const TEST_SUFFIX = ".test.ts";
1392
1446
  const SPECIFICATION_SUFFIX = ".specification.ts";
1447
+ const DEFAULT_DEPTH = "facet-domain";
1393
1448
  /**
1394
- * 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)
1395
1457
  *
1396
1458
  * A facet (`specs/<facet>/` — api, jobs, cli, integrations, lint, …) is the
1397
1459
  * master folder. It carries its runner(s) at its ROOT
@@ -1399,36 +1461,66 @@ const SPECIFICATION_SUFFIX = ".specification.ts";
1399
1461
  * product command/area with one or more `<aspect>.test.ts` files plus their
1400
1462
  * shared asset dirs.
1401
1463
  *
1402
- * Two placement invariants, checked from the filename:
1403
- * - a `*.test.ts` must sit at facet/domain depth — exactly
1464
+ * - a `*.test.ts` sits at facet/domain depth — exactly
1404
1465
  * `specs/<facet>/<domain>/<file>.test.ts`. A test directly at the facet root
1405
1466
  * (or nested deeper than a domain) is rejected.
1406
- * - a `*.specification.ts` must sit at the facet root — exactly
1467
+ * - a `*.specification.ts` sits at the facet root — exactly
1407
1468
  * `specs/<facet>/<file>.specification.ts`, never inside a domain.
1408
1469
  *
1409
- * 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.
1410
1484
  */
1411
1485
  const c1DomainStructure = {
1412
1486
  create(context) {
1487
+ const depth = context.options[0]?.depth ?? DEFAULT_DEPTH;
1488
+ if (depth === "off") return {};
1413
1489
  return { Program(node) {
1414
- const parts = segments(context.filename);
1415
- const base = parts.at(-1) ?? "";
1416
- const specsIndex = parts.lastIndexOf("specs");
1417
- if (specsIndex === -1) return;
1418
- 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
+ }
1419
1511
  if (base.endsWith(TEST_SUFFIX)) {
1420
- if (depth < 2) context.report({
1512
+ if (nesting < 2) context.report({
1421
1513
  messageId: "testAtFacetRoot",
1422
1514
  node
1423
1515
  });
1424
- else if (depth > 2) context.report({
1516
+ else if (nesting > 2) context.report({
1425
1517
  messageId: "testTooDeep",
1426
1518
  node
1427
1519
  });
1428
1520
  return;
1429
1521
  }
1430
1522
  if (base.endsWith(SPECIFICATION_SUFFIX)) {
1431
- if (depth !== 1) context.report({
1523
+ if (nesting !== 1) context.report({
1432
1524
  messageId: "specNotAtFacetRoot",
1433
1525
  node
1434
1526
  });
@@ -1436,12 +1528,27 @@ const c1DomainStructure = {
1436
1528
  } };
1437
1529
  },
1438
1530
  meta: {
1531
+ defaultOptions: [{ depth: DEFAULT_DEPTH }],
1439
1532
  docs: RULE_DOCS["c1-domain-structure"],
1440
1533
  messages: {
1441
1534
  specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1442
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).",
1443
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)."
1444
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
+ }],
1445
1552
  type: "problem"
1446
1553
  }
1447
1554
  };
@@ -1891,22 +1998,9 @@ const VERB_ROOTS = {
1891
1998
  function looksLikePath(value) {
1892
1999
  return value.length > 0 && !/\s/u.test(value);
1893
2000
  }
1894
- /** Nearest ancestor directory named `specs` — the pool root for `$FIXTURES/`. */
1895
- function specsRoot(file) {
1896
- let dir = dirname(file);
1897
- for (;;) {
1898
- if (basename(dir) === "specs") return dir;
1899
- const parent = dirname(dir);
1900
- if (parent === dir) return;
1901
- dir = parent;
1902
- }
1903
- }
1904
2001
  /** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
1905
- function resolveFixture(argument, featureDir, file) {
1906
- if (argument.startsWith("$FIXTURES/")) {
1907
- const root = specsRoot(file);
1908
- return root === void 0 ? void 0 : join(root, "fixtures", argument.slice(10));
1909
- }
2002
+ function resolveFixture(argument, featureDir, specsDir) {
2003
+ if (argument.startsWith("$FIXTURES/")) return join(specsDir, "fixtures", argument.slice(10));
1910
2004
  if (argument.startsWith("$")) return;
1911
2005
  return join(featureDir, "fixtures", argument);
1912
2006
  }
@@ -1921,7 +2015,8 @@ function resolveFixture(argument, featureDir, file) {
1921
2015
  const c8ReferencedFixtureExists = {
1922
2016
  create(context) {
1923
2017
  const file = context.physicalFilename;
1924
- 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 {};
1925
2020
  const featureDir = dirname(file);
1926
2021
  return { CallExpression(node) {
1927
2022
  const callee = node.callee;
@@ -1931,7 +2026,7 @@ const c8ReferencedFixtureExists = {
1931
2026
  if (verb === "fixture") {
1932
2027
  const value = stringValue(args[0]);
1933
2028
  if (value === void 0 || !looksLikePath(value)) return;
1934
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, file);
2029
+ const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
1935
2030
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
1936
2031
  data: {
1937
2032
  path: value,
@@ -2544,7 +2639,7 @@ const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2544
2639
  const f2NoTestImportsInProd = {
2545
2640
  create(context) {
2546
2641
  const file = context.filename;
2547
- 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 {};
2548
2643
  return { ...importSourceVisitor(({ node, source }) => {
2549
2644
  if (source === TOOL_SUBPATH$1) return;
2550
2645
  if (source === "vitest" || source.startsWith("vitest/")) context.report({
@@ -2602,10 +2697,9 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
2602
2697
  */
2603
2698
  const f3SpecsPublicEntry = {
2604
2699
  create(context) {
2605
- const parts = segments(context.filename);
2606
- const specsIndex = parts.indexOf("specs");
2607
- if (specsIndex === -1) return {};
2608
- 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";
2609
2703
  return { ...importSourceVisitor(({ node, source }) => {
2610
2704
  if (source.startsWith(`${PACKAGE}/`)) {
2611
2705
  if (source === TOOL_SUBPATH) return;
@@ -2688,36 +2782,6 @@ const f5FixturesOnlyFromTests = {
2688
2782
  };
2689
2783
  //#endregion
2690
2784
  //#region src/lint/rules/i1-layer-boundaries.ts
2691
- /**
2692
- * One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
2693
- * `src/integrations/<folder>/` may import.
2694
- */
2695
- const INTEGRATION_DEPS = {
2696
- anthropic: ["@anthropic-ai/sdk"],
2697
- appium: ["webdriverio"],
2698
- compose: ["yaml"],
2699
- docker: [],
2700
- hono: ["hono", "@hono/node-server"],
2701
- msw: ["msw"],
2702
- openai: ["openai"],
2703
- playwright: ["playwright"],
2704
- postgres: ["pg"],
2705
- redis: ["redis"],
2706
- sqlite: ["better-sqlite3"],
2707
- testcontainers: ["testcontainers"]
2708
- };
2709
- /** External packages the vitest layer (ALL runner coupling) may import. */
2710
- const VITEST_DEPS = [
2711
- "vitest",
2712
- "vitest-mock-extended",
2713
- "mockdate"
2714
- ];
2715
- /** The pure core helpers the tool-facing lint layer may reach (I1). */
2716
- const LINT_CORE_WHITELIST = /* @__PURE__ */ new Set([
2717
- "core/matching/match",
2718
- "core/specification/shared/binding",
2719
- "core/specification/shared/fixtures"
2720
- ]);
2721
2785
  const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
2722
2786
  function matchesPackage(source, packages) {
2723
2787
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
@@ -2728,64 +2792,68 @@ function pathInsideSrc(path) {
2728
2792
  const srcIndex = parts.lastIndexOf("src");
2729
2793
  return srcIndex === -1 ? void 0 : parts.slice(srcIndex + 1).join("/");
2730
2794
  }
2731
- /** Strip a `.js`/`.ts`-style extension for whitelist comparison. */
2795
+ /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
2732
2796
  function withoutExtension(path) {
2733
2797
  return path.replace(/\.[cm]?[jt]sx?$/, "");
2734
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
+ }
2735
2803
  /**
2736
- * 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
+ * }]
2813
+ *
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.
2737
2821
  *
2738
- * - `core/` — zero external imports; may reach `integrations/docker`,
2739
- * `integrations/hono`, `vitest/matchers`, and (from `builder.ts` only, the
2740
- * lazy MSW seam) `integrations/msw`.
2741
- * - `integrations/<dep>/` — its own external dependency and `core/` only.
2742
- * - `vitest/` — the runner coupling: `vitest`, `vitest-mock-extended`,
2743
- * `mockdate`, plus `core/` and `integrations/docker` (the matchers recognise
2744
- * the zero-dependency ContainerAccessor subject).
2745
- * - `lint/` — zero runtime imports: no external packages, and from `core/`
2746
- * only the pure helpers (token list, case conversions, fixture markers).
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.
2747
2827
  *
2748
- * Module tests and fixtures files are exempt (their imports are governed by
2749
- * F2/I4); `src/index.ts` is the composition root and lives above the layers.
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/`.
2750
2831
  */
2751
2832
  const i1LayerBoundaries = {
2752
2833
  create(context) {
2834
+ const layers = context.options[0]?.layers;
2835
+ if (layers === void 0 || Object.keys(layers).length === 0) return {};
2753
2836
  const file = context.physicalFilename;
2754
2837
  if (TEST_OR_FIXTURES.test(file)) return {};
2755
2838
  const inside = pathInsideSrc(file);
2756
2839
  const layer = inside?.split("/")[0];
2757
- if (inside === void 0 || layer === void 0 || ![
2758
- "core",
2759
- "integrations",
2760
- "lint",
2761
- "vitest"
2762
- ].includes(layer)) return {};
2763
- const integrationFolder = layer === "integrations" ? inside.split("/")[1] : void 0;
2764
- const checkExternal = (source) => {
2765
- if (layer === "core") return "coreExternal";
2766
- if (layer === "lint") return "lintRuntime";
2767
- if (layer === "vitest") return matchesPackage(source, VITEST_DEPS) ? null : "foreignDependency";
2768
- return matchesPackage(source, INTEGRATION_DEPS[integrationFolder ?? ""] ?? []) ? null : "foreignDependency";
2769
- };
2770
- const checkInternal = (target) => {
2771
- if (layer === "core") {
2772
- if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2773
- if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2774
- if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2775
- if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
2776
- return "crossLayer";
2777
- }
2778
- if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
2779
- if (layer === "vitest") return target.startsWith("core/") || target.startsWith("vitest/") || target.startsWith("integrations/docker/") ? null : "crossLayer";
2780
- 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}/`);
2781
2849
  };
2782
2850
  return { ...importSourceVisitor(({ node, source }) => {
2783
2851
  if (source.startsWith("node:")) return;
2784
2852
  let messageId;
2785
2853
  if (source.startsWith(".")) {
2786
2854
  const target = pathInsideSrc(resolve(dirname(file), source));
2787
- messageId = target === void 0 ? "crossLayer" : checkInternal(target);
2788
- } else messageId = checkExternal(source);
2855
+ messageId = target === void 0 || !allowsInternal(target) ? "crossLayer" : null;
2856
+ } else messageId = allowsExternal(source) ? null : "foreignDependency";
2789
2857
  if (messageId !== null) context.report({
2790
2858
  data: {
2791
2859
  layer,
@@ -2797,13 +2865,47 @@ const i1LayerBoundaries = {
2797
2865
  }) };
2798
2866
  },
2799
2867
  meta: {
2868
+ defaultOptions: [{ layers: {} }],
2800
2869
  docs: RULE_DOCS["i1-layer-boundaries"],
2801
2870
  messages: {
2802
- coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (I1 — see docs/10-linting.md).",
2803
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (I1 — see docs/10-linting.md).",
2804
- foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
2805
- 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)."
2806
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
+ }],
2807
2909
  type: "problem"
2808
2910
  }
2809
2911
  };
@@ -2865,10 +2967,48 @@ const i2SiblingTestNaming = {
2865
2967
  //#endregion
2866
2968
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
2867
2969
  const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
2868
- /** Module-ish sources a src test may import; anything else with an extension is a data asset. */
2869
- const CODE_IMPORT = /\.[cm]?[jt]sx?$/;
2870
- /** Does this import path even carry an extension? Bare/extension-less ones are code. */
2871
- 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
+ }
2872
3012
  /**
2873
3013
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
2874
3014
  * `mockOf`/`mockOfDate` inline, large payloads in a `*.fixtures.ts` neighbour.
@@ -2876,8 +3016,11 @@ const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2876
3016
  *
2877
3017
  * - `vi.mock(…)` calls (module mocking) in any file;
2878
3018
  * - files living in a `__mocks__/` or `__fixtures__/` directory;
2879
- * - a `*.test.ts` importing a non-code asset (`.json`, `.txt`, `.sql`, …) —
3019
+ * - a `*.test.ts` importing a known data asset (`.json`, `.txt`, `.sql`, …) —
2880
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.
2881
3024
  */
2882
3025
  const i4NoViMockInSrc = {
2883
3026
  create(context) {
@@ -2905,7 +3048,7 @@ const i4NoViMockInSrc = {
2905
3048
  }
2906
3049
  };
2907
3050
  if (isTest) Object.assign(visitor, importSourceVisitor(({ node, source }) => {
2908
- if (HAS_EXTENSION.test(source) && !CODE_IMPORT.test(source)) context.report({
3051
+ if (DATA_ASSET.test(withoutSuffix(source))) context.report({
2909
3052
  data: { source },
2910
3053
  messageId: "assetImport",
2911
3054
  node
@@ -2976,7 +3119,7 @@ const j1NoOnlySkip = {
2976
3119
  */
2977
3120
  const j2NoSleepInSpecs = {
2978
3121
  create(context) {
2979
- if (!segments(context.filename).includes("specs")) return {};
3122
+ if (!isUnderSpecs(context.filename)) return {};
2980
3123
  return {
2981
3124
  CallExpression(node) {
2982
3125
  const callee = node.callee;