@jterrazz/test 14.3.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. */
@@ -198,21 +198,10 @@ function isTestCallee(callee) {
198
198
  if (callee.type === "CallExpression") return isTestCallee(callee.callee);
199
199
  return false;
200
200
  }
201
- /** Does the test callee chain carry a modifier member with the given name? */
202
- function testCalleeHasModifier(callee, modifier) {
203
- if (callee === void 0) return false;
204
- if (callee.type === "MemberExpression") return memberPropertyName(callee) === modifier || testCalleeHasModifier(callee.object, modifier);
205
- if (callee.type === "CallExpression") return testCalleeHasModifier(callee.callee, modifier);
206
- return false;
207
- }
208
201
  /** The callback argument of a test call (arrow or function expression), if any. */
209
202
  function findTestCallback(args) {
210
203
  return args.find((arg) => arg.type === "ArrowFunctionExpression" || arg.type === "FunctionExpression");
211
204
  }
212
- /** The static string name of a test call (`test('name', …)`), if a literal. */
213
- function testName(callNode) {
214
- return stringValue((callNode.arguments ?? [])[0]);
215
- }
216
205
  /**
217
206
  * Visitor fragment collecting every import-like source: static imports,
218
207
  * dynamic `import()`, and re-exports. Rules spread this into their visitor.
@@ -239,6 +228,26 @@ function importSourceVisitor(onSource) {
239
228
  * The **statique** channel — one entry per shipped `jterrazz/*` rule. Each rule
240
229
  * file attaches its entry as `meta.docs`, so a rule and its normative text can
241
230
  * never drift (the completeness meta-test asserts every rule carries one).
231
+ *
232
+ * **What this channel does NOT own.** A convention oxlint's own `vitest` plugin
233
+ * already enforces belongs to that plugin, not here — `@jterrazz/typescript` v10
234
+ * turns the plugin on over the test globs in every profile, so a consumer of the
235
+ * preset gets it without wiring. Four hygiene rules left the channel on that
236
+ * ground (J1, J3, J4, J5 → `vitest/no-focused-tests` + `vitest/no-disabled-tests`,
237
+ * `vitest/expect-expect`, `vitest/no-identical-title`, `vitest/prefer-lowercase-title`).
238
+ * `prefer-lowercase-title` is stricter than J5 was: a title opening on an all-caps
239
+ * identifier (`HTTP 404 …`, `DI …`) now fails, and the rulebook carries no
240
+ * `allowedPrefixes` — a title starts lowercase, whatever its first word. The
241
+ * rest are behaviour-identical on this package's own fixtures. Only J2 (the
242
+ * arbitrary-sleep ban, which has no upstream counterpart) stays statique.
243
+ *
244
+ * **Severity.** The estate's rulebook has no warn tier: a rule is on at `error`
245
+ * or off with a recorded reason, and that law binds `@jterrazz/typescript`'s
246
+ * rulebook. This channel is its one documented exception, and it is narrow — an
247
+ * id shaped `<family><n>w-…` is a REDUNDANCY heuristic (a `root` the walk would
248
+ * have found, a probe cluster that wants a golden), a reading of taste that
249
+ * would block a legitimate shape if it failed the build. Every hard convention
250
+ * here is an `error`; `plugin.test.ts` holds the split.
242
251
  */
243
252
  const RULE_DOCS = {
244
253
  "a1-specification-file": {
@@ -474,7 +483,7 @@ const RULE_DOCS = {
474
483
  },
475
484
  "f3-specs-public-entry": {
476
485
  channel: "statique",
477
- 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/**`.",
478
487
  family: "F",
479
488
  id: "F3",
480
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."
@@ -514,13 +523,6 @@ const RULE_DOCS = {
514
523
  id: "I4",
515
524
  rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
516
525
  },
517
- "j1-no-only-skip": {
518
- channel: "statique",
519
- convention: "Aucun `.only` / `.skip` committé (`describe.only`, `test.only`, `test.skip`).",
520
- family: "J",
521
- id: "J1",
522
- rationale: "Un `.only`/`.skip` oublié désactive silencieusement une partie de la suite."
523
- },
524
526
  "j2-no-sleep-in-specs": {
525
527
  channel: "statique",
526
528
  convention: "Aucun sleep arbitraire (`setTimeout`/`setInterval`/`Atomics.wait`) sous `specs/**` — la synchronisation passe par `waitFor`.",
@@ -528,27 +530,6 @@ const RULE_DOCS = {
528
530
  id: "J2",
529
531
  rationale: "Un sleep fixe rend les tests lents et instables ; attendre une condition est déterministe."
530
532
  },
531
- "j3-no-expectless-test": {
532
- channel: "statique",
533
- convention: "Un `test(...)` avec callback contient au moins un `expect(…)` ; `test.todo` (sans callback) est ignoré.",
534
- family: "J",
535
- id: "J3",
536
- rationale: "Un test sans assertion est mort ou muet — il passe toujours sans rien vérifier."
537
- },
538
- "j4-unique-test-names": {
539
- channel: "statique",
540
- convention: "Deux tests d’un même fichier ne partagent pas un nom littéral (`.each` ignoré).",
541
- family: "J",
542
- id: "J4",
543
- rationale: "Le nom du test est son unique description — deux noms identiques rendent un échec ambigu."
544
- },
545
- "j5-lowercase-title": {
546
- channel: "statique",
547
- convention: "La première lettre d’un titre `test()`/`describe()`/`it()` littéral est en minuscule. Exemptés : les titres dont le premier MOT est un identifiant tout en majuscules/underscores (`VALID_CATEGORIES`, `HTTP`, `DI`) et ceux démarrant sur un non-lettre — seuls les premiers mots de prose minusculisables sont contraints.",
548
- family: "J",
549
- id: "J5",
550
- rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
551
- },
552
533
  "w1-scenario-pure": {
553
534
  channel: "statique",
554
535
  convention: "Un scénario (`.visit()` website, `.open()` mobile) est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné.",
@@ -907,90 +888,13 @@ const a1SpecificationFile = {
907
888
  },
908
889
  meta: {
909
890
  docs: RULE_DOCS["a1-specification-file"],
910
- messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/10-linting.md)." },
911
- type: "problem"
912
- }
913
- };
914
- //#endregion
915
- //#region src/core/specification/shared/binding.ts
916
- /**
917
- * Compose-binding resolution and the case conversions it shares with the
918
- * automatic env injection (CONVENTIONS A6 / B6).
919
- *
920
- * A services-record key is written in the natural TypeScript style
921
- * (`analyticsDb`), while a compose service name is kebab-case
922
- * (`analytics-db`). These helpers bridge the two vocabularies deterministically
923
- * so the record key stays the single source of truth and `composeService`
924
- * remains a rare escape hatch.
925
- */
926
- /**
927
- * Convert a record key to its kebab-case form: camelCase boundaries become
928
- * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
929
- */
930
- function toKebabCase(key) {
931
- return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
932
- }
933
- //#endregion
934
- //#region src/lint/rules/a10-duplicate-binding.ts
935
- /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
936
- function canonicalBinding(entry, key) {
937
- const factory = entry.value;
938
- if (factory?.type === "CallExpression") {
939
- const options = factory.arguments?.[0];
940
- if (options?.type === "ObjectExpression") {
941
- const explicit = stringValue(findProperty(options, "composeService")?.value);
942
- if (explicit !== void 0) return explicit;
943
- }
944
- }
945
- return toKebabCase(key);
946
- }
947
- /**
948
- * CONVENTIONS A10 — within one `services` record, two keys that derive (or
949
- * pin via `composeService`) the SAME compose service name are a silent
950
- * collision: the record is a map, and the second binding would shadow the
951
- * first. Detected on the same kebab derivation the runtime binding uses
952
- * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
953
- * `composeService` values.
954
- */
955
- const a10DuplicateBinding = {
956
- create(context) {
957
- return { CallExpression(node) {
958
- if (specificationMember(node) === void 0) return;
959
- for (const argument of node.arguments ?? []) {
960
- const record = findProperty(argument, "services")?.value;
961
- if (record?.type !== "ObjectExpression") continue;
962
- const seen = /* @__PURE__ */ new Map();
963
- for (const entry of record.properties ?? []) {
964
- const key = propertyKeyName(entry);
965
- if (key === void 0) continue;
966
- const binding = canonicalBinding(entry, key);
967
- const first = seen.get(binding);
968
- if (first !== void 0) {
969
- context.report({
970
- data: {
971
- binding,
972
- first,
973
- key
974
- },
975
- messageId: "duplicate",
976
- node: entry
977
- });
978
- continue;
979
- }
980
- seen.set(binding, key);
981
- }
982
- }
983
- } };
984
- },
985
- meta: {
986
- docs: RULE_DOCS["a10-duplicate-binding"],
987
- 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/10-linting.md)." },
891
+ messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/13-linting.md)." },
988
892
  type: "problem"
989
893
  }
990
894
  };
991
895
  //#endregion
992
896
  //#region src/lint/rules/a2-known-constructors.ts
993
- /** The five constructors, and only five (A2 — see docs/10-linting.md). */
897
+ /** The five constructors, and only five (A2 — see docs/13-linting.md). */
994
898
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
995
899
  "api",
996
900
  "cli",
@@ -1019,7 +923,7 @@ const a2KnownConstructors = {
1019
923
  },
1020
924
  meta: {
1021
925
  docs: RULE_DOCS["a2-known-constructors"],
1022
- messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.website() and specification.mobile() (A2 — see docs/10-linting.md)." },
926
+ messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.website() and specification.mobile() (A2 — see docs/13-linting.md)." },
1023
927
  type: "problem"
1024
928
  }
1025
929
  };
@@ -1064,7 +968,7 @@ const a3NoDestructureAlias = {
1064
968
  },
1065
969
  meta: {
1066
970
  docs: RULE_DOCS["a3-no-destructure-alias"],
1067
- messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (A3 — see docs/10-linting.md). Rename at the import site if a different local name is needed." },
971
+ messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (A3 — see docs/13-linting.md). Rename at the import site if a different local name is needed." },
1068
972
  type: "problem"
1069
973
  }
1070
974
  };
@@ -1116,7 +1020,7 @@ const a4CleanupAfterall = {
1116
1020
  },
1117
1021
  meta: {
1118
1022
  docs: RULE_DOCS["a4-cleanup-afterall"],
1119
- messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/10-linting.md)." },
1023
+ messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/13-linting.md)." },
1120
1024
  type: "problem"
1121
1025
  }
1122
1026
  };
@@ -1146,11 +1050,30 @@ const a5ModeWithServer = {
1146
1050
  },
1147
1051
  meta: {
1148
1052
  docs: RULE_DOCS["a5-mode-with-server"],
1149
- messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (A5 — see docs/10-linting.md)." },
1053
+ messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (A5 — see docs/13-linting.md)." },
1150
1054
  type: "problem"
1151
1055
  }
1152
1056
  };
1153
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
1154
1077
  //#region src/lint/rules/a6w-redundant-compose-service.ts
1155
1078
  /**
1156
1079
  * CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
@@ -1187,12 +1110,12 @@ const a6wRedundantComposeService = {
1187
1110
  },
1188
1111
  meta: {
1189
1112
  docs: RULE_DOCS["a6w-redundant-compose-service"],
1190
- messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, A6 — see docs/10-linting.md)." },
1113
+ messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, A6 — see docs/13-linting.md)." },
1191
1114
  type: "suggestion"
1192
1115
  }
1193
1116
  };
1194
1117
  //#endregion
1195
- //#region src/core/specification/shared/resolve.ts
1118
+ //#region src/specification/facets/_common/resolve.ts
1196
1119
  /** What makes a directory a project root — a manifest, or a test stack. */
1197
1120
  const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
1198
1121
  /**
@@ -1222,7 +1145,7 @@ function findRoot(startDir, exists) {
1222
1145
  //#endregion
1223
1146
  //#region src/lint/rules/a9w-redundant-root.ts
1224
1147
  /**
1225
- * The root the convention would derive (A9 — see docs/10-linting.md): the
1148
+ * The root the convention would derive (A9 — see docs/13-linting.md): the
1226
1149
  * framework's own walk-up, run against this layer's cached fs probe. The rule
1227
1150
  * flags a `root` that the runner would have found by itself, so the two MUST
1228
1151
  * be the same function — a private copy of the walk drifted from it before.
@@ -1254,12 +1177,70 @@ const a9wRedundantRoot = {
1254
1177
  },
1255
1178
  meta: {
1256
1179
  docs: RULE_DOCS["a9w-redundant-root"],
1257
- messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/10-linting.md)." },
1180
+ messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/13-linting.md)." },
1258
1181
  type: "suggestion"
1259
1182
  }
1260
1183
  };
1261
1184
  //#endregion
1262
- //#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
1263
1244
  /**
1264
1245
  * The GROUND a row of specs stands on — the naming law of a spec tree.
1265
1246
  *
@@ -1300,7 +1281,7 @@ const RENAMED_GROUND_DIRS = {
1300
1281
  seeds: GROUND_SEEDS
1301
1282
  };
1302
1283
  //#endregion
1303
- //#region src/core/specification/shared/fixtures.ts
1284
+ //#region src/specification/facets/_common/fixtures.ts
1304
1285
  /**
1305
1286
  * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
1306
1287
  *
@@ -1347,7 +1328,7 @@ const b2KnownFixtureMarker = {
1347
1328
  const args = node.arguments ?? [];
1348
1329
  const path = leadingText(args[0]);
1349
1330
  if (path === void 0 || !path.startsWith("$")) return;
1350
- const marker = path.replace(/\/+$/, "").split("/")[0];
1331
+ const marker = path.replace(/\/+$/u, "").split("/")[0] ?? "";
1351
1332
  if (!KNOWN.has(marker)) context.report({
1352
1333
  data: {
1353
1334
  known: [...KNOWN_FIXTURE_MARKERS].join(", "),
@@ -1360,7 +1341,7 @@ const b2KnownFixtureMarker = {
1360
1341
  },
1361
1342
  meta: {
1362
1343
  docs: RULE_DOCS["b2-known-fixture-marker"],
1363
- messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/10-linting.md)." },
1344
+ messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/13-linting.md)." },
1364
1345
  type: "problem"
1365
1346
  }
1366
1347
  };
@@ -1425,8 +1406,8 @@ const b4GivenThen = {
1425
1406
  meta: {
1426
1407
  docs: RULE_DOCS["b4-given-then"],
1427
1408
  messages: {
1428
- givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/10-linting.md).",
1429
- missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/10-linting.md — every test needs both `// Given -` and `// Then -`)."
1409
+ givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/13-linting.md).",
1410
+ missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/13-linting.md — every test needs both `// Given -` and `// Then -`)."
1430
1411
  },
1431
1412
  type: "problem"
1432
1413
  }
@@ -1467,7 +1448,7 @@ const b5AwaitUsing = {
1467
1448
  meta: {
1468
1449
  docs: RULE_DOCS["b5-await-using"],
1469
1450
  defaultOptions: [{ runners: [] }],
1470
- messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (B5 — see docs/10-linting.md)." },
1451
+ messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (B5 — see docs/13-linting.md)." },
1471
1452
  schema: [{
1472
1453
  additionalProperties: false,
1473
1454
  properties: { runners: {
@@ -1481,7 +1462,7 @@ const b5AwaitUsing = {
1481
1462
  };
1482
1463
  //#endregion
1483
1464
  //#region src/lint/rules/b6w-redundant-env-url.ts
1484
- const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/;
1465
+ const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/u;
1485
1466
  /**
1486
1467
  * CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
1487
1468
  * already injects `<KEY>_URL` for every service (plus the unambiguous
@@ -1510,14 +1491,14 @@ const b6wRedundantEnvUrl = {
1510
1491
  },
1511
1492
  meta: {
1512
1493
  docs: RULE_DOCS["b6w-redundant-env-url"],
1513
- messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (B6 — see docs/10-linting.md)." },
1494
+ messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (B6 — see docs/13-linting.md)." },
1514
1495
  type: "suggestion"
1515
1496
  }
1516
1497
  };
1517
1498
  //#endregion
1518
1499
  //#region src/lint/rules/b8-kebab-trigger.ts
1519
1500
  /** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
1520
- const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1501
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
1521
1502
  /**
1522
1503
  * CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
1523
1504
  * identifier; it is a contract between the app and its tests. Only string
@@ -1539,7 +1520,7 @@ const b8KebabTrigger = {
1539
1520
  },
1540
1521
  meta: {
1541
1522
  docs: RULE_DOCS["b8-kebab-trigger"],
1542
- messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (B8 — see docs/10-linting.md)." },
1523
+ messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (B8 — see docs/13-linting.md)." },
1543
1524
  type: "problem"
1544
1525
  }
1545
1526
  };
@@ -1594,7 +1575,7 @@ const b9wProductCommand = {
1594
1575
  },
1595
1576
  meta: {
1596
1577
  docs: RULE_DOCS["b9w-product-command"],
1597
- messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (B9 — see docs/10-linting.md). Suppress with a reason if the product genuinely is this binary." },
1578
+ messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (B9 — see docs/13-linting.md). Suppress with a reason if the product genuinely is this binary." },
1598
1579
  type: "suggestion"
1599
1580
  }
1600
1581
  };
@@ -1747,14 +1728,14 @@ const c1DomainStructure = {
1747
1728
  defaultOptions: [{ depth: DEFAULT_DEPTH }],
1748
1729
  docs: RULE_DOCS["c1-domain-structure"],
1749
1730
  messages: {
1750
- specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/10-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
1751
- specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1752
- 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).",
1753
- 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).",
1754
- 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).",
1755
- testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/10-linting.md).",
1756
- 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).",
1757
- testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/10-linting.md)."
1731
+ specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/13-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
1732
+ specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/13-linting.md).",
1733
+ 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/13-linting.md).",
1734
+ 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/13-linting.md).",
1735
+ testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/13-linting.md).",
1736
+ testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/13-linting.md).",
1737
+ testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/13-linting.md).",
1738
+ testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/13-linting.md)."
1758
1739
  },
1759
1740
  schema: [{
1760
1741
  additionalProperties: false,
@@ -1773,212 +1754,71 @@ const c1DomainStructure = {
1773
1754
  }
1774
1755
  };
1775
1756
  //#endregion
1776
- //#region src/lint/rules/c10-contracts-boundary.ts
1777
- /** A specifier reaching into a provider folder — the internal half of contracts/. */
1778
- 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;
1779
1759
  /**
1780
- * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1781
- * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1782
- * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1783
- * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1784
- * importing a unit is an error — a test routes through the facade, so a
1785
- * 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.
1786
1765
  */
1787
- const c10ContractsBoundary = {
1766
+ const c2HttpOnlyRequests = {
1788
1767
  create(context) {
1789
- const parts = segments(context.physicalFilename);
1790
- if (!parts.includes("specs")) return {};
1791
- if (parts.slice(0, -1).includes("contracts")) return {};
1792
- return importSourceVisitor(({ node, source }) => {
1793
- if (INTERNAL_UNIT.test(source)) context.report({
1794
- data: { source },
1795
- 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",
1796
1775
  node
1797
1776
  });
1798
- });
1777
+ } };
1799
1778
  },
1800
1779
  meta: {
1801
- docs: RULE_DOCS["c10-contracts-boundary"],
1802
- messages: { internal: "Import \"{{source}}\" reaches into a provider folder — only contracts/*.contracts.ts is importable from a test; add a named scenario export there instead (C10 — see docs/10-linting.md)." },
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)." },
1803
1782
  type: "problem"
1804
1783
  }
1805
1784
  };
1806
1785
  //#endregion
1807
- //#region src/lint/rules/c11-contract-data-pairing.ts
1808
- const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/;
1809
- /** The provider folders that may carry data files. */
1810
- 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([
1811
1789
  "anthropic",
1812
1790
  "http",
1813
1791
  "openai"
1814
- ];
1815
- /** Data files: served payloads and matched inputs, both owned by a contract. */
1816
- const DATA_SUFFIXES = [".request.ts", ".response.json"];
1817
- /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1818
- function ownerOf(entry) {
1819
- 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";
1820
1818
  }
1821
1819
  /**
1822
- * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1823
- * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1824
- * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1825
- * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1826
- * file with no owner is dead weight no test can reach — reported on the feature's
1827
- * test file, since oxlint never visits a `.json`.
1828
- */
1829
- const c11ContractDataPairing = {
1830
- create(context) {
1831
- const file = context.physicalFilename;
1832
- if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1833
- return { Program(node) {
1834
- const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
1835
- for (const provider of PROVIDERS$1) {
1836
- const entries = listDirectory((0, node_path.join)(contractsDir, provider));
1837
- if (entries === null) continue;
1838
- const present = new Set(entries);
1839
- for (const entry of entries) {
1840
- if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1841
- const owner = ownerOf(entry);
1842
- if (!present.has(owner)) context.report({
1843
- data: {
1844
- entry,
1845
- owner,
1846
- provider
1847
- },
1848
- messageId: "orphan",
1849
- node
1850
- });
1851
- }
1852
- }
1853
- } };
1854
- },
1855
- meta: {
1856
- docs: RULE_DOCS["c11-contract-data-pairing"],
1857
- messages: { orphan: "contracts/{{provider}}/{{entry}} has no owning contract — data pairs with the sibling {{owner}} (stem = name up to the first dot) (C11 — see docs/10-linting.md)." },
1858
- type: "problem"
1859
- }
1860
- };
1861
- //#endregion
1862
- //#region src/lint/rules/c13-underscored-ground.ts
1863
- const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1864
- /**
1865
- * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
1866
- * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
1867
- * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
1868
- * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
1869
- * fallback.
1870
- *
1871
- * The catch this rule exists for: a consumer that upgrades and keeps the old
1872
- * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
1873
- * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
1874
- * and the tree becomes invisible rather than wrong. The failure that follows is
1875
- * "fixture does not exist" on every spec at once, one step removed from the
1876
- * cause; this rule states the cause and the rename.
1877
- *
1878
- * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
1879
- * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
1880
- * nearest specs root. A DOMAIN folder named after the ground it holds
1881
- * (`specs/api/requests/_requests/`) is never a probe target — only what sits
1882
- * beside a spec is.
1883
- */
1884
- const c13UnderscoredGround = {
1885
- create(context) {
1886
- const file = context.physicalFilename;
1887
- const anchor = specsAnchor(file);
1888
- if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
1889
- return { Program(node) {
1890
- const seen = /* @__PURE__ */ new Set();
1891
- const probe = (parent, legacy) => {
1892
- const path = (0, node_path.join)(parent, legacy);
1893
- if (seen.has(path) || !isDirectory(path)) return;
1894
- seen.add(path);
1895
- context.report({
1896
- data: {
1897
- ground: RENAMED_GROUND_DIRS[legacy],
1898
- legacy
1899
- },
1900
- messageId: "notUnderscored",
1901
- node
1902
- });
1903
- };
1904
- for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe((0, node_path.dirname)(file), legacy);
1905
- probe(anchor.directory, "fixtures");
1906
- } };
1907
- },
1908
- meta: {
1909
- docs: RULE_DOCS["c13-underscored-ground"],
1910
- messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/10-linting.md)." },
1911
- type: "problem"
1912
- }
1913
- };
1914
- //#endregion
1915
- //#region src/lint/rules/c2-http-only-requests.ts
1916
- const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1917
- /**
1918
- * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1919
- * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1920
- * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1921
- * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1922
- * is reported on the test file.
1923
- */
1924
- const c2HttpOnlyRequests = {
1925
- create(context) {
1926
- const file = context.physicalFilename;
1927
- if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1928
- return { Program(node) {
1929
- const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_REQUESTS);
1930
- for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1931
- data: { entry },
1932
- messageId: "notHttp",
1933
- node
1934
- });
1935
- } };
1936
- },
1937
- meta: {
1938
- docs: RULE_DOCS["c2-http-only-requests"],
1939
- messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1940
- type: "problem"
1941
- }
1942
- };
1943
- //#endregion
1944
- //#region src/lint/rules/c4-contract-shape.ts
1945
- /** The only directories a `contracts/` root may hold — the provider carriers. */
1946
- const PROVIDERS = /* @__PURE__ */ new Set([
1947
- "anthropic",
1948
- "http",
1949
- "openai"
1950
- ]);
1951
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1952
- /** A file oxlint itself visits — the AST half of the rule reports on those. */
1953
- const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1954
- /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1955
- const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1956
- /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1957
- const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1958
- /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1959
- const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1960
- /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1961
- const RESPONSE_DATA_FILE = /\.response\.json$/;
1962
- /** The name a specifier/declaration exports under, when it is `default`. */
1963
- function exportsDefault(node) {
1964
- return (node.specifiers ?? []).some((specifier) => {
1965
- const exported = specifier.exported;
1966
- return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1967
- });
1968
- }
1969
- /** Is this node a `defineContract(...)` call? */
1970
- function isDefineContract(node) {
1971
- const callee = node.callee;
1972
- return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1973
- }
1974
- /** A default export that may legitimately produce a contract (value or factory). */
1975
- function producesContract(declaration) {
1976
- if (declaration === void 0) return false;
1977
- return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1978
- }
1979
- /**
1980
- * The provider a `request:` value implies, when the builder is written as
1981
- * `<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.
1982
1822
  */
1983
1823
  function requestProvider(request) {
1984
1824
  if (request?.type !== "CallExpression") return;
@@ -2006,7 +1846,7 @@ function checkLayout(context, node, contractsDir) {
2006
1846
  });
2007
1847
  continue;
2008
1848
  }
2009
- if (!PROVIDERS.has(entry)) {
1849
+ if (!PROVIDERS$1.has(entry)) {
2010
1850
  context.report({
2011
1851
  data: { entry },
2012
1852
  messageId: "badProviderDir",
@@ -2022,7 +1862,7 @@ function checkLayout(context, node, contractsDir) {
2022
1862
  messageId: "providerSubfolder",
2023
1863
  node
2024
1864
  });
2025
- 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({
2026
1866
  data: {
2027
1867
  child,
2028
1868
  provider: entry
@@ -2091,7 +1931,7 @@ function checkUnit(context, program, base, provider) {
2091
1931
  if (isDefineContract(node)) {
2092
1932
  const argument = node.arguments?.[0];
2093
1933
  const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
2094
- if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1934
+ if (declared !== void 0 && PROVIDERS$1.has(declared) && declared !== provider) context.report({
2095
1935
  data: {
2096
1936
  declared,
2097
1937
  provider
@@ -2133,7 +1973,7 @@ const c4ContractShape = {
2133
1973
  if (!parts.includes("specs")) return {};
2134
1974
  const contractsIndex = parts.lastIndexOf("contracts");
2135
1975
  if (contractsIndex === -1) {
2136
- if (!TEST_FILE$9.test(file)) return {};
1976
+ if (!TEST_FILE$11.test(file)) return {};
2137
1977
  return { Program(node) {
2138
1978
  checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
2139
1979
  } };
@@ -2144,8 +1984,8 @@ const c4ContractShape = {
2144
1984
  checkComposite(context, node, base);
2145
1985
  } };
2146
1986
  if (depth === 2) {
2147
- const provider = parts[contractsIndex + 1];
2148
- return PROVIDERS.has(provider) ? { Program(node) {
1987
+ const provider = parts[contractsIndex + 1] ?? "";
1988
+ return PROVIDERS$1.has(provider) ? { Program(node) {
2149
1989
  checkUnit(context, node, base, provider);
2150
1990
  } } : {};
2151
1991
  }
@@ -2160,25 +2000,25 @@ const c4ContractShape = {
2160
2000
  meta: {
2161
2001
  docs: RULE_DOCS["c4-contract-shape"],
2162
2002
  messages: {
2163
- badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/10-linting.md).",
2164
- badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/10-linting.md).",
2165
- missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
2166
- missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
2167
- notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
2168
- notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
2169
- providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
2170
- providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/10-linting.md).",
2171
- providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
2172
- rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/10-linting.md).",
2173
- rootFile: "Contract file \"{{base}}\" sits at the contracts/ root, which holds only *.contracts.ts facades — a unit contract belongs in http/, openai/ or anthropic/ (C4 — see docs/10-linting.md).",
2174
- tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
2003
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/13-linting.md).",
2004
+ badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/13-linting.md).",
2005
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/13-linting.md).",
2006
+ missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/13-linting.md).",
2007
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/13-linting.md).",
2008
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/13-linting.md).",
2009
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/13-linting.md).",
2010
+ providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/13-linting.md).",
2011
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/13-linting.md).",
2012
+ rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/13-linting.md).",
2013
+ rootFile: "Contract file \"{{base}}\" sits at the contracts/ root, which holds only *.contracts.ts facades — a unit contract belongs in http/, openai/ or anthropic/ (C4 — see docs/13-linting.md).",
2014
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/13-linting.md)."
2175
2015
  },
2176
2016
  type: "problem"
2177
2017
  }
2178
2018
  };
2179
2019
  //#endregion
2180
2020
  //#region src/lint/rules/c6-tomatch-extension.ts
2181
- const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/;
2021
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/u;
2182
2022
  /** Does the expect(…) subject chain contain a `.directory(…)` call? */
2183
2023
  function subjectIsDirectory(toMatchCallee) {
2184
2024
  const expectCall = toMatchCallee.object;
@@ -2203,7 +2043,7 @@ function subjectIsDirectory(toMatchCallee) {
2203
2043
  const c6ToMatchExtension = {
2204
2044
  create(context) {
2205
2045
  const file = context.physicalFilename;
2206
- if (!TEST_FILE$8.test(file) || !segments(file).includes("specs")) return {};
2046
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
2207
2047
  return { CallExpression(node) {
2208
2048
  const callee = node.callee;
2209
2049
  if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
@@ -2222,13 +2062,13 @@ const c6ToMatchExtension = {
2222
2062
  },
2223
2063
  meta: {
2224
2064
  docs: RULE_DOCS["c6-tomatch-extension"],
2225
- messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (_expected/<name>/) omit it (C6 — see docs/10-linting.md)." },
2065
+ messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (_expected/<name>/) omit it (C6 — see docs/13-linting.md)." },
2226
2066
  type: "problem"
2227
2067
  }
2228
2068
  };
2229
2069
  //#endregion
2230
2070
  //#region src/lint/rules/c7-seeds-sql-only.ts
2231
- const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2071
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/u;
2232
2072
  /**
2233
2073
  * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
2234
2074
  * nothing but `*.sql` fragments. File state goes through `.fixture()`; there
@@ -2238,7 +2078,7 @@ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2238
2078
  const c7SeedsSqlOnly = {
2239
2079
  create(context) {
2240
2080
  const file = context.physicalFilename;
2241
- if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2081
+ if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
2242
2082
  return { Program(node) {
2243
2083
  const seedsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_SEEDS);
2244
2084
  for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
@@ -2250,13 +2090,13 @@ const c7SeedsSqlOnly = {
2250
2090
  },
2251
2091
  meta: {
2252
2092
  docs: RULE_DOCS["c7-seeds-sql-only"],
2253
- messages: { notSql: "_seeds/{{entry}} is not a .sql file — _seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/10-linting.md)." },
2093
+ messages: { notSql: "_seeds/{{entry}} is not a .sql file — _seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/13-linting.md)." },
2254
2094
  type: "problem"
2255
2095
  }
2256
2096
  };
2257
2097
  //#endregion
2258
2098
  //#region src/lint/rules/c8-referenced-fixture-exists.ts
2259
- const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/;
2099
+ const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/u;
2260
2100
  /** The ground directory each fixture-referencing verb reads from. */
2261
2101
  const VERB_ROOTS = {
2262
2102
  request: GROUND_REQUESTS,
@@ -2289,7 +2129,7 @@ const c8ReferencedFixtureExists = {
2289
2129
  create(context) {
2290
2130
  const file = context.physicalFilename;
2291
2131
  const anchor = specsAnchor(file);
2292
- if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
2132
+ if (!TEST_FILE$8.test(file) || anchor === void 0) return {};
2293
2133
  const featureDir = (0, node_path.dirname)(file);
2294
2134
  return { CallExpression(node) {
2295
2135
  const callee = node.callee;
@@ -2299,7 +2139,7 @@ const c8ReferencedFixtureExists = {
2299
2139
  if (verb === "fixture") {
2300
2140
  const value = stringValue(args[0]);
2301
2141
  if (value === void 0 || !looksLikePath(value)) return;
2302
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
2142
+ const target = resolveFixture(value.replace(/\/+$/u, ""), featureDir, anchor.directory);
2303
2143
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
2304
2144
  data: {
2305
2145
  path: value,
@@ -2314,7 +2154,7 @@ const c8ReferencedFixtureExists = {
2314
2154
  if (root === void 0) return;
2315
2155
  const value = stringValue(args[0]);
2316
2156
  if (value === void 0 || !looksLikePath(value)) return;
2317
- const target = (0, node_path.join)(featureDir, root, value.replace(/\/+$/, ""));
2157
+ const target = (0, node_path.join)(featureDir, root, value.replace(/\/+$/u, ""));
2318
2158
  if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
2319
2159
  data: {
2320
2160
  path: `${root}/${value}`,
@@ -2327,236 +2167,147 @@ const c8ReferencedFixtureExists = {
2327
2167
  },
2328
2168
  meta: {
2329
2169
  docs: RULE_DOCS["c8-referenced-fixture-exists"],
2330
- messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its ground {{root}}/ root (C8 — see docs/10-linting.md). Create it or fix the reference." },
2170
+ messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its ground {{root}}/ root (C8 — see docs/13-linting.md). Create it or fix the reference." },
2331
2171
  type: "problem"
2332
2172
  }
2333
2173
  };
2334
2174
  //#endregion
2335
- //#region src/lint/rules/d12w-response-body-probe.ts
2336
- /** A test reading the response body this many times (or more) is a cluster. */
2337
- const DEFAULT_THRESHOLD = 3;
2338
- /** Is `node` the `x.response.body` member access itself? */
2339
- function isResponseBody(node) {
2340
- if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
2341
- const object = node.object;
2342
- return object !== void 0 && memberPropertyName(object) === "response";
2343
- }
2344
- /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
2345
- function unwrap(node) {
2346
- let current = node ?? void 0;
2347
- while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
2348
- else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
2349
- else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
2350
- else return current;
2351
- }
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;
2352
2178
  /**
2353
- * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
2354
- * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
2355
- * of raw body probes — reading `.response.body` (or a variable cast from it)
2356
- * `threshold`+ times in one test callback — should collapse into one golden:
2357
- * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
2358
- * (a legitimate scalpel: absence, a single-field delta, third-party output).
2359
- *
2360
- * A probe is a body-rooted read: a direct `.response.body` use, or a field
2361
- * access on the local binding a `const body = result.response.body as {…}`
2362
- * 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.
2363
2185
  */
2364
- const d12wResponseBodyProbe = {
2186
+ const c10ContractsBoundary = {
2365
2187
  create(context) {
2366
- if (!segments(context.filename).includes("specs")) return {};
2367
- const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
2368
- return { CallExpression(node) {
2369
- if (!isTestCallee(node.callee)) return;
2370
- const callback = findTestCallback(node.arguments ?? []);
2371
- if (callback === void 0) return;
2372
- const aliases = /* @__PURE__ */ new Set();
2373
- let responseBodyNodes = 0;
2374
- walk(callback, (inner) => {
2375
- if (isResponseBody(inner)) responseBodyNodes++;
2376
- if (inner.type === "VariableDeclarator") {
2377
- const init = unwrap(inner.init);
2378
- const id = inner.id;
2379
- if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
2380
- }
2381
- });
2382
- let aliasReads = 0;
2383
- walk(callback, (inner) => {
2384
- if (inner.type !== "MemberExpression") return;
2385
- const object = inner.object;
2386
- if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
2387
- });
2388
- const probes = responseBodyNodes - aliases.size + aliasReads;
2389
- if (probes >= threshold) context.report({
2390
- data: { count: String(probes) },
2391
- 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",
2392
2195
  node
2393
2196
  });
2394
- } };
2197
+ });
2395
2198
  },
2396
2199
  meta: {
2397
- docs: RULE_DOCS["d12w-response-body-probe"],
2398
- defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
2399
- messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (D12 — see docs/10-linting.md)." },
2400
- schema: [{
2401
- additionalProperties: false,
2402
- properties: { threshold: {
2403
- minimum: 1,
2404
- type: "integer"
2405
- } },
2406
- type: "object"
2407
- }],
2408
- 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"
2409
2203
  }
2410
2204
  };
2411
2205
  //#endregion
2412
- //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
2413
- /**
2414
- * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
2415
- * A frozen fixture opts out of update-mode rewriting, so it is exempt.
2416
- */
2417
- function hasFrozenOption(args) {
2418
- const options = args[1];
2419
- if (options === void 0 || options.type !== "ObjectExpression") return false;
2420
- const value = findProperty(options, "frozen")?.value;
2421
- 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`;
2422
2219
  }
2423
2220
  /**
2424
- * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
2425
- * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
2426
- * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
2427
- * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
2428
- * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
2429
- * expression: it stops at the first statement boundary or non-`expect` call, so
2430
- * 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`.
2431
2227
  */
2432
- const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
2433
- "ArrowFunctionExpression",
2434
- "AwaitExpression",
2435
- "ChainExpression",
2436
- "MemberExpression",
2437
- "ParenthesizedExpression",
2438
- "TSAsExpression",
2439
- "TSNonNullExpression"
2440
- ]);
2441
- function isWrappedInExpect(node) {
2442
- let current = node.parent;
2443
- while (current !== void 0) {
2444
- if (current.type === "CallExpression") {
2445
- const callee = current.callee;
2446
- return callee?.type === "Identifier" && callee.name === "expect";
2447
- }
2448
- if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
2449
- current = current.parent;
2450
- }
2451
- return false;
2452
- }
2453
- /**
2454
- * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
2455
- * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
2456
- * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
2457
- * unfrozen fixture is silently rewritten with the actual output — the matcher
2458
- * writes instead of throwing — which destroys the negative case (the assertion
2459
- * then no longer throws). Pass `{ frozen: true }` so the fixture is never
2460
- * rewritten and the mismatch/error still throws in update mode.
2461
- *
2462
- * The structural signal is precise for the wrapped forms; a `toMatch` routed
2463
- * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
2464
- * is out of static reach — see the D13 process note for that residue.
2465
- */
2466
- const d13wUnfrozenNegativeFixture = {
2228
+ const c11ContractDataPairing = {
2467
2229
  create(context) {
2468
- if (!segments(context.filename).includes("specs")) return {};
2469
- return { CallExpression(node) {
2470
- const callee = node.callee;
2471
- if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
2472
- if (!isWrappedInExpect(node)) return;
2473
- if (hasFrozenOption(node.arguments ?? [])) return;
2474
- context.report({
2475
- messageId: "unfrozenNegativeFixture",
2476
- node
2477
- });
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
+ }
2478
2252
  } };
2479
2253
  },
2480
2254
  meta: {
2481
- docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
2482
- 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/10-linting.md)." },
2483
- 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"
2484
2258
  }
2485
2259
  };
2486
2260
  //#endregion
2487
- //#region src/lint/rules/d15w-status-only-probe.ts
2488
- /** An HTTP status code lives in this range — the numeric-literal gate. */
2489
- const MIN_STATUS = 100;
2490
- const MAX_STATUS = 599;
2491
- /** Matchers that pin a single value — the shape of a status probe. */
2492
- const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
2493
- /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
2494
- function isExpectCall(node) {
2495
- if (node?.type !== "CallExpression") return false;
2496
- const callee = node.callee;
2497
- return callee?.type === "Identifier" && callee.name === "expect";
2498
- }
2499
- /** Is `node` a numeric literal in the HTTP status range (100–599)? */
2500
- function isStatusLiteral(node) {
2501
- if (node?.type !== "Literal" || typeof node.value !== "number") return false;
2502
- const value = node.value;
2503
- return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
2504
- }
2505
- /**
2506
- * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
2507
- * `X.status` is any `.status` member access and `N` is a numeric literal in the
2508
- * HTTP status range? The numeric-literal gate keeps container-status STRINGS
2509
- * (`expect(neo.status).toBe('running')`) out.
2510
- */
2511
- function isStatusProbe(node) {
2512
- if (node.type !== "CallExpression") return false;
2513
- const callee = node.callee;
2514
- if (callee?.type !== "MemberExpression") return false;
2515
- const matcher = memberPropertyName(callee);
2516
- if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
2517
- const expectCall = callee.object;
2518
- if (!isExpectCall(expectCall)) return false;
2519
- const subject = (expectCall?.arguments ?? [])[0];
2520
- if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
2521
- const expected = (node.arguments ?? [])[0];
2522
- return isStatusLiteral(expected);
2523
- }
2261
+ //#region src/lint/rules/c13-underscored-ground.ts
2262
+ const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/u;
2524
2263
  /**
2525
- * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
2526
- * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
2527
- * 100–599) throws away the full-response golden. Pin the whole response instead:
2528
- * `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.
2529
2269
  *
2530
- * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
2531
- * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
2532
- * threshold yet still hides everything the response carries. The rule fires only
2533
- * when EVERY `expect(…)` in the callback is a status probe — a status probe
2534
- * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
2535
- * 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.
2536
2282
  */
2537
- const d15wStatusOnlyProbe = {
2283
+ const c13UnderscoredGround = {
2538
2284
  create(context) {
2539
- if (!segments(context.filename).includes("specs")) return {};
2540
- return { CallExpression(node) {
2541
- if (!isTestCallee(node.callee)) return;
2542
- const callback = findTestCallback(node.arguments ?? []);
2543
- if (callback === void 0) return;
2544
- let expectCalls = 0;
2545
- let statusProbes = 0;
2546
- walk(callback, (inner) => {
2547
- if (isExpectCall(inner)) expectCalls++;
2548
- if (isStatusProbe(inner)) statusProbes++;
2549
- });
2550
- if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
2551
- messageId: "statusOnlyProbe",
2552
- node
2553
- });
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");
2554
2305
  } };
2555
2306
  },
2556
2307
  meta: {
2557
- docs: RULE_DOCS["d15w-status-only-probe"],
2558
- messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (D11 — see docs/10-linting.md)." },
2559
- 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"
2560
2311
  }
2561
2312
  };
2562
2313
  //#endregion
@@ -2610,7 +2361,7 @@ const d2AwaitIoMatcher = {
2610
2361
  },
2611
2362
  meta: {
2612
2363
  docs: RULE_DOCS["d2-await-io-matcher"],
2613
- messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (D2 — see docs/10-linting.md)." },
2364
+ messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (D2 — see docs/13-linting.md)." },
2614
2365
  type: "problem"
2615
2366
  }
2616
2367
  };
@@ -2664,7 +2415,7 @@ const d2wAwaitSyncMatcher = {
2664
2415
  },
2665
2416
  meta: {
2666
2417
  docs: RULE_DOCS["d2w-await-sync-matcher"],
2667
- messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (D2 — see docs/10-linting.md)." },
2418
+ messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (D2 — see docs/13-linting.md)." },
2668
2419
  type: "suggestion"
2669
2420
  }
2670
2421
  };
@@ -2692,7 +2443,7 @@ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
2692
2443
  "url",
2693
2444
  "uuid",
2694
2445
  "workdir"
2695
- ].join("|")})(?:#[\\w.-]+)?\\}\\}$`);
2446
+ ].join("|")})(?:#[\\w.-]+)?\\}\\}$`, "u");
2696
2447
  /**
2697
2448
  * Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
2698
2449
  * Accepts an arrow with an expression body or a single-return body whose
@@ -2704,8 +2455,9 @@ function isTokenEquivalentTransform(fn) {
2704
2455
  let expression = fn.body;
2705
2456
  if (expression?.type === "BlockStatement") {
2706
2457
  const statements = expression.body ?? [];
2707
- if (statements.length !== 1 || statements[0].type !== "ReturnStatement") return false;
2708
- expression = statements[0].argument;
2458
+ const [only] = statements;
2459
+ if (statements.length !== 1 || only?.type !== "ReturnStatement") return false;
2460
+ expression = only.argument;
2709
2461
  }
2710
2462
  let sawReplace = false;
2711
2463
  while (expression?.type === "CallExpression") {
@@ -2741,124 +2493,352 @@ const d6wTransformTokenEquivalent = {
2741
2493
  },
2742
2494
  meta: {
2743
2495
  docs: RULE_DOCS["d6w-transform-token-equivalent"],
2744
- messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the _expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/10-linting.md)." },
2496
+ messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the _expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/13-linting.md)." },
2745
2497
  type: "suggestion"
2746
2498
  }
2747
- };
2748
- //#endregion
2749
- //#region src/lint/rules/d8w-text-bypass.ts
2750
- /** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
2751
- const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
2499
+ };
2500
+ //#endregion
2501
+ //#region src/lint/rules/d8w-text-bypass.ts
2502
+ /** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
2503
+ const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
2504
+ /**
2505
+ * CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
2506
+ * `expect(result.text).toContain(…)` throws away the typed subject (its token
2507
+ * grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
2508
+ * Prefer asserting on the accessor subject itself.
2509
+ */
2510
+ const d8wTextBypass = {
2511
+ create(context) {
2512
+ return { CallExpression(node) {
2513
+ const callee = node.callee;
2514
+ const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
2515
+ if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
2516
+ const expectCall = callee?.object;
2517
+ if (expectCall?.type !== "CallExpression") return;
2518
+ const expectCallee = expectCall.callee;
2519
+ if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
2520
+ const subject = expectCall.arguments?.[0];
2521
+ if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
2522
+ data: { matcher },
2523
+ messageId: "textBypass",
2524
+ node
2525
+ });
2526
+ } };
2527
+ },
2528
+ meta: {
2529
+ docs: RULE_DOCS["d8w-text-bypass"],
2530
+ messages: { textBypass: "expect(x.text).{{matcher}}(…) asserts on the raw stream — prefer the typed accessor subject (expect(x)) so the token grammar and fixture resolution apply (D8 — see docs/13-linting.md)." },
2531
+ type: "suggestion"
2532
+ }
2533
+ };
2534
+ //#endregion
2535
+ //#region src/lint/rules/d9w-single-use-ref.ts
2536
+ const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/u;
2537
+ /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2538
+ const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/gu;
2539
+ /** Is this call `match.ref(...)`? */
2540
+ function isMatchRef(node) {
2541
+ const callee = node.callee;
2542
+ if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
2543
+ const object = callee.object;
2544
+ return object?.type === "Identifier" && object.name === "match";
2545
+ }
2546
+ /** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
2547
+ function refsInFixture(target) {
2548
+ const refs = [];
2549
+ const collect = (text) => {
2550
+ for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
2551
+ };
2552
+ if (isFile(target)) {
2553
+ const text = readFileCached(target);
2554
+ if (text !== null) collect(text);
2555
+ return refs;
2556
+ }
2557
+ if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture((0, node_path.join)(target, entry)));
2558
+ return refs;
2559
+ }
2560
+ /**
2561
+ * CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
2562
+ * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2563
+ * point of a capture is the equality check on later occurrences). Aggregating
2564
+ * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2565
+ * `_expected/` fixtures the file references — a ref that appears exactly once
2566
+ * everywhere is a plain matcher wearing a name.
2567
+ */
2568
+ const d9wSingleUseRef = {
2569
+ create(context) {
2570
+ const file = context.physicalFilename;
2571
+ if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
2572
+ const featureDir = (0, node_path.dirname)(file);
2573
+ return { Program(program) {
2574
+ const counts = /* @__PURE__ */ new Map();
2575
+ const codeNodes = /* @__PURE__ */ new Map();
2576
+ const fixtureNodes = /* @__PURE__ */ new Map();
2577
+ const bump = (ref) => {
2578
+ counts.set(ref, (counts.get(ref) ?? 0) + 1);
2579
+ };
2580
+ walk(program, (node) => {
2581
+ if (node.type !== "CallExpression") return;
2582
+ if (isMatchRef(node)) {
2583
+ const ref = stringValue(node.arguments?.[0]);
2584
+ if (ref !== void 0) {
2585
+ bump(ref);
2586
+ if (!codeNodes.has(ref)) codeNodes.set(ref, node);
2587
+ }
2588
+ return;
2589
+ }
2590
+ if (memberPropertyName(node.callee) === "toMatch") {
2591
+ const name = stringValue(node.arguments?.[0]);
2592
+ if (name !== void 0) {
2593
+ const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/u, ""));
2594
+ for (const ref of refsInFixture(target)) {
2595
+ bump(ref);
2596
+ if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
2597
+ }
2598
+ }
2599
+ }
2600
+ });
2601
+ for (const [ref, count] of counts) {
2602
+ const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
2603
+ if (count === 1 && node !== void 0) context.report({
2604
+ data: { ref },
2605
+ messageId: "singleUse",
2606
+ node
2607
+ });
2608
+ }
2609
+ } };
2610
+ },
2611
+ meta: {
2612
+ docs: RULE_DOCS["d9w-single-use-ref"],
2613
+ messages: { singleUse: "Capture ref \"{{ref}}\" is used only once in this spec — a ref earns its name by asserting equality across occurrences; use a plain matcher instead (D9 — see docs/13-linting.md)." },
2614
+ type: "suggestion"
2615
+ }
2616
+ };
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
+ }
2752
2736
  /**
2753
- * CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
2754
- * `expect(result.text).toContain(…)` throws away the typed subject (its token
2755
- * grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
2756
- * Prefer asserting on the accessor subject itself.
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.
2757
2748
  */
2758
- const d8wTextBypass = {
2749
+ const d13wUnfrozenNegativeFixture = {
2759
2750
  create(context) {
2751
+ if (!segments(context.filename).includes("specs")) return {};
2760
2752
  return { CallExpression(node) {
2761
2753
  const callee = node.callee;
2762
- const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
2763
- if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
2764
- const expectCall = callee?.object;
2765
- if (expectCall?.type !== "CallExpression") return;
2766
- const expectCallee = expectCall.callee;
2767
- if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
2768
- const subject = expectCall.arguments?.[0];
2769
- if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
2770
- data: { matcher },
2771
- messageId: "textBypass",
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",
2772
2759
  node
2773
2760
  });
2774
2761
  } };
2775
2762
  },
2776
2763
  meta: {
2777
- docs: RULE_DOCS["d8w-text-bypass"],
2778
- messages: { textBypass: "expect(x.text).{{matcher}}(…) asserts on the raw stream — prefer the typed accessor subject (expect(x)) so the token grammar and fixture resolution apply (D8 — see docs/10-linting.md)." },
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)." },
2779
2766
  type: "suggestion"
2780
2767
  }
2781
2768
  };
2782
2769
  //#endregion
2783
- //#region src/lint/rules/d9w-single-use-ref.ts
2784
- const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/;
2785
- /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2786
- const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/g;
2787
- /** Is this call `match.ref(...)`? */
2788
- function isMatchRef(node) {
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;
2789
2779
  const callee = node.callee;
2790
- if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
2791
- const object = callee.object;
2792
- return object?.type === "Identifier" && object.name === "match";
2780
+ return callee?.type === "Identifier" && callee.name === "expect";
2793
2781
  }
2794
- /** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
2795
- function refsInFixture(target) {
2796
- const refs = [];
2797
- const collect = (text) => {
2798
- for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
2799
- };
2800
- if (isFile(target)) {
2801
- const text = readFileCached(target);
2802
- if (text !== null) collect(text);
2803
- return refs;
2804
- }
2805
- if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture((0, node_path.join)(target, entry)));
2806
- return refs;
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;
2807
2787
  }
2808
2788
  /**
2809
- * CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
2810
- * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2811
- * point of a capture is the equality check on later occurrences). Aggregating
2812
- * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2813
- * `_expected/` fixtures the file references — a ref that appears exactly once
2814
- * everywhere is a plain matcher wearing a name.
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.
2815
2793
  */
2816
- const d9wSingleUseRef = {
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 = {
2817
2821
  create(context) {
2818
- const file = context.physicalFilename;
2819
- if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
2820
- const featureDir = (0, node_path.dirname)(file);
2821
- return { Program(program) {
2822
- const counts = /* @__PURE__ */ new Map();
2823
- const codeNodes = /* @__PURE__ */ new Map();
2824
- const fixtureNodes = /* @__PURE__ */ new Map();
2825
- const bump = (ref) => {
2826
- counts.set(ref, (counts.get(ref) ?? 0) + 1);
2827
- };
2828
- walk(program, (node) => {
2829
- if (node.type !== "CallExpression") return;
2830
- if (isMatchRef(node)) {
2831
- const ref = stringValue(node.arguments?.[0]);
2832
- if (ref !== void 0) {
2833
- bump(ref);
2834
- if (!codeNodes.has(ref)) codeNodes.set(ref, node);
2835
- }
2836
- return;
2837
- }
2838
- if (memberPropertyName(node.callee) === "toMatch") {
2839
- const name = stringValue(node.arguments?.[0]);
2840
- if (name !== void 0) {
2841
- const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/, ""));
2842
- for (const ref of refsInFixture(target)) {
2843
- bump(ref);
2844
- if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
2845
- }
2846
- }
2847
- }
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
2848
2836
  });
2849
- for (const [ref, count] of counts) {
2850
- const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
2851
- if (count === 1 && node !== void 0) context.report({
2852
- data: { ref },
2853
- messageId: "singleUse",
2854
- node
2855
- });
2856
- }
2857
2837
  } };
2858
2838
  },
2859
2839
  meta: {
2860
- docs: RULE_DOCS["d9w-single-use-ref"],
2861
- messages: { singleUse: "Capture ref \"{{ref}}\" is used only once in this spec — a ref earns its name by asserting equality across occurrences; use a plain matcher instead (D9 — see docs/10-linting.md)." },
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)." },
2862
2842
  type: "suggestion"
2863
2843
  }
2864
2844
  };
@@ -2916,7 +2896,7 @@ function findOwnManifest(startDir) {
2916
2896
  */
2917
2897
  function declaredSubpaths(startDir) {
2918
2898
  if (startDir === void 0 && cached !== null) return cached;
2919
- 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;
2920
2900
  const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2921
2901
  if (startDir === void 0) cached = subpaths;
2922
2902
  return subpaths;
@@ -2952,15 +2932,15 @@ const f1NoSubpathImport = {
2952
2932
  },
2953
2933
  meta: {
2954
2934
  docs: RULE_DOCS["f1-no-subpath-import"],
2955
- messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2935
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/13-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2956
2936
  type: "problem"
2957
2937
  }
2958
2938
  };
2959
2939
  //#endregion
2960
2940
  //#region src/lint/rules/f2-no-test-imports-in-prod.ts
2961
- const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/;
2962
- const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/;
2963
- 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;
2964
2944
  const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2965
2945
  /**
2966
2946
  * CONVENTIONS F2 — production code never imports a test artefact. In a
@@ -3000,7 +2980,7 @@ const f2NoTestImportsInProd = {
3000
2980
  },
3001
2981
  meta: {
3002
2982
  docs: RULE_DOCS["f2-no-test-imports-in-prod"],
3003
- messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (F2 — see docs/10-linting.md)." },
2983
+ messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (F2 — see docs/13-linting.md)." },
3004
2984
  type: "problem"
3005
2985
  }
3006
2986
  };
@@ -3017,15 +2997,15 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
3017
2997
  * `server: () => createApp()` importing `../../src/app.js`).
3018
2998
  */
3019
2999
  const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
3020
- "core",
3021
3000
  "integrations",
3022
3001
  "lint",
3002
+ "specification",
3023
3003
  "vitest"
3024
3004
  ]);
3025
3005
  /**
3026
3006
  * CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
3027
3007
  * forbidden: a relative path resolving inside the framework repo's
3028
- * `src/{core,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3008
+ * `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3029
3009
  * other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
3030
3010
  * imports of its OWN app source are always allowed — that is the pattern.
3031
3011
  *
@@ -3051,7 +3031,7 @@ const f3SpecsPublicEntry = {
3051
3031
  }
3052
3032
  const marker = source.indexOf("/src/");
3053
3033
  if (marker === -1 && !source.startsWith("src/")) return;
3054
- 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] ?? "";
3055
3035
  if (!FRAMEWORK_LAYERS.has(layer)) return;
3056
3036
  if (underIntegrations && layer === "integrations") return;
3057
3037
  context.report({
@@ -3063,14 +3043,14 @@ const f3SpecsPublicEntry = {
3063
3043
  },
3064
3044
  meta: {
3065
3045
  docs: RULE_DOCS["f3-specs-public-entry"],
3066
- messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/10-linting.md; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
3046
+ messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
3067
3047
  type: "problem"
3068
3048
  }
3069
3049
  };
3070
3050
  //#endregion
3071
3051
  //#region src/lint/rules/f4-no-test-to-test-import.ts
3072
- const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/;
3073
- 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;
3074
3054
  /**
3075
3055
  * CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
3076
3056
  * test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
@@ -3089,14 +3069,14 @@ const f4NoTestToTestImport = {
3089
3069
  },
3090
3070
  meta: {
3091
3071
  docs: RULE_DOCS["f4-no-test-to-test-import"],
3092
- messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (F4 — see docs/10-linting.md)." },
3072
+ messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (F4 — see docs/13-linting.md)." },
3093
3073
  type: "problem"
3094
3074
  }
3095
3075
  };
3096
3076
  //#endregion
3097
3077
  //#region src/lint/rules/f5-fixtures-only-from-tests.ts
3098
- const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/;
3099
- 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;
3100
3080
  /**
3101
3081
  * CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
3102
3082
  * `*.test.ts` files. Any other importer (prod code, a specification file,
@@ -3115,13 +3095,13 @@ const f5FixturesOnlyFromTests = {
3115
3095
  },
3116
3096
  meta: {
3117
3097
  docs: RULE_DOCS["f5-fixtures-only-from-tests"],
3118
- messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/10-linting.md)." },
3098
+ messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/13-linting.md)." },
3119
3099
  type: "problem"
3120
3100
  }
3121
3101
  };
3122
3102
  //#endregion
3123
3103
  //#region src/lint/rules/i1-layer-boundaries.ts
3124
- const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
3104
+ const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
3125
3105
  function matchesPackage(source, packages) {
3126
3106
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
3127
3107
  }
@@ -3133,7 +3113,7 @@ function pathInsideSrc(path) {
3133
3113
  }
3134
3114
  /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
3135
3115
  function withoutExtension(path) {
3136
- return path.replace(/\.[cm]?[jt]sx?$/, "");
3116
+ return path.replace(/\.[cm]?[jt]sx?$/u, "");
3137
3117
  }
3138
3118
  /** Does `target` match one of the declared paths (prefix, or exact module)? */
3139
3119
  function matchesPath(target, patterns) {
@@ -3207,8 +3187,8 @@ const i1LayerBoundaries = {
3207
3187
  defaultOptions: [{ layers: {} }],
3208
3188
  docs: RULE_DOCS["i1-layer-boundaries"],
3209
3189
  messages: {
3210
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/10-linting.md).",
3211
- foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/10-linting.md)."
3190
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/13-linting.md).",
3191
+ foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/13-linting.md)."
3212
3192
  },
3213
3193
  schema: [{
3214
3194
  additionalProperties: false,
@@ -3250,7 +3230,7 @@ const i1LayerBoundaries = {
3250
3230
  };
3251
3231
  //#endregion
3252
3232
  //#region src/lint/rules/i2-sibling-test-naming.ts
3253
- const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/;
3233
+ const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
3254
3234
  /**
3255
3235
  * CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
3256
3236
  * `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
@@ -3278,7 +3258,7 @@ const i2SiblingTestNaming = {
3278
3258
  ".js"
3279
3259
  ].map((extension) => file.replace(TEST_FILE$1, extension));
3280
3260
  if (!neighbours.some(isFile)) context.report({
3281
- data: { expected: neighbours[0].split(/[/\\]/).at(-1) ?? "" },
3261
+ data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
3282
3262
  messageId: "orphanTest",
3283
3263
  node
3284
3264
  });
@@ -3296,16 +3276,16 @@ const i2SiblingTestNaming = {
3296
3276
  meta: {
3297
3277
  docs: RULE_DOCS["i2-sibling-test-naming"],
3298
3278
  messages: {
3299
- orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/10-linting.md). A test needing more than its module is a specification: move it to specs/.",
3300
- rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/10-linting.md).",
3301
- testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/10-linting.md)."
3279
+ orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A test needing more than its module is a specification: move it to specs/.",
3280
+ rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/13-linting.md).",
3281
+ testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/13-linting.md)."
3302
3282
  },
3303
3283
  type: "problem"
3304
3284
  }
3305
3285
  };
3306
3286
  //#endregion
3307
3287
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
3308
- const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
3288
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
3309
3289
  const DATA_ASSET = new RegExp(`\\.(?:${[
3310
3290
  "avif",
3311
3291
  "bin",
@@ -3343,10 +3323,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
3343
3323
  "yaml",
3344
3324
  "yml",
3345
3325
  "zip"
3346
- ].join("|")})$`, "i");
3326
+ ].join("|")})$`, "iu");
3347
3327
  /** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
3348
3328
  function withoutSuffix(source) {
3349
- return source.replace(/[?#].*$/, "");
3329
+ return source.replace(/[?#].*$/u, "");
3350
3330
  }
3351
3331
  /**
3352
3332
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
@@ -3398,57 +3378,14 @@ const i4NoViMockInSrc = {
3398
3378
  meta: {
3399
3379
  docs: RULE_DOCS["i4-no-vi-mock-in-src"],
3400
3380
  messages: {
3401
- assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (I4 — see docs/10-linting.md).",
3402
- bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (I4 — see docs/10-linting.md).",
3403
- viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/10-linting.md)."
3381
+ assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (I4 — see docs/13-linting.md).",
3382
+ bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (I4 — see docs/13-linting.md).",
3383
+ viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/13-linting.md)."
3404
3384
  },
3405
3385
  type: "problem"
3406
3386
  }
3407
3387
  };
3408
3388
  //#endregion
3409
- //#region src/lint/rules/j1-no-only-skip.ts
3410
- /** Test runners whose `.only` / `.skip` modifiers must never be committed. */
3411
- const RUNNERS = /* @__PURE__ */ new Set([
3412
- "describe",
3413
- "it",
3414
- "test"
3415
- ]);
3416
- /** The focus/skip modifiers CONVENTIONS J1 forbids. */
3417
- const FORBIDDEN = /* @__PURE__ */ new Set(["only", "skip"]);
3418
- /**
3419
- * CONVENTIONS J1 — no committed `.only` / `.skip`.
3420
- *
3421
- * Flags the member form used across the specs: `describe.only`, `test.only`,
3422
- * `it.only` and their `.skip` counterparts. Modifiers that legitimately gate a
3423
- * test (`test.skipIf`, `test.runIf`) are untouched — only the bare `only`/`skip`
3424
- * that pin or disable a test in a commit are reported.
3425
- */
3426
- const j1NoOnlySkip = {
3427
- create(context) {
3428
- return { MemberExpression(node) {
3429
- if (node.computed === true) return;
3430
- const object = node.object;
3431
- const property = node.property;
3432
- if (object?.type !== "Identifier" || property?.type !== "Identifier") return;
3433
- const runner = object.name;
3434
- const modifier = property.name;
3435
- if (RUNNERS.has(runner) && FORBIDDEN.has(modifier)) context.report({
3436
- data: {
3437
- modifier,
3438
- runner
3439
- },
3440
- messageId: "forbidden",
3441
- node
3442
- });
3443
- } };
3444
- },
3445
- meta: {
3446
- docs: RULE_DOCS["j1-no-only-skip"],
3447
- messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (J1 — see docs/10-linting.md). Remove it before committing." },
3448
- type: "problem"
3449
- }
3450
- };
3451
- //#endregion
3452
3389
  //#region src/lint/rules/j2-no-sleep-in-specs.ts
3453
3390
  /**
3454
3391
  * CONVENTIONS J2 — spec files (`specs/**`) contain no arbitrary sleeps:
@@ -3487,138 +3424,13 @@ const j2NoSleepInSpecs = {
3487
3424
  meta: {
3488
3425
  docs: RULE_DOCS["j2-no-sleep-in-specs"],
3489
3426
  messages: {
3490
- sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md).",
3491
- timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md)."
3427
+ sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md).",
3428
+ timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md)."
3492
3429
  },
3493
3430
  type: "problem"
3494
3431
  }
3495
3432
  };
3496
3433
  //#endregion
3497
- //#region src/lint/rules/j3-no-expectless-test.ts
3498
- /** Does `callback` contain at least one `expect(…)` call? */
3499
- function hasExpect(callback) {
3500
- let found = false;
3501
- walk(callback, (node) => {
3502
- if (found || node.type !== "CallExpression") return;
3503
- const callee = node.callee;
3504
- if (callee?.type === "Identifier" && callee.name === "expect") found = true;
3505
- });
3506
- return found;
3507
- }
3508
- /**
3509
- * CONVENTIONS J3 — a test asserts something. A `test('…', () => { … })` whose
3510
- * body contains no `expect(…)` is either dead or a silent no-op.
3511
- *
3512
- * Reuses the shared B4 test-shape helpers. `test.todo(...)` (and any other
3513
- * callback-less form) is skipped — there is nothing to assert yet.
3514
- */
3515
- const j3NoExpectlessTest = {
3516
- create(context) {
3517
- return { CallExpression(node) {
3518
- const callee = node.callee;
3519
- if (!isTestCallee(callee)) return;
3520
- if (testCalleeHasModifier(callee, "todo")) return;
3521
- const callback = findTestCallback(node.arguments ?? []);
3522
- if (callback === void 0) return;
3523
- if (!hasExpect(callback)) context.report({
3524
- messageId: "noExpect",
3525
- node
3526
- });
3527
- } };
3528
- },
3529
- meta: {
3530
- docs: RULE_DOCS["j3-no-expectless-test"],
3531
- messages: { noExpect: "Test has no `expect(…)` — a test must assert something (J3 — see docs/10-linting.md). Use `test.todo` for a pending test." },
3532
- type: "problem"
3533
- }
3534
- };
3535
- //#endregion
3536
- //#region src/lint/rules/j4-unique-test-names.ts
3537
- /**
3538
- * CONVENTIONS J4 — test names are the sole description of behaviour (B3), so two
3539
- * tests sharing a literal name in one file are a copy-paste smell: one shadows
3540
- * the other in the reporter. Flags the second (and later) occurrence.
3541
- *
3542
- * `.each` / parametrized tests are skipped — their name is a template expanded
3543
- * per row, collisions there are by construction.
3544
- */
3545
- const j4UniqueTestNames = {
3546
- create(context) {
3547
- const seen = /* @__PURE__ */ new Set();
3548
- return { CallExpression(node) {
3549
- const callee = node.callee;
3550
- if (!isTestCallee(callee)) return;
3551
- if (testCalleeHasModifier(callee, "each")) return;
3552
- const name = testName(node);
3553
- if (name === void 0) return;
3554
- if (seen.has(name)) {
3555
- context.report({
3556
- data: { name },
3557
- messageId: "duplicate",
3558
- node
3559
- });
3560
- return;
3561
- }
3562
- seen.add(name);
3563
- } };
3564
- },
3565
- meta: {
3566
- docs: RULE_DOCS["j4-unique-test-names"],
3567
- messages: { duplicate: "Duplicate test name \"{{name}}\" in this file — the test name is the sole description of behaviour, so it must be unique (J4 — see docs/10-linting.md)." },
3568
- type: "problem"
3569
- }
3570
- };
3571
- //#endregion
3572
- //#region src/lint/rules/j5-lowercase-title.ts
3573
- /** The block/test introducers whose title literal C1' constrains. */
3574
- const TITLED = /* @__PURE__ */ new Set([
3575
- "describe",
3576
- "it",
3577
- "test"
3578
- ]);
3579
- /** An identifier-shaped, all-caps/underscored first word: `VALID_CATEGORIES`,
3580
- * `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
3581
- * misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
3582
- const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
3583
- /**
3584
- * CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
3585
- * literal must be lowercase.
3586
- *
3587
- * The test name is the sole description of behaviour (B3): a sentence fragment,
3588
- * lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
3589
- * whose first WORD is an identifier-shaped all-caps/underscored token
3590
- * (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
3591
- * lowercasing a named symbol would misspell it — and titles opening on a
3592
- * non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
3593
- * first words are enforced. Covers the modifier and parametrized forms
3594
- * (`test.only`, `describe.each(...)(...)`) via the chain root.
3595
- */
3596
- const j5LowercaseTitle = {
3597
- create(context) {
3598
- return { CallExpression(node) {
3599
- const callee = node.callee;
3600
- const root = chainRootName(callee);
3601
- if (root === void 0 || !TITLED.has(root)) return;
3602
- const title = stringValue(node.arguments?.[0]);
3603
- if (title === void 0) return;
3604
- const first = [...title][0];
3605
- if (first === void 0 || !/\p{L}/u.test(first)) return;
3606
- const firstWord = title.split(/\s+/u)[0];
3607
- if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
3608
- if (first !== first.toLocaleLowerCase()) context.report({
3609
- data: { runner: root },
3610
- messageId: "uppercase",
3611
- node
3612
- });
3613
- } };
3614
- },
3615
- meta: {
3616
- docs: RULE_DOCS["j5-lowercase-title"],
3617
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
3618
- type: "problem"
3619
- }
3620
- };
3621
- //#endregion
3622
3434
  //#region src/lint/rules/w1-scenario-pure.ts
3623
3435
  /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3624
3436
  const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
@@ -3648,7 +3460,7 @@ const w1ScenarioPure = {
3648
3460
  },
3649
3461
  meta: {
3650
3462
  docs: RULE_DOCS["w1-scenario-pure"],
3651
- messages: { expectInScenario: "No expect() inside a scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
3463
+ messages: { expectInScenario: "No expect() inside a scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/13-linting.md)." },
3652
3464
  type: "problem"
3653
3465
  }
3654
3466
  };
@@ -3664,7 +3476,7 @@ const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
3664
3476
  *
3665
3477
  * Registered in a consumer's (or this repo's own) `oxlint.config.ts` via
3666
3478
  * `jsPlugins: ['@jterrazz/test/oxlint']` and referenced as `jterrazz/<rule>` in
3667
- * the `rules` map, e.g. `'jterrazz/j1-no-only-skip': 'error'` — or enabled
3479
+ * the `rules` map, e.g. `'jterrazz/j2-no-sleep-in-specs': 'error'` — or enabled
3668
3480
  * wholesale by spreading {@link recommendedRules}.
3669
3481
  *
3670
3482
  * This entry is bundled by tsdown (`dist/oxlint.js`); rules import nothing from
@@ -3714,11 +3526,7 @@ const plugin = {
3714
3526
  "i1-layer-boundaries": i1LayerBoundaries,
3715
3527
  "i2-sibling-test-naming": i2SiblingTestNaming,
3716
3528
  "i4-no-vi-mock-in-src": i4NoViMockInSrc,
3717
- "j1-no-only-skip": j1NoOnlySkip,
3718
3529
  "j2-no-sleep-in-specs": j2NoSleepInSpecs,
3719
- "j3-no-expectless-test": j3NoExpectlessTest,
3720
- "j4-unique-test-names": j4UniqueTestNames,
3721
- "j5-lowercase-title": j5LowercaseTitle,
3722
3530
  "w1-scenario-pure": w1ScenarioPure,
3723
3531
  "w2w-user-facing-elements": {
3724
3532
  create(context) {
@@ -3740,7 +3548,7 @@ const plugin = {
3740
3548
  },
3741
3549
  meta: {
3742
3550
  docs: RULE_DOCS["w2w-user-facing-elements"],
3743
- messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/10-linting.md)." },
3551
+ messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/13-linting.md)." },
3744
3552
  type: "suggestion"
3745
3553
  }
3746
3554
  }
@@ -3758,7 +3566,7 @@ const plugin = {
3758
3566
  *
3759
3567
  * 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
3760
3568
  */
3761
- 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"]));
3762
3570
  /**
3763
3571
  * The composable testing fragment — wire the plugin, enable the whole
3764
3572
  * catalogue, and ship the one override every strict consumer needs. Designed