@jterrazz/test 15.0.0 → 15.1.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.cjs CHANGED
@@ -4,7 +4,6 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  let node_path = require("node:path");
6
6
  let node_fs = require("node:fs");
7
- let node_url = require("node:url");
8
7
  //#region src/lint/fs-cache.ts
9
8
  /**
10
9
  * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
@@ -70,7 +69,7 @@ function readFileCached(path) {
70
69
  */
71
70
  /** Split a path into its non-empty segments (posix or win separators). */
72
71
  function segments(path) {
73
- return path.split(/[/\\]/).filter(Boolean);
72
+ return path.split(/[/\\]/u).filter(Boolean);
74
73
  }
75
74
  /**
76
75
  * The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
@@ -112,7 +111,8 @@ function stringValue(node) {
112
111
  if (node.type === "TemplateLiteral") {
113
112
  const expressions = node.expressions;
114
113
  const quasis = node.quasis;
115
- if (expressions?.length === 0 && quasis?.length === 1) return quasis[0].value?.cooked;
114
+ const onlyQuasi = quasis?.[0];
115
+ if (expressions?.length === 0 && quasis?.length === 1 && onlyQuasi !== void 0) return onlyQuasi.value?.cooked;
116
116
  }
117
117
  }
118
118
  /** The property name of a non-computed member expression, if identifiable. */
@@ -483,7 +483,7 @@ const RULE_DOCS = {
483
483
  },
484
484
  "f3-specs-public-entry": {
485
485
  channel: "statique",
486
- convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{core,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` autre que `@jterrazz/test/oxlint`. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté) ; seul `specs/integrations/` peut importer en profondeur `src/integrations/**`.",
486
+ convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` autre que `@jterrazz/test/oxlint`. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté) ; seul `specs/integrations/` peut importer en profondeur `src/integrations/**`.",
487
487
  family: "F",
488
488
  id: "F3",
489
489
  rationale: "Tester par la surface publique garde les specs découplées des chemins internes du framework, sans gêner le consommateur qui importe sa propre app."
@@ -893,83 +893,6 @@ const a1SpecificationFile = {
893
893
  }
894
894
  };
895
895
  //#endregion
896
- //#region src/core/specification/shared/binding.ts
897
- /**
898
- * Compose-binding resolution and the case conversions it shares with the
899
- * automatic env injection (CONVENTIONS A6 / B6).
900
- *
901
- * A services-record key is written in the natural TypeScript style
902
- * (`analyticsDb`), while a compose service name is kebab-case
903
- * (`analytics-db`). These helpers bridge the two vocabularies deterministically
904
- * so the record key stays the single source of truth and `composeService`
905
- * remains a rare escape hatch.
906
- */
907
- /**
908
- * Convert a record key to its kebab-case form: camelCase boundaries become
909
- * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
910
- */
911
- function toKebabCase(key) {
912
- return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
913
- }
914
- //#endregion
915
- //#region src/lint/rules/a10-duplicate-binding.ts
916
- /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
917
- function canonicalBinding(entry, key) {
918
- const factory = entry.value;
919
- if (factory?.type === "CallExpression") {
920
- const options = factory.arguments?.[0];
921
- if (options?.type === "ObjectExpression") {
922
- const explicit = stringValue(findProperty(options, "composeService")?.value);
923
- if (explicit !== void 0) return explicit;
924
- }
925
- }
926
- return toKebabCase(key);
927
- }
928
- /**
929
- * CONVENTIONS A10 — within one `services` record, two keys that derive (or
930
- * pin via `composeService`) the SAME compose service name are a silent
931
- * collision: the record is a map, and the second binding would shadow the
932
- * first. Detected on the same kebab derivation the runtime binding uses
933
- * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
934
- * `composeService` values.
935
- */
936
- const a10DuplicateBinding = {
937
- create(context) {
938
- return { CallExpression(node) {
939
- if (specificationMember(node) === void 0) return;
940
- for (const argument of node.arguments ?? []) {
941
- const record = findProperty(argument, "services")?.value;
942
- if (record?.type !== "ObjectExpression") continue;
943
- const seen = /* @__PURE__ */ new Map();
944
- for (const entry of record.properties ?? []) {
945
- const key = propertyKeyName(entry);
946
- if (key === void 0) continue;
947
- const binding = canonicalBinding(entry, key);
948
- const first = seen.get(binding);
949
- if (first !== void 0) {
950
- context.report({
951
- data: {
952
- binding,
953
- first,
954
- key
955
- },
956
- messageId: "duplicate",
957
- node: entry
958
- });
959
- continue;
960
- }
961
- seen.set(binding, key);
962
- }
963
- }
964
- } };
965
- },
966
- meta: {
967
- docs: RULE_DOCS["a10-duplicate-binding"],
968
- messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (A10 — see docs/13-linting.md)." },
969
- type: "problem"
970
- }
971
- };
972
- //#endregion
973
896
  //#region src/lint/rules/a2-known-constructors.ts
974
897
  /** The five constructors, and only five (A2 — see docs/13-linting.md). */
975
898
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
@@ -1132,6 +1055,25 @@ const a5ModeWithServer = {
1132
1055
  }
1133
1056
  };
1134
1057
  //#endregion
1058
+ //#region src/specification/facets/_common/binding.ts
1059
+ /**
1060
+ * Compose-binding resolution and the case conversions it shares with the
1061
+ * automatic env injection (CONVENTIONS A6 / B6).
1062
+ *
1063
+ * A services-record key is written in the natural TypeScript style
1064
+ * (`analyticsDb`), while a compose service name is kebab-case
1065
+ * (`analytics-db`). These helpers bridge the two vocabularies deterministically
1066
+ * so the record key stays the single source of truth and `composeService`
1067
+ * remains a rare escape hatch.
1068
+ */
1069
+ /**
1070
+ * Convert a record key to its kebab-case form: camelCase boundaries become
1071
+ * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
1072
+ */
1073
+ function toKebabCase(key) {
1074
+ return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
1075
+ }
1076
+ //#endregion
1135
1077
  //#region src/lint/rules/a6w-redundant-compose-service.ts
1136
1078
  /**
1137
1079
  * CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
@@ -1173,7 +1115,7 @@ const a6wRedundantComposeService = {
1173
1115
  }
1174
1116
  };
1175
1117
  //#endregion
1176
- //#region src/core/specification/shared/resolve.ts
1118
+ //#region src/specification/facets/_common/resolve.ts
1177
1119
  /** What makes a directory a project root — a manifest, or a test stack. */
1178
1120
  const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
1179
1121
  /**
@@ -1240,7 +1182,65 @@ const a9wRedundantRoot = {
1240
1182
  }
1241
1183
  };
1242
1184
  //#endregion
1243
- //#region src/core/specification/shared/ground.ts
1185
+ //#region src/lint/rules/a10-duplicate-binding.ts
1186
+ /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
1187
+ function canonicalBinding(entry, key) {
1188
+ const factory = entry.value;
1189
+ if (factory?.type === "CallExpression") {
1190
+ const options = factory.arguments?.[0];
1191
+ if (options?.type === "ObjectExpression") {
1192
+ const explicit = stringValue(findProperty(options, "composeService")?.value);
1193
+ if (explicit !== void 0) return explicit;
1194
+ }
1195
+ }
1196
+ return toKebabCase(key);
1197
+ }
1198
+ /**
1199
+ * CONVENTIONS A10 — within one `services` record, two keys that derive (or
1200
+ * pin via `composeService`) the SAME compose service name are a silent
1201
+ * collision: the record is a map, and the second binding would shadow the
1202
+ * first. Detected on the same kebab derivation the runtime binding uses
1203
+ * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
1204
+ * `composeService` values.
1205
+ */
1206
+ const a10DuplicateBinding = {
1207
+ create(context) {
1208
+ return { CallExpression(node) {
1209
+ if (specificationMember(node) === void 0) return;
1210
+ for (const argument of node.arguments ?? []) {
1211
+ const record = findProperty(argument, "services")?.value;
1212
+ if (record?.type !== "ObjectExpression") continue;
1213
+ const seen = /* @__PURE__ */ new Map();
1214
+ for (const entry of record.properties ?? []) {
1215
+ const key = propertyKeyName(entry);
1216
+ if (key === void 0) continue;
1217
+ const binding = canonicalBinding(entry, key);
1218
+ const first = seen.get(binding);
1219
+ if (first !== void 0) {
1220
+ context.report({
1221
+ data: {
1222
+ binding,
1223
+ first,
1224
+ key
1225
+ },
1226
+ messageId: "duplicate",
1227
+ node: entry
1228
+ });
1229
+ continue;
1230
+ }
1231
+ seen.set(binding, key);
1232
+ }
1233
+ }
1234
+ } };
1235
+ },
1236
+ meta: {
1237
+ docs: RULE_DOCS["a10-duplicate-binding"],
1238
+ messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (A10 — see docs/13-linting.md)." },
1239
+ type: "problem"
1240
+ }
1241
+ };
1242
+ //#endregion
1243
+ //#region src/specification/facets/_common/ground.ts
1244
1244
  /**
1245
1245
  * The GROUND a row of specs stands on — the naming law of a spec tree.
1246
1246
  *
@@ -1281,7 +1281,7 @@ const RENAMED_GROUND_DIRS = {
1281
1281
  seeds: GROUND_SEEDS
1282
1282
  };
1283
1283
  //#endregion
1284
- //#region src/core/specification/shared/fixtures.ts
1284
+ //#region src/specification/facets/_common/fixtures.ts
1285
1285
  /**
1286
1286
  * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
1287
1287
  *
@@ -1328,7 +1328,7 @@ const b2KnownFixtureMarker = {
1328
1328
  const args = node.arguments ?? [];
1329
1329
  const path = leadingText(args[0]);
1330
1330
  if (path === void 0 || !path.startsWith("$")) return;
1331
- const marker = path.replace(/\/+$/, "").split("/")[0];
1331
+ const marker = path.replace(/\/+$/u, "").split("/")[0] ?? "";
1332
1332
  if (!KNOWN.has(marker)) context.report({
1333
1333
  data: {
1334
1334
  known: [...KNOWN_FIXTURE_MARKERS].join(", "),
@@ -1462,7 +1462,7 @@ const b5AwaitUsing = {
1462
1462
  };
1463
1463
  //#endregion
1464
1464
  //#region src/lint/rules/b6w-redundant-env-url.ts
1465
- const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/;
1465
+ const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/u;
1466
1466
  /**
1467
1467
  * CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
1468
1468
  * already injects `<KEY>_URL` for every service (plus the unambiguous
@@ -1498,7 +1498,7 @@ const b6wRedundantEnvUrl = {
1498
1498
  //#endregion
1499
1499
  //#region src/lint/rules/b8-kebab-trigger.ts
1500
1500
  /** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
1501
- const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1501
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
1502
1502
  /**
1503
1503
  * CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
1504
1504
  * identifier; it is a contract between the app and its tests. Only string
@@ -1754,212 +1754,71 @@ const c1DomainStructure = {
1754
1754
  }
1755
1755
  };
1756
1756
  //#endregion
1757
- //#region src/lint/rules/c10-contracts-boundary.ts
1758
- /** A specifier reaching into a provider folder — the internal half of contracts/. */
1759
- const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1757
+ //#region src/lint/rules/c2-http-only-requests.ts
1758
+ const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/u;
1760
1759
  /**
1761
- * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1762
- * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1763
- * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1764
- * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1765
- * importing a unit is an error — a test routes through the facade, so a
1766
- * scenario is named once, next to the world it derives from.
1760
+ * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1761
+ * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1762
+ * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1763
+ * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1764
+ * is reported on the test file.
1767
1765
  */
1768
- const c10ContractsBoundary = {
1766
+ const c2HttpOnlyRequests = {
1769
1767
  create(context) {
1770
- const parts = segments(context.physicalFilename);
1771
- if (!parts.includes("specs")) return {};
1772
- if (parts.slice(0, -1).includes("contracts")) return {};
1773
- return importSourceVisitor(({ node, source }) => {
1774
- if (INTERNAL_UNIT.test(source)) context.report({
1775
- data: { source },
1776
- messageId: "internal",
1768
+ const file = context.physicalFilename;
1769
+ if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1770
+ return { Program(node) {
1771
+ const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_REQUESTS);
1772
+ for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1773
+ data: { entry },
1774
+ messageId: "notHttp",
1777
1775
  node
1778
1776
  });
1779
- });
1777
+ } };
1780
1778
  },
1781
1779
  meta: {
1782
- docs: RULE_DOCS["c10-contracts-boundary"],
1783
- 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/13-linting.md)." },
1780
+ docs: RULE_DOCS["c2-http-only-requests"],
1781
+ messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/13-linting.md)." },
1784
1782
  type: "problem"
1785
1783
  }
1786
1784
  };
1787
1785
  //#endregion
1788
- //#region src/lint/rules/c11-contract-data-pairing.ts
1789
- const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/;
1790
- /** The provider folders that may carry data files. */
1791
- const PROVIDERS$1 = [
1786
+ //#region src/lint/rules/c4-contract-shape.ts
1787
+ /** The only directories a `contracts/` root may hold — the provider carriers. */
1788
+ const PROVIDERS$1 = /* @__PURE__ */ new Set([
1792
1789
  "anthropic",
1793
1790
  "http",
1794
1791
  "openai"
1795
- ];
1796
- /** Data files: served payloads and matched inputs, both owned by a contract. */
1797
- const DATA_SUFFIXES = [".request.ts", ".response.json"];
1798
- /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1799
- function ownerOf(entry) {
1800
- return `${entry.slice(0, entry.indexOf("."))}.ts`;
1792
+ ]);
1793
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/u;
1794
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1795
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/u;
1796
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1797
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1798
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1799
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1800
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1801
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1802
+ /** The name a specifier/declaration exports under, when it is `default`. */
1803
+ function exportsDefault(node) {
1804
+ return (node.specifiers ?? []).some((specifier) => {
1805
+ const exported = specifier.exported;
1806
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1807
+ });
1808
+ }
1809
+ /** Is this node a `defineContract(...)` call? */
1810
+ function isDefineContract(node) {
1811
+ const callee = node.callee;
1812
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1813
+ }
1814
+ /** A default export that may legitimately produce a contract (value or factory). */
1815
+ function producesContract(declaration) {
1816
+ if (declaration === void 0) return false;
1817
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1801
1818
  }
1802
1819
  /**
1803
- * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1804
- * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1805
- * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1806
- * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1807
- * file with no owner is dead weight no test can reach — reported on the feature's
1808
- * test file, since oxlint never visits a `.json`.
1809
- */
1810
- const c11ContractDataPairing = {
1811
- create(context) {
1812
- const file = context.physicalFilename;
1813
- if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1814
- return { Program(node) {
1815
- const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
1816
- for (const provider of PROVIDERS$1) {
1817
- const entries = listDirectory((0, node_path.join)(contractsDir, provider));
1818
- if (entries === null) continue;
1819
- const present = new Set(entries);
1820
- for (const entry of entries) {
1821
- if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1822
- const owner = ownerOf(entry);
1823
- if (!present.has(owner)) context.report({
1824
- data: {
1825
- entry,
1826
- owner,
1827
- provider
1828
- },
1829
- messageId: "orphan",
1830
- node
1831
- });
1832
- }
1833
- }
1834
- } };
1835
- },
1836
- meta: {
1837
- docs: RULE_DOCS["c11-contract-data-pairing"],
1838
- 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/13-linting.md)." },
1839
- type: "problem"
1840
- }
1841
- };
1842
- //#endregion
1843
- //#region src/lint/rules/c13-underscored-ground.ts
1844
- const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1845
- /**
1846
- * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
1847
- * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
1848
- * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
1849
- * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
1850
- * fallback.
1851
- *
1852
- * The catch this rule exists for: a consumer that upgrades and keeps the old
1853
- * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
1854
- * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
1855
- * and the tree becomes invisible rather than wrong. The failure that follows is
1856
- * "fixture does not exist" on every spec at once, one step removed from the
1857
- * cause; this rule states the cause and the rename.
1858
- *
1859
- * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
1860
- * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
1861
- * nearest specs root. A DOMAIN folder named after the ground it holds
1862
- * (`specs/api/requests/_requests/`) is never a probe target — only what sits
1863
- * beside a spec is.
1864
- */
1865
- const c13UnderscoredGround = {
1866
- create(context) {
1867
- const file = context.physicalFilename;
1868
- const anchor = specsAnchor(file);
1869
- if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
1870
- return { Program(node) {
1871
- const seen = /* @__PURE__ */ new Set();
1872
- const probe = (parent, legacy) => {
1873
- const path = (0, node_path.join)(parent, legacy);
1874
- if (seen.has(path) || !isDirectory(path)) return;
1875
- seen.add(path);
1876
- context.report({
1877
- data: {
1878
- ground: RENAMED_GROUND_DIRS[legacy],
1879
- legacy
1880
- },
1881
- messageId: "notUnderscored",
1882
- node
1883
- });
1884
- };
1885
- for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe((0, node_path.dirname)(file), legacy);
1886
- probe(anchor.directory, "fixtures");
1887
- } };
1888
- },
1889
- meta: {
1890
- docs: RULE_DOCS["c13-underscored-ground"],
1891
- messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/13-linting.md)." },
1892
- type: "problem"
1893
- }
1894
- };
1895
- //#endregion
1896
- //#region src/lint/rules/c2-http-only-requests.ts
1897
- const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1898
- /**
1899
- * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1900
- * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1901
- * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1902
- * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1903
- * is reported on the test file.
1904
- */
1905
- const c2HttpOnlyRequests = {
1906
- create(context) {
1907
- const file = context.physicalFilename;
1908
- if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1909
- return { Program(node) {
1910
- const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_REQUESTS);
1911
- for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1912
- data: { entry },
1913
- messageId: "notHttp",
1914
- node
1915
- });
1916
- } };
1917
- },
1918
- meta: {
1919
- docs: RULE_DOCS["c2-http-only-requests"],
1920
- messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/13-linting.md)." },
1921
- type: "problem"
1922
- }
1923
- };
1924
- //#endregion
1925
- //#region src/lint/rules/c4-contract-shape.ts
1926
- /** The only directories a `contracts/` root may hold — the provider carriers. */
1927
- const PROVIDERS = /* @__PURE__ */ new Set([
1928
- "anthropic",
1929
- "http",
1930
- "openai"
1931
- ]);
1932
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1933
- /** A file oxlint itself visits — the AST half of the rule reports on those. */
1934
- const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1935
- /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1936
- const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1937
- /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1938
- const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1939
- /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1940
- const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1941
- /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1942
- const RESPONSE_DATA_FILE = /\.response\.json$/;
1943
- /** The name a specifier/declaration exports under, when it is `default`. */
1944
- function exportsDefault(node) {
1945
- return (node.specifiers ?? []).some((specifier) => {
1946
- const exported = specifier.exported;
1947
- return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1948
- });
1949
- }
1950
- /** Is this node a `defineContract(...)` call? */
1951
- function isDefineContract(node) {
1952
- const callee = node.callee;
1953
- return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1954
- }
1955
- /** A default export that may legitimately produce a contract (value or factory). */
1956
- function producesContract(declaration) {
1957
- if (declaration === void 0) return false;
1958
- return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1959
- }
1960
- /**
1961
- * The provider a `request:` value implies, when the builder is written as
1962
- * `<provider>.<verb>(…)` — the only statically decidable form.
1820
+ * The provider a `request:` value implies, when the builder is written as
1821
+ * `<provider>.<verb>(…)` — the only statically decidable form.
1963
1822
  */
1964
1823
  function requestProvider(request) {
1965
1824
  if (request?.type !== "CallExpression") return;
@@ -1987,7 +1846,7 @@ function checkLayout(context, node, contractsDir) {
1987
1846
  });
1988
1847
  continue;
1989
1848
  }
1990
- if (!PROVIDERS.has(entry)) {
1849
+ if (!PROVIDERS$1.has(entry)) {
1991
1850
  context.report({
1992
1851
  data: { entry },
1993
1852
  messageId: "badProviderDir",
@@ -2003,7 +1862,7 @@ function checkLayout(context, node, contractsDir) {
2003
1862
  messageId: "providerSubfolder",
2004
1863
  node
2005
1864
  });
2006
- else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1865
+ else if (!/\.[cm]?ts$/u.test(child) && !child.endsWith(".response.json")) context.report({
2007
1866
  data: {
2008
1867
  child,
2009
1868
  provider: entry
@@ -2072,7 +1931,7 @@ function checkUnit(context, program, base, provider) {
2072
1931
  if (isDefineContract(node)) {
2073
1932
  const argument = node.arguments?.[0];
2074
1933
  const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
2075
- if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1934
+ if (declared !== void 0 && PROVIDERS$1.has(declared) && declared !== provider) context.report({
2076
1935
  data: {
2077
1936
  declared,
2078
1937
  provider
@@ -2114,7 +1973,7 @@ const c4ContractShape = {
2114
1973
  if (!parts.includes("specs")) return {};
2115
1974
  const contractsIndex = parts.lastIndexOf("contracts");
2116
1975
  if (contractsIndex === -1) {
2117
- if (!TEST_FILE$9.test(file)) return {};
1976
+ if (!TEST_FILE$11.test(file)) return {};
2118
1977
  return { Program(node) {
2119
1978
  checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
2120
1979
  } };
@@ -2125,8 +1984,8 @@ const c4ContractShape = {
2125
1984
  checkComposite(context, node, base);
2126
1985
  } };
2127
1986
  if (depth === 2) {
2128
- const provider = parts[contractsIndex + 1];
2129
- return PROVIDERS.has(provider) ? { Program(node) {
1987
+ const provider = parts[contractsIndex + 1] ?? "";
1988
+ return PROVIDERS$1.has(provider) ? { Program(node) {
2130
1989
  checkUnit(context, node, base, provider);
2131
1990
  } } : {};
2132
1991
  }
@@ -2159,7 +2018,7 @@ const c4ContractShape = {
2159
2018
  };
2160
2019
  //#endregion
2161
2020
  //#region src/lint/rules/c6-tomatch-extension.ts
2162
- const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/;
2021
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/u;
2163
2022
  /** Does the expect(…) subject chain contain a `.directory(…)` call? */
2164
2023
  function subjectIsDirectory(toMatchCallee) {
2165
2024
  const expectCall = toMatchCallee.object;
@@ -2184,7 +2043,7 @@ function subjectIsDirectory(toMatchCallee) {
2184
2043
  const c6ToMatchExtension = {
2185
2044
  create(context) {
2186
2045
  const file = context.physicalFilename;
2187
- if (!TEST_FILE$8.test(file) || !segments(file).includes("specs")) return {};
2046
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
2188
2047
  return { CallExpression(node) {
2189
2048
  const callee = node.callee;
2190
2049
  if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
@@ -2209,7 +2068,7 @@ const c6ToMatchExtension = {
2209
2068
  };
2210
2069
  //#endregion
2211
2070
  //#region src/lint/rules/c7-seeds-sql-only.ts
2212
- const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2071
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/u;
2213
2072
  /**
2214
2073
  * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
2215
2074
  * nothing but `*.sql` fragments. File state goes through `.fixture()`; there
@@ -2219,7 +2078,7 @@ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2219
2078
  const c7SeedsSqlOnly = {
2220
2079
  create(context) {
2221
2080
  const file = context.physicalFilename;
2222
- if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2081
+ if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
2223
2082
  return { Program(node) {
2224
2083
  const seedsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_SEEDS);
2225
2084
  for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
@@ -2237,7 +2096,7 @@ const c7SeedsSqlOnly = {
2237
2096
  };
2238
2097
  //#endregion
2239
2098
  //#region src/lint/rules/c8-referenced-fixture-exists.ts
2240
- const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/;
2099
+ const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/u;
2241
2100
  /** The ground directory each fixture-referencing verb reads from. */
2242
2101
  const VERB_ROOTS = {
2243
2102
  request: GROUND_REQUESTS,
@@ -2270,7 +2129,7 @@ const c8ReferencedFixtureExists = {
2270
2129
  create(context) {
2271
2130
  const file = context.physicalFilename;
2272
2131
  const anchor = specsAnchor(file);
2273
- if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
2132
+ if (!TEST_FILE$8.test(file) || anchor === void 0) return {};
2274
2133
  const featureDir = (0, node_path.dirname)(file);
2275
2134
  return { CallExpression(node) {
2276
2135
  const callee = node.callee;
@@ -2280,7 +2139,7 @@ const c8ReferencedFixtureExists = {
2280
2139
  if (verb === "fixture") {
2281
2140
  const value = stringValue(args[0]);
2282
2141
  if (value === void 0 || !looksLikePath(value)) return;
2283
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
2142
+ const target = resolveFixture(value.replace(/\/+$/u, ""), featureDir, anchor.directory);
2284
2143
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
2285
2144
  data: {
2286
2145
  path: value,
@@ -2295,7 +2154,7 @@ const c8ReferencedFixtureExists = {
2295
2154
  if (root === void 0) return;
2296
2155
  const value = stringValue(args[0]);
2297
2156
  if (value === void 0 || !looksLikePath(value)) return;
2298
- const target = (0, node_path.join)(featureDir, root, value.replace(/\/+$/, ""));
2157
+ const target = (0, node_path.join)(featureDir, root, value.replace(/\/+$/u, ""));
2299
2158
  if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
2300
2159
  data: {
2301
2160
  path: `${root}/${value}`,
@@ -2313,231 +2172,142 @@ const c8ReferencedFixtureExists = {
2313
2172
  }
2314
2173
  };
2315
2174
  //#endregion
2316
- //#region src/lint/rules/d12w-response-body-probe.ts
2317
- /** A test reading the response body this many times (or more) is a cluster. */
2318
- const DEFAULT_THRESHOLD = 3;
2319
- /** Is `node` the `x.response.body` member access itself? */
2320
- function isResponseBody(node) {
2321
- if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
2322
- const object = node.object;
2323
- return object !== void 0 && memberPropertyName(object) === "response";
2324
- }
2325
- /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
2326
- function unwrap(node) {
2327
- let current = node ?? void 0;
2328
- while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
2329
- else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
2330
- else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
2331
- else return current;
2332
- }
2175
+ //#region src/lint/rules/c10-contracts-boundary.ts
2176
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
2177
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
2333
2178
  /**
2334
- * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
2335
- * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
2336
- * of raw body probes — reading `.response.body` (or a variable cast from it)
2337
- * `threshold`+ times in one test callback — should collapse into one golden:
2338
- * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
2339
- * (a legitimate scalpel: absence, a single-field delta, third-party output).
2340
- *
2341
- * A probe is a body-rooted read: a direct `.response.body` use, or a field
2342
- * access on the local binding a `const body = result.response.body as {…}`
2343
- * introduces. The count is the same threshold the golden transformation applies.
2179
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
2180
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
2181
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
2182
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
2183
+ * importing a unit is an error — a test routes through the facade, so a
2184
+ * scenario is named once, next to the world it derives from.
2344
2185
  */
2345
- const d12wResponseBodyProbe = {
2186
+ const c10ContractsBoundary = {
2346
2187
  create(context) {
2347
- if (!segments(context.filename).includes("specs")) return {};
2348
- const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
2349
- return { CallExpression(node) {
2350
- if (!isTestCallee(node.callee)) return;
2351
- const callback = findTestCallback(node.arguments ?? []);
2352
- if (callback === void 0) return;
2353
- const aliases = /* @__PURE__ */ new Set();
2354
- let responseBodyNodes = 0;
2355
- walk(callback, (inner) => {
2356
- if (isResponseBody(inner)) responseBodyNodes++;
2357
- if (inner.type === "VariableDeclarator") {
2358
- const init = unwrap(inner.init);
2359
- const id = inner.id;
2360
- if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
2361
- }
2362
- });
2363
- let aliasReads = 0;
2364
- walk(callback, (inner) => {
2365
- if (inner.type !== "MemberExpression") return;
2366
- const object = inner.object;
2367
- if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
2368
- });
2369
- const probes = responseBodyNodes - aliases.size + aliasReads;
2370
- if (probes >= threshold) context.report({
2371
- data: { count: String(probes) },
2372
- messageId: "bodyProbeCluster",
2188
+ const parts = segments(context.physicalFilename);
2189
+ if (!parts.includes("specs")) return {};
2190
+ if (parts.slice(0, -1).includes("contracts")) return {};
2191
+ return importSourceVisitor(({ node, source }) => {
2192
+ if (INTERNAL_UNIT.test(source)) context.report({
2193
+ data: { source },
2194
+ messageId: "internal",
2373
2195
  node
2374
2196
  });
2375
- } };
2197
+ });
2376
2198
  },
2377
2199
  meta: {
2378
- docs: RULE_DOCS["d12w-response-body-probe"],
2379
- defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
2380
- messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (D12 — see docs/13-linting.md)." },
2381
- schema: [{
2382
- additionalProperties: false,
2383
- properties: { threshold: {
2384
- minimum: 1,
2385
- type: "integer"
2386
- } },
2387
- type: "object"
2388
- }],
2389
- type: "suggestion"
2200
+ docs: RULE_DOCS["c10-contracts-boundary"],
2201
+ 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/13-linting.md)." },
2202
+ type: "problem"
2390
2203
  }
2391
2204
  };
2392
2205
  //#endregion
2393
- //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
2394
- /**
2395
- * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
2396
- * A frozen fixture opts out of update-mode rewriting, so it is exempt.
2397
- */
2398
- function hasFrozenOption(args) {
2399
- const options = args[1];
2400
- if (options === void 0 || options.type !== "ObjectExpression") return false;
2401
- const value = findProperty(options, "frozen")?.value;
2402
- return value?.type === "Literal" && value.value === true;
2206
+ //#region src/lint/rules/c11-contract-data-pairing.ts
2207
+ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/u;
2208
+ /** The provider folders that may carry data files. */
2209
+ const PROVIDERS = [
2210
+ "anthropic",
2211
+ "http",
2212
+ "openai"
2213
+ ];
2214
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
2215
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
2216
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
2217
+ function ownerOf(entry) {
2218
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
2403
2219
  }
2404
2220
  /**
2405
- * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
2406
- * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
2407
- * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
2408
- * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
2409
- * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
2410
- * expression: it stops at the first statement boundary or non-`expect` call, so
2411
- * a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
2221
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
2222
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
2223
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
2224
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
2225
+ * file with no owner is dead weight no test can reach — reported on the feature's
2226
+ * test file, since oxlint never visits a `.json`.
2412
2227
  */
2413
- const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
2414
- "ArrowFunctionExpression",
2415
- "AwaitExpression",
2416
- "ChainExpression",
2417
- "MemberExpression",
2418
- "ParenthesizedExpression",
2419
- "TSAsExpression",
2420
- "TSNonNullExpression"
2421
- ]);
2422
- function isWrappedInExpect(node) {
2423
- let current = node.parent;
2424
- while (current !== void 0) {
2425
- if (current.type === "CallExpression") {
2426
- const callee = current.callee;
2427
- return callee?.type === "Identifier" && callee.name === "expect";
2428
- }
2429
- if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
2430
- current = current.parent;
2431
- }
2432
- return false;
2433
- }
2434
- /**
2435
- * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
2436
- * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
2437
- * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
2438
- * unfrozen fixture is silently rewritten with the actual output — the matcher
2439
- * writes instead of throwing — which destroys the negative case (the assertion
2440
- * then no longer throws). Pass `{ frozen: true }` so the fixture is never
2441
- * rewritten and the mismatch/error still throws in update mode.
2442
- *
2443
- * The structural signal is precise for the wrapped forms; a `toMatch` routed
2444
- * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
2445
- * is out of static reach — see the D13 process note for that residue.
2446
- */
2447
- const d13wUnfrozenNegativeFixture = {
2228
+ const c11ContractDataPairing = {
2448
2229
  create(context) {
2449
- if (!segments(context.filename).includes("specs")) return {};
2450
- return { CallExpression(node) {
2451
- const callee = node.callee;
2452
- if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
2453
- if (!isWrappedInExpect(node)) return;
2454
- if (hasFrozenOption(node.arguments ?? [])) return;
2455
- context.report({
2456
- messageId: "unfrozenNegativeFixture",
2457
- node
2458
- });
2230
+ const file = context.physicalFilename;
2231
+ if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2232
+ return { Program(node) {
2233
+ const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
2234
+ for (const provider of PROVIDERS) {
2235
+ const entries = listDirectory((0, node_path.join)(contractsDir, provider));
2236
+ if (entries === null) continue;
2237
+ const present = new Set(entries);
2238
+ for (const entry of entries) {
2239
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
2240
+ const owner = ownerOf(entry);
2241
+ if (!present.has(owner)) context.report({
2242
+ data: {
2243
+ entry,
2244
+ owner,
2245
+ provider
2246
+ },
2247
+ messageId: "orphan",
2248
+ node
2249
+ });
2250
+ }
2251
+ }
2459
2252
  } };
2460
2253
  },
2461
2254
  meta: {
2462
- docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
2463
- messages: { unfrozenNegativeFixture: "This toMatch asserts a mismatch (wrapped in expect(…).toThrow/.rejects) — pass { frozen: true } so TEST_UPDATE=1 never overwrites the deliberately-wrong fixture (D13 — see docs/13-linting.md)." },
2464
- type: "suggestion"
2255
+ docs: RULE_DOCS["c11-contract-data-pairing"],
2256
+ 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/13-linting.md)." },
2257
+ type: "problem"
2465
2258
  }
2466
2259
  };
2467
2260
  //#endregion
2468
- //#region src/lint/rules/d15w-status-only-probe.ts
2469
- /** An HTTP status code lives in this range — the numeric-literal gate. */
2470
- const MIN_STATUS = 100;
2471
- const MAX_STATUS = 599;
2472
- /** Matchers that pin a single value — the shape of a status probe. */
2473
- const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
2474
- /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
2475
- function isExpectCall(node) {
2476
- if (node?.type !== "CallExpression") return false;
2477
- const callee = node.callee;
2478
- return callee?.type === "Identifier" && callee.name === "expect";
2479
- }
2480
- /** Is `node` a numeric literal in the HTTP status range (100–599)? */
2481
- function isStatusLiteral(node) {
2482
- if (node?.type !== "Literal" || typeof node.value !== "number") return false;
2483
- const value = node.value;
2484
- return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
2485
- }
2486
- /**
2487
- * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
2488
- * `X.status` is any `.status` member access and `N` is a numeric literal in the
2489
- * HTTP status range? The numeric-literal gate keeps container-status STRINGS
2490
- * (`expect(neo.status).toBe('running')`) out.
2491
- */
2492
- function isStatusProbe(node) {
2493
- if (node.type !== "CallExpression") return false;
2494
- const callee = node.callee;
2495
- if (callee?.type !== "MemberExpression") return false;
2496
- const matcher = memberPropertyName(callee);
2497
- if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
2498
- const expectCall = callee.object;
2499
- if (!isExpectCall(expectCall)) return false;
2500
- const subject = (expectCall?.arguments ?? [])[0];
2501
- if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
2502
- const expected = (node.arguments ?? [])[0];
2503
- return isStatusLiteral(expected);
2504
- }
2261
+ //#region src/lint/rules/c13-underscored-ground.ts
2262
+ const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/u;
2505
2263
  /**
2506
- * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
2507
- * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
2508
- * 100–599) throws away the full-response golden. Pin the whole response instead:
2509
- * `expect(result.response).toMatch('case.http')`.
2264
+ * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
2265
+ * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
2266
+ * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
2267
+ * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
2268
+ * fallback.
2510
2269
  *
2511
- * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
2512
- * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
2513
- * threshold yet still hides everything the response carries. The rule fires only
2514
- * when EVERY `expect(…)` in the callback is a status probe — a status probe
2515
- * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
2516
- * the legitimate scalpel and stays silent.
2270
+ * The catch this rule exists for: a consumer that upgrades and keeps the old
2271
+ * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
2272
+ * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
2273
+ * and the tree becomes invisible rather than wrong. The failure that follows is
2274
+ * "fixture does not exist" on every spec at once, one step removed from the
2275
+ * cause; this rule states the cause and the rename.
2276
+ *
2277
+ * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
2278
+ * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
2279
+ * nearest specs root. A DOMAIN folder named after the ground it holds
2280
+ * (`specs/api/requests/_requests/`) is never a probe target — only what sits
2281
+ * beside a spec is.
2517
2282
  */
2518
- const d15wStatusOnlyProbe = {
2283
+ const c13UnderscoredGround = {
2519
2284
  create(context) {
2520
- if (!segments(context.filename).includes("specs")) return {};
2521
- return { CallExpression(node) {
2522
- if (!isTestCallee(node.callee)) return;
2523
- const callback = findTestCallback(node.arguments ?? []);
2524
- if (callback === void 0) return;
2525
- let expectCalls = 0;
2526
- let statusProbes = 0;
2527
- walk(callback, (inner) => {
2528
- if (isExpectCall(inner)) expectCalls++;
2529
- if (isStatusProbe(inner)) statusProbes++;
2530
- });
2531
- if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
2532
- messageId: "statusOnlyProbe",
2533
- node
2534
- });
2285
+ const file = context.physicalFilename;
2286
+ const anchor = specsAnchor(file);
2287
+ if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
2288
+ return { Program(node) {
2289
+ const seen = /* @__PURE__ */ new Set();
2290
+ const probe = (parent, legacy) => {
2291
+ const path = (0, node_path.join)(parent, legacy);
2292
+ if (seen.has(path) || !isDirectory(path)) return;
2293
+ seen.add(path);
2294
+ context.report({
2295
+ data: {
2296
+ ground: RENAMED_GROUND_DIRS[legacy] ?? legacy,
2297
+ legacy
2298
+ },
2299
+ messageId: "notUnderscored",
2300
+ node
2301
+ });
2302
+ };
2303
+ for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe((0, node_path.dirname)(file), legacy);
2304
+ probe(anchor.directory, "fixtures");
2535
2305
  } };
2536
2306
  },
2537
2307
  meta: {
2538
- docs: RULE_DOCS["d15w-status-only-probe"],
2539
- messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (D11 — see docs/13-linting.md)." },
2540
- type: "suggestion"
2308
+ docs: RULE_DOCS["c13-underscored-ground"],
2309
+ messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/13-linting.md)." },
2310
+ type: "problem"
2541
2311
  }
2542
2312
  };
2543
2313
  //#endregion
@@ -2673,7 +2443,7 @@ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
2673
2443
  "url",
2674
2444
  "uuid",
2675
2445
  "workdir"
2676
- ].join("|")})(?:#[\\w.-]+)?\\}\\}$`);
2446
+ ].join("|")})(?:#[\\w.-]+)?\\}\\}$`, "u");
2677
2447
  /**
2678
2448
  * Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
2679
2449
  * Accepts an arrow with an expression body or a single-return body whose
@@ -2685,8 +2455,9 @@ function isTokenEquivalentTransform(fn) {
2685
2455
  let expression = fn.body;
2686
2456
  if (expression?.type === "BlockStatement") {
2687
2457
  const statements = expression.body ?? [];
2688
- if (statements.length !== 1 || statements[0].type !== "ReturnStatement") return false;
2689
- expression = statements[0].argument;
2458
+ const [only] = statements;
2459
+ if (statements.length !== 1 || only?.type !== "ReturnStatement") return false;
2460
+ expression = only.argument;
2690
2461
  }
2691
2462
  let sawReplace = false;
2692
2463
  while (expression?.type === "CallExpression") {
@@ -2762,9 +2533,9 @@ const d8wTextBypass = {
2762
2533
  };
2763
2534
  //#endregion
2764
2535
  //#region src/lint/rules/d9w-single-use-ref.ts
2765
- const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/;
2536
+ const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/u;
2766
2537
  /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2767
- const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/g;
2538
+ const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/gu;
2768
2539
  /** Is this call `match.ref(...)`? */
2769
2540
  function isMatchRef(node) {
2770
2541
  const callee = node.callee;
@@ -2819,7 +2590,7 @@ const d9wSingleUseRef = {
2819
2590
  if (memberPropertyName(node.callee) === "toMatch") {
2820
2591
  const name = stringValue(node.arguments?.[0]);
2821
2592
  if (name !== void 0) {
2822
- const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/, ""));
2593
+ const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/u, ""));
2823
2594
  for (const ref of refsInFixture(target)) {
2824
2595
  bump(ref);
2825
2596
  if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
@@ -2844,6 +2615,234 @@ const d9wSingleUseRef = {
2844
2615
  }
2845
2616
  };
2846
2617
  //#endregion
2618
+ //#region src/lint/rules/d12w-response-body-probe.ts
2619
+ /** A test reading the response body this many times (or more) is a cluster. */
2620
+ const DEFAULT_THRESHOLD = 3;
2621
+ /** Is `node` the `x.response.body` member access itself? */
2622
+ function isResponseBody(node) {
2623
+ if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
2624
+ const object = node.object;
2625
+ return object !== void 0 && memberPropertyName(object) === "response";
2626
+ }
2627
+ /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
2628
+ function unwrap(node) {
2629
+ let current = node ?? void 0;
2630
+ while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
2631
+ else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
2632
+ else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
2633
+ else return current;
2634
+ }
2635
+ /**
2636
+ * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
2637
+ * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
2638
+ * of raw body probes — reading `.response.body` (or a variable cast from it)
2639
+ * `threshold`+ times in one test callback — should collapse into one golden:
2640
+ * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
2641
+ * (a legitimate scalpel: absence, a single-field delta, third-party output).
2642
+ *
2643
+ * A probe is a body-rooted read: a direct `.response.body` use, or a field
2644
+ * access on the local binding a `const body = result.response.body as {…}`
2645
+ * introduces. The count is the same threshold the golden transformation applies.
2646
+ */
2647
+ const d12wResponseBodyProbe = {
2648
+ create(context) {
2649
+ if (!segments(context.filename).includes("specs")) return {};
2650
+ const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
2651
+ return { CallExpression(node) {
2652
+ if (!isTestCallee(node.callee)) return;
2653
+ const callback = findTestCallback(node.arguments ?? []);
2654
+ if (callback === void 0) return;
2655
+ const aliases = /* @__PURE__ */ new Set();
2656
+ let responseBodyNodes = 0;
2657
+ walk(callback, (inner) => {
2658
+ if (isResponseBody(inner)) responseBodyNodes++;
2659
+ if (inner.type === "VariableDeclarator") {
2660
+ const init = unwrap(inner.init);
2661
+ const id = inner.id;
2662
+ if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
2663
+ }
2664
+ });
2665
+ let aliasReads = 0;
2666
+ walk(callback, (inner) => {
2667
+ if (inner.type !== "MemberExpression") return;
2668
+ const object = inner.object;
2669
+ if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
2670
+ });
2671
+ const probes = responseBodyNodes - aliases.size + aliasReads;
2672
+ if (probes >= threshold) context.report({
2673
+ data: { count: String(probes) },
2674
+ messageId: "bodyProbeCluster",
2675
+ node
2676
+ });
2677
+ } };
2678
+ },
2679
+ meta: {
2680
+ docs: RULE_DOCS["d12w-response-body-probe"],
2681
+ defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
2682
+ messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (D12 — see docs/13-linting.md)." },
2683
+ schema: [{
2684
+ additionalProperties: false,
2685
+ properties: { threshold: {
2686
+ minimum: 1,
2687
+ type: "integer"
2688
+ } },
2689
+ type: "object"
2690
+ }],
2691
+ type: "suggestion"
2692
+ }
2693
+ };
2694
+ //#endregion
2695
+ //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
2696
+ /**
2697
+ * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
2698
+ * A frozen fixture opts out of update-mode rewriting, so it is exempt.
2699
+ */
2700
+ function hasFrozenOption(args) {
2701
+ const options = args[1];
2702
+ if (options === void 0 || options.type !== "ObjectExpression") return false;
2703
+ const value = findProperty(options, "frozen")?.value;
2704
+ return value?.type === "Literal" && value.value === true;
2705
+ }
2706
+ /**
2707
+ * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
2708
+ * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
2709
+ * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
2710
+ * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
2711
+ * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
2712
+ * expression: it stops at the first statement boundary or non-`expect` call, so
2713
+ * a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
2714
+ */
2715
+ const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
2716
+ "ArrowFunctionExpression",
2717
+ "AwaitExpression",
2718
+ "ChainExpression",
2719
+ "MemberExpression",
2720
+ "ParenthesizedExpression",
2721
+ "TSAsExpression",
2722
+ "TSNonNullExpression"
2723
+ ]);
2724
+ function isWrappedInExpect(node) {
2725
+ let current = node.parent;
2726
+ while (current !== void 0) {
2727
+ if (current.type === "CallExpression") {
2728
+ const callee = current.callee;
2729
+ return callee?.type === "Identifier" && callee.name === "expect";
2730
+ }
2731
+ if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
2732
+ current = current.parent;
2733
+ }
2734
+ return false;
2735
+ }
2736
+ /**
2737
+ * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
2738
+ * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
2739
+ * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
2740
+ * unfrozen fixture is silently rewritten with the actual output — the matcher
2741
+ * writes instead of throwing — which destroys the negative case (the assertion
2742
+ * then no longer throws). Pass `{ frozen: true }` so the fixture is never
2743
+ * rewritten and the mismatch/error still throws in update mode.
2744
+ *
2745
+ * The structural signal is precise for the wrapped forms; a `toMatch` routed
2746
+ * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
2747
+ * is out of static reach — see the D13 process note for that residue.
2748
+ */
2749
+ const d13wUnfrozenNegativeFixture = {
2750
+ create(context) {
2751
+ if (!segments(context.filename).includes("specs")) return {};
2752
+ return { CallExpression(node) {
2753
+ const callee = node.callee;
2754
+ if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
2755
+ if (!isWrappedInExpect(node)) return;
2756
+ if (hasFrozenOption(node.arguments ?? [])) return;
2757
+ context.report({
2758
+ messageId: "unfrozenNegativeFixture",
2759
+ node
2760
+ });
2761
+ } };
2762
+ },
2763
+ meta: {
2764
+ docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
2765
+ messages: { unfrozenNegativeFixture: "This toMatch asserts a mismatch (wrapped in expect(…).toThrow/.rejects) — pass { frozen: true } so TEST_UPDATE=1 never overwrites the deliberately-wrong fixture (D13 — see docs/13-linting.md)." },
2766
+ type: "suggestion"
2767
+ }
2768
+ };
2769
+ //#endregion
2770
+ //#region src/lint/rules/d15w-status-only-probe.ts
2771
+ /** An HTTP status code lives in this range — the numeric-literal gate. */
2772
+ const MIN_STATUS = 100;
2773
+ const MAX_STATUS = 599;
2774
+ /** Matchers that pin a single value — the shape of a status probe. */
2775
+ const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
2776
+ /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
2777
+ function isExpectCall(node) {
2778
+ if (node?.type !== "CallExpression") return false;
2779
+ const callee = node.callee;
2780
+ return callee?.type === "Identifier" && callee.name === "expect";
2781
+ }
2782
+ /** Is `node` a numeric literal in the HTTP status range (100–599)? */
2783
+ function isStatusLiteral(node) {
2784
+ if (node?.type !== "Literal" || typeof node.value !== "number") return false;
2785
+ const { value } = node;
2786
+ return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
2787
+ }
2788
+ /**
2789
+ * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
2790
+ * `X.status` is any `.status` member access and `N` is a numeric literal in the
2791
+ * HTTP status range? The numeric-literal gate keeps container-status STRINGS
2792
+ * (`expect(neo.status).toBe('running')`) out.
2793
+ */
2794
+ function isStatusProbe(node) {
2795
+ if (node.type !== "CallExpression") return false;
2796
+ const callee = node.callee;
2797
+ if (callee?.type !== "MemberExpression") return false;
2798
+ const matcher = memberPropertyName(callee);
2799
+ if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
2800
+ const expectCall = callee.object;
2801
+ if (!isExpectCall(expectCall)) return false;
2802
+ const subject = (expectCall?.arguments ?? [])[0];
2803
+ if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
2804
+ const expected = (node.arguments ?? [])[0];
2805
+ return isStatusLiteral(expected);
2806
+ }
2807
+ /**
2808
+ * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
2809
+ * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
2810
+ * 100–599) throws away the full-response golden. Pin the whole response instead:
2811
+ * `expect(result.response).toMatch('case.http')`.
2812
+ *
2813
+ * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
2814
+ * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
2815
+ * threshold yet still hides everything the response carries. The rule fires only
2816
+ * when EVERY `expect(…)` in the callback is a status probe — a status probe
2817
+ * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
2818
+ * the legitimate scalpel and stays silent.
2819
+ */
2820
+ const d15wStatusOnlyProbe = {
2821
+ create(context) {
2822
+ if (!segments(context.filename).includes("specs")) return {};
2823
+ return { CallExpression(node) {
2824
+ if (!isTestCallee(node.callee)) return;
2825
+ const callback = findTestCallback(node.arguments ?? []);
2826
+ if (callback === void 0) return;
2827
+ let expectCalls = 0;
2828
+ let statusProbes = 0;
2829
+ walk(callback, (inner) => {
2830
+ if (isExpectCall(inner)) expectCalls++;
2831
+ if (isStatusProbe(inner)) statusProbes++;
2832
+ });
2833
+ if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
2834
+ messageId: "statusOnlyProbe",
2835
+ node
2836
+ });
2837
+ } };
2838
+ },
2839
+ meta: {
2840
+ docs: RULE_DOCS["d15w-status-only-probe"],
2841
+ messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (D11 — see docs/13-linting.md)." },
2842
+ type: "suggestion"
2843
+ }
2844
+ };
2845
+ //#endregion
2847
2846
  //#region src/lint/package-exports.ts
2848
2847
  /**
2849
2848
  * The subpaths this package actually publishes, read from its OWN
@@ -2897,7 +2896,7 @@ function findOwnManifest(startDir) {
2897
2896
  */
2898
2897
  function declaredSubpaths(startDir) {
2899
2898
  if (startDir === void 0 && cached !== null) return cached;
2900
- const exports = findOwnManifest(startDir ?? (0, node_path.dirname)((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)))?.exports;
2899
+ const exports = findOwnManifest(startDir ?? __dirname)?.exports;
2901
2900
  const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2902
2901
  if (startDir === void 0) cached = subpaths;
2903
2902
  return subpaths;
@@ -2939,9 +2938,9 @@ const f1NoSubpathImport = {
2939
2938
  };
2940
2939
  //#endregion
2941
2940
  //#region src/lint/rules/f2-no-test-imports-in-prod.ts
2942
- const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/;
2943
- const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/;
2944
- const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/;
2941
+ const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/u;
2942
+ const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/u;
2943
+ const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/u;
2945
2944
  const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2946
2945
  /**
2947
2946
  * CONVENTIONS F2 — production code never imports a test artefact. In a
@@ -2998,15 +2997,15 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
2998
2997
  * `server: () => createApp()` importing `../../src/app.js`).
2999
2998
  */
3000
2999
  const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
3001
- "core",
3002
3000
  "integrations",
3003
3001
  "lint",
3002
+ "specification",
3004
3003
  "vitest"
3005
3004
  ]);
3006
3005
  /**
3007
3006
  * CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
3008
3007
  * forbidden: a relative path resolving inside the framework repo's
3009
- * `src/{core,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3008
+ * `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3010
3009
  * other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
3011
3010
  * imports of its OWN app source are always allowed — that is the pattern.
3012
3011
  *
@@ -3032,7 +3031,7 @@ const f3SpecsPublicEntry = {
3032
3031
  }
3033
3032
  const marker = source.indexOf("/src/");
3034
3033
  if (marker === -1 && !source.startsWith("src/")) return;
3035
- const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0];
3034
+ const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0] ?? "";
3036
3035
  if (!FRAMEWORK_LAYERS.has(layer)) return;
3037
3036
  if (underIntegrations && layer === "integrations") return;
3038
3037
  context.report({
@@ -3050,8 +3049,8 @@ const f3SpecsPublicEntry = {
3050
3049
  };
3051
3050
  //#endregion
3052
3051
  //#region src/lint/rules/f4-no-test-to-test-import.ts
3053
- const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/;
3054
- const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/;
3052
+ const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/u;
3053
+ const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/u;
3055
3054
  /**
3056
3055
  * CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
3057
3056
  * test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
@@ -3076,8 +3075,8 @@ const f4NoTestToTestImport = {
3076
3075
  };
3077
3076
  //#endregion
3078
3077
  //#region src/lint/rules/f5-fixtures-only-from-tests.ts
3079
- const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/;
3080
- const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/;
3078
+ const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/u;
3079
+ const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/u;
3081
3080
  /**
3082
3081
  * CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
3083
3082
  * `*.test.ts` files. Any other importer (prod code, a specification file,
@@ -3102,7 +3101,7 @@ const f5FixturesOnlyFromTests = {
3102
3101
  };
3103
3102
  //#endregion
3104
3103
  //#region src/lint/rules/i1-layer-boundaries.ts
3105
- const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
3104
+ const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
3106
3105
  function matchesPackage(source, packages) {
3107
3106
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
3108
3107
  }
@@ -3114,7 +3113,7 @@ function pathInsideSrc(path) {
3114
3113
  }
3115
3114
  /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
3116
3115
  function withoutExtension(path) {
3117
- return path.replace(/\.[cm]?[jt]sx?$/, "");
3116
+ return path.replace(/\.[cm]?[jt]sx?$/u, "");
3118
3117
  }
3119
3118
  /** Does `target` match one of the declared paths (prefix, or exact module)? */
3120
3119
  function matchesPath(target, patterns) {
@@ -3231,7 +3230,7 @@ const i1LayerBoundaries = {
3231
3230
  };
3232
3231
  //#endregion
3233
3232
  //#region src/lint/rules/i2-sibling-test-naming.ts
3234
- const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/;
3233
+ const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
3235
3234
  /**
3236
3235
  * CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
3237
3236
  * `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
@@ -3259,7 +3258,7 @@ const i2SiblingTestNaming = {
3259
3258
  ".js"
3260
3259
  ].map((extension) => file.replace(TEST_FILE$1, extension));
3261
3260
  if (!neighbours.some(isFile)) context.report({
3262
- data: { expected: neighbours[0].split(/[/\\]/).at(-1) ?? "" },
3261
+ data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
3263
3262
  messageId: "orphanTest",
3264
3263
  node
3265
3264
  });
@@ -3286,7 +3285,7 @@ const i2SiblingTestNaming = {
3286
3285
  };
3287
3286
  //#endregion
3288
3287
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
3289
- const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
3288
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
3290
3289
  const DATA_ASSET = new RegExp(`\\.(?:${[
3291
3290
  "avif",
3292
3291
  "bin",
@@ -3324,10 +3323,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
3324
3323
  "yaml",
3325
3324
  "yml",
3326
3325
  "zip"
3327
- ].join("|")})$`, "i");
3326
+ ].join("|")})$`, "iu");
3328
3327
  /** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
3329
3328
  function withoutSuffix(source) {
3330
- return source.replace(/[?#].*$/, "");
3329
+ return source.replace(/[?#].*$/u, "");
3331
3330
  }
3332
3331
  /**
3333
3332
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
@@ -3567,7 +3566,7 @@ const plugin = {
3567
3566
  *
3568
3567
  * 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
3569
3568
  */
3570
- const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/.test(rule) ? "warn" : "error"]));
3569
+ const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/u.test(rule) ? "warn" : "error"]));
3571
3570
  /**
3572
3571
  * The composable testing fragment — wire the plugin, enable the whole
3573
3572
  * catalogue, and ship the one override every strict consumer needs. Designed