@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.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { basename, dirname, join, resolve } from "node:path";
2
2
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
- import { fileURLToPath } from "node:url";
4
3
  //#region src/lint/fs-cache.ts
5
4
  /**
6
5
  * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
@@ -66,7 +65,7 @@ function readFileCached(path) {
66
65
  */
67
66
  /** Split a path into its non-empty segments (posix or win separators). */
68
67
  function segments(path) {
69
- return path.split(/[/\\]/).filter(Boolean);
68
+ return path.split(/[/\\]/u).filter(Boolean);
70
69
  }
71
70
  /**
72
71
  * The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
@@ -108,7 +107,8 @@ function stringValue(node) {
108
107
  if (node.type === "TemplateLiteral") {
109
108
  const expressions = node.expressions;
110
109
  const quasis = node.quasis;
111
- if (expressions?.length === 0 && quasis?.length === 1) return quasis[0].value?.cooked;
110
+ const onlyQuasi = quasis?.[0];
111
+ if (expressions?.length === 0 && quasis?.length === 1 && onlyQuasi !== void 0) return onlyQuasi.value?.cooked;
112
112
  }
113
113
  }
114
114
  /** The property name of a non-computed member expression, if identifiable. */
@@ -194,21 +194,10 @@ function isTestCallee(callee) {
194
194
  if (callee.type === "CallExpression") return isTestCallee(callee.callee);
195
195
  return false;
196
196
  }
197
- /** Does the test callee chain carry a modifier member with the given name? */
198
- function testCalleeHasModifier(callee, modifier) {
199
- if (callee === void 0) return false;
200
- if (callee.type === "MemberExpression") return memberPropertyName(callee) === modifier || testCalleeHasModifier(callee.object, modifier);
201
- if (callee.type === "CallExpression") return testCalleeHasModifier(callee.callee, modifier);
202
- return false;
203
- }
204
197
  /** The callback argument of a test call (arrow or function expression), if any. */
205
198
  function findTestCallback(args) {
206
199
  return args.find((arg) => arg.type === "ArrowFunctionExpression" || arg.type === "FunctionExpression");
207
200
  }
208
- /** The static string name of a test call (`test('name', …)`), if a literal. */
209
- function testName(callNode) {
210
- return stringValue((callNode.arguments ?? [])[0]);
211
- }
212
201
  /**
213
202
  * Visitor fragment collecting every import-like source: static imports,
214
203
  * dynamic `import()`, and re-exports. Rules spread this into their visitor.
@@ -235,6 +224,26 @@ function importSourceVisitor(onSource) {
235
224
  * The **statique** channel — one entry per shipped `jterrazz/*` rule. Each rule
236
225
  * file attaches its entry as `meta.docs`, so a rule and its normative text can
237
226
  * never drift (the completeness meta-test asserts every rule carries one).
227
+ *
228
+ * **What this channel does NOT own.** A convention oxlint's own `vitest` plugin
229
+ * already enforces belongs to that plugin, not here — `@jterrazz/typescript` v10
230
+ * turns the plugin on over the test globs in every profile, so a consumer of the
231
+ * preset gets it without wiring. Four hygiene rules left the channel on that
232
+ * ground (J1, J3, J4, J5 → `vitest/no-focused-tests` + `vitest/no-disabled-tests`,
233
+ * `vitest/expect-expect`, `vitest/no-identical-title`, `vitest/prefer-lowercase-title`).
234
+ * `prefer-lowercase-title` is stricter than J5 was: a title opening on an all-caps
235
+ * identifier (`HTTP 404 …`, `DI …`) now fails, and the rulebook carries no
236
+ * `allowedPrefixes` — a title starts lowercase, whatever its first word. The
237
+ * rest are behaviour-identical on this package's own fixtures. Only J2 (the
238
+ * arbitrary-sleep ban, which has no upstream counterpart) stays statique.
239
+ *
240
+ * **Severity.** The estate's rulebook has no warn tier: a rule is on at `error`
241
+ * or off with a recorded reason, and that law binds `@jterrazz/typescript`'s
242
+ * rulebook. This channel is its one documented exception, and it is narrow — an
243
+ * id shaped `<family><n>w-…` is a REDUNDANCY heuristic (a `root` the walk would
244
+ * have found, a probe cluster that wants a golden), a reading of taste that
245
+ * would block a legitimate shape if it failed the build. Every hard convention
246
+ * here is an `error`; `plugin.test.ts` holds the split.
238
247
  */
239
248
  const RULE_DOCS = {
240
249
  "a1-specification-file": {
@@ -470,7 +479,7 @@ const RULE_DOCS = {
470
479
  },
471
480
  "f3-specs-public-entry": {
472
481
  channel: "statique",
473
- 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/**`.",
482
+ 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/**`.",
474
483
  family: "F",
475
484
  id: "F3",
476
485
  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."
@@ -510,13 +519,6 @@ const RULE_DOCS = {
510
519
  id: "I4",
511
520
  rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
512
521
  },
513
- "j1-no-only-skip": {
514
- channel: "statique",
515
- convention: "Aucun `.only` / `.skip` committé (`describe.only`, `test.only`, `test.skip`).",
516
- family: "J",
517
- id: "J1",
518
- rationale: "Un `.only`/`.skip` oublié désactive silencieusement une partie de la suite."
519
- },
520
522
  "j2-no-sleep-in-specs": {
521
523
  channel: "statique",
522
524
  convention: "Aucun sleep arbitraire (`setTimeout`/`setInterval`/`Atomics.wait`) sous `specs/**` — la synchronisation passe par `waitFor`.",
@@ -524,27 +526,6 @@ const RULE_DOCS = {
524
526
  id: "J2",
525
527
  rationale: "Un sleep fixe rend les tests lents et instables ; attendre une condition est déterministe."
526
528
  },
527
- "j3-no-expectless-test": {
528
- channel: "statique",
529
- convention: "Un `test(...)` avec callback contient au moins un `expect(…)` ; `test.todo` (sans callback) est ignoré.",
530
- family: "J",
531
- id: "J3",
532
- rationale: "Un test sans assertion est mort ou muet — il passe toujours sans rien vérifier."
533
- },
534
- "j4-unique-test-names": {
535
- channel: "statique",
536
- convention: "Deux tests d’un même fichier ne partagent pas un nom littéral (`.each` ignoré).",
537
- family: "J",
538
- id: "J4",
539
- rationale: "Le nom du test est son unique description — deux noms identiques rendent un échec ambigu."
540
- },
541
- "j5-lowercase-title": {
542
- channel: "statique",
543
- 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.",
544
- family: "J",
545
- id: "J5",
546
- rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
547
- },
548
529
  "w1-scenario-pure": {
549
530
  channel: "statique",
550
531
  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é.",
@@ -903,90 +884,13 @@ const a1SpecificationFile = {
903
884
  },
904
885
  meta: {
905
886
  docs: RULE_DOCS["a1-specification-file"],
906
- messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/10-linting.md)." },
907
- type: "problem"
908
- }
909
- };
910
- //#endregion
911
- //#region src/core/specification/shared/binding.ts
912
- /**
913
- * Compose-binding resolution and the case conversions it shares with the
914
- * automatic env injection (CONVENTIONS A6 / B6).
915
- *
916
- * A services-record key is written in the natural TypeScript style
917
- * (`analyticsDb`), while a compose service name is kebab-case
918
- * (`analytics-db`). These helpers bridge the two vocabularies deterministically
919
- * so the record key stays the single source of truth and `composeService`
920
- * remains a rare escape hatch.
921
- */
922
- /**
923
- * Convert a record key to its kebab-case form: camelCase boundaries become
924
- * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
925
- */
926
- function toKebabCase(key) {
927
- return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
928
- }
929
- //#endregion
930
- //#region src/lint/rules/a10-duplicate-binding.ts
931
- /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
932
- function canonicalBinding(entry, key) {
933
- const factory = entry.value;
934
- if (factory?.type === "CallExpression") {
935
- const options = factory.arguments?.[0];
936
- if (options?.type === "ObjectExpression") {
937
- const explicit = stringValue(findProperty(options, "composeService")?.value);
938
- if (explicit !== void 0) return explicit;
939
- }
940
- }
941
- return toKebabCase(key);
942
- }
943
- /**
944
- * CONVENTIONS A10 — within one `services` record, two keys that derive (or
945
- * pin via `composeService`) the SAME compose service name are a silent
946
- * collision: the record is a map, and the second binding would shadow the
947
- * first. Detected on the same kebab derivation the runtime binding uses
948
- * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
949
- * `composeService` values.
950
- */
951
- const a10DuplicateBinding = {
952
- create(context) {
953
- return { CallExpression(node) {
954
- if (specificationMember(node) === void 0) return;
955
- for (const argument of node.arguments ?? []) {
956
- const record = findProperty(argument, "services")?.value;
957
- if (record?.type !== "ObjectExpression") continue;
958
- const seen = /* @__PURE__ */ new Map();
959
- for (const entry of record.properties ?? []) {
960
- const key = propertyKeyName(entry);
961
- if (key === void 0) continue;
962
- const binding = canonicalBinding(entry, key);
963
- const first = seen.get(binding);
964
- if (first !== void 0) {
965
- context.report({
966
- data: {
967
- binding,
968
- first,
969
- key
970
- },
971
- messageId: "duplicate",
972
- node: entry
973
- });
974
- continue;
975
- }
976
- seen.set(binding, key);
977
- }
978
- }
979
- } };
980
- },
981
- meta: {
982
- docs: RULE_DOCS["a10-duplicate-binding"],
983
- 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)." },
887
+ messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/13-linting.md)." },
984
888
  type: "problem"
985
889
  }
986
890
  };
987
891
  //#endregion
988
892
  //#region src/lint/rules/a2-known-constructors.ts
989
- /** The five constructors, and only five (A2 — see docs/10-linting.md). */
893
+ /** The five constructors, and only five (A2 — see docs/13-linting.md). */
990
894
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
991
895
  "api",
992
896
  "cli",
@@ -1015,7 +919,7 @@ const a2KnownConstructors = {
1015
919
  },
1016
920
  meta: {
1017
921
  docs: RULE_DOCS["a2-known-constructors"],
1018
- 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)." },
922
+ 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)." },
1019
923
  type: "problem"
1020
924
  }
1021
925
  };
@@ -1060,7 +964,7 @@ const a3NoDestructureAlias = {
1060
964
  },
1061
965
  meta: {
1062
966
  docs: RULE_DOCS["a3-no-destructure-alias"],
1063
- 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." },
967
+ 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." },
1064
968
  type: "problem"
1065
969
  }
1066
970
  };
@@ -1112,7 +1016,7 @@ const a4CleanupAfterall = {
1112
1016
  },
1113
1017
  meta: {
1114
1018
  docs: RULE_DOCS["a4-cleanup-afterall"],
1115
- messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/10-linting.md)." },
1019
+ messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/13-linting.md)." },
1116
1020
  type: "problem"
1117
1021
  }
1118
1022
  };
@@ -1142,11 +1046,30 @@ const a5ModeWithServer = {
1142
1046
  },
1143
1047
  meta: {
1144
1048
  docs: RULE_DOCS["a5-mode-with-server"],
1145
- 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)." },
1049
+ 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)." },
1146
1050
  type: "problem"
1147
1051
  }
1148
1052
  };
1149
1053
  //#endregion
1054
+ //#region src/specification/facets/_common/binding.ts
1055
+ /**
1056
+ * Compose-binding resolution and the case conversions it shares with the
1057
+ * automatic env injection (CONVENTIONS A6 / B6).
1058
+ *
1059
+ * A services-record key is written in the natural TypeScript style
1060
+ * (`analyticsDb`), while a compose service name is kebab-case
1061
+ * (`analytics-db`). These helpers bridge the two vocabularies deterministically
1062
+ * so the record key stays the single source of truth and `composeService`
1063
+ * remains a rare escape hatch.
1064
+ */
1065
+ /**
1066
+ * Convert a record key to its kebab-case form: camelCase boundaries become
1067
+ * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
1068
+ */
1069
+ function toKebabCase(key) {
1070
+ return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
1071
+ }
1072
+ //#endregion
1150
1073
  //#region src/lint/rules/a6w-redundant-compose-service.ts
1151
1074
  /**
1152
1075
  * CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
@@ -1183,12 +1106,12 @@ const a6wRedundantComposeService = {
1183
1106
  },
1184
1107
  meta: {
1185
1108
  docs: RULE_DOCS["a6w-redundant-compose-service"],
1186
- 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)." },
1109
+ 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)." },
1187
1110
  type: "suggestion"
1188
1111
  }
1189
1112
  };
1190
1113
  //#endregion
1191
- //#region src/core/specification/shared/resolve.ts
1114
+ //#region src/specification/facets/_common/resolve.ts
1192
1115
  /** What makes a directory a project root — a manifest, or a test stack. */
1193
1116
  const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
1194
1117
  /**
@@ -1218,7 +1141,7 @@ function findRoot(startDir, exists) {
1218
1141
  //#endregion
1219
1142
  //#region src/lint/rules/a9w-redundant-root.ts
1220
1143
  /**
1221
- * The root the convention would derive (A9 — see docs/10-linting.md): the
1144
+ * The root the convention would derive (A9 — see docs/13-linting.md): the
1222
1145
  * framework's own walk-up, run against this layer's cached fs probe. The rule
1223
1146
  * flags a `root` that the runner would have found by itself, so the two MUST
1224
1147
  * be the same function — a private copy of the walk drifted from it before.
@@ -1250,12 +1173,70 @@ const a9wRedundantRoot = {
1250
1173
  },
1251
1174
  meta: {
1252
1175
  docs: RULE_DOCS["a9w-redundant-root"],
1253
- messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/10-linting.md)." },
1176
+ messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/13-linting.md)." },
1254
1177
  type: "suggestion"
1255
1178
  }
1256
1179
  };
1257
1180
  //#endregion
1258
- //#region src/core/specification/shared/ground.ts
1181
+ //#region src/lint/rules/a10-duplicate-binding.ts
1182
+ /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
1183
+ function canonicalBinding(entry, key) {
1184
+ const factory = entry.value;
1185
+ if (factory?.type === "CallExpression") {
1186
+ const options = factory.arguments?.[0];
1187
+ if (options?.type === "ObjectExpression") {
1188
+ const explicit = stringValue(findProperty(options, "composeService")?.value);
1189
+ if (explicit !== void 0) return explicit;
1190
+ }
1191
+ }
1192
+ return toKebabCase(key);
1193
+ }
1194
+ /**
1195
+ * CONVENTIONS A10 — within one `services` record, two keys that derive (or
1196
+ * pin via `composeService`) the SAME compose service name are a silent
1197
+ * collision: the record is a map, and the second binding would shadow the
1198
+ * first. Detected on the same kebab derivation the runtime binding uses
1199
+ * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
1200
+ * `composeService` values.
1201
+ */
1202
+ const a10DuplicateBinding = {
1203
+ create(context) {
1204
+ return { CallExpression(node) {
1205
+ if (specificationMember(node) === void 0) return;
1206
+ for (const argument of node.arguments ?? []) {
1207
+ const record = findProperty(argument, "services")?.value;
1208
+ if (record?.type !== "ObjectExpression") continue;
1209
+ const seen = /* @__PURE__ */ new Map();
1210
+ for (const entry of record.properties ?? []) {
1211
+ const key = propertyKeyName(entry);
1212
+ if (key === void 0) continue;
1213
+ const binding = canonicalBinding(entry, key);
1214
+ const first = seen.get(binding);
1215
+ if (first !== void 0) {
1216
+ context.report({
1217
+ data: {
1218
+ binding,
1219
+ first,
1220
+ key
1221
+ },
1222
+ messageId: "duplicate",
1223
+ node: entry
1224
+ });
1225
+ continue;
1226
+ }
1227
+ seen.set(binding, key);
1228
+ }
1229
+ }
1230
+ } };
1231
+ },
1232
+ meta: {
1233
+ docs: RULE_DOCS["a10-duplicate-binding"],
1234
+ 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)." },
1235
+ type: "problem"
1236
+ }
1237
+ };
1238
+ //#endregion
1239
+ //#region src/specification/facets/_common/ground.ts
1259
1240
  /**
1260
1241
  * The GROUND a row of specs stands on — the naming law of a spec tree.
1261
1242
  *
@@ -1296,7 +1277,7 @@ const RENAMED_GROUND_DIRS = {
1296
1277
  seeds: GROUND_SEEDS
1297
1278
  };
1298
1279
  //#endregion
1299
- //#region src/core/specification/shared/fixtures.ts
1280
+ //#region src/specification/facets/_common/fixtures.ts
1300
1281
  /**
1301
1282
  * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
1302
1283
  *
@@ -1343,7 +1324,7 @@ const b2KnownFixtureMarker = {
1343
1324
  const args = node.arguments ?? [];
1344
1325
  const path = leadingText(args[0]);
1345
1326
  if (path === void 0 || !path.startsWith("$")) return;
1346
- const marker = path.replace(/\/+$/, "").split("/")[0];
1327
+ const marker = path.replace(/\/+$/u, "").split("/")[0] ?? "";
1347
1328
  if (!KNOWN.has(marker)) context.report({
1348
1329
  data: {
1349
1330
  known: [...KNOWN_FIXTURE_MARKERS].join(", "),
@@ -1356,7 +1337,7 @@ const b2KnownFixtureMarker = {
1356
1337
  },
1357
1338
  meta: {
1358
1339
  docs: RULE_DOCS["b2-known-fixture-marker"],
1359
- messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/10-linting.md)." },
1340
+ messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/13-linting.md)." },
1360
1341
  type: "problem"
1361
1342
  }
1362
1343
  };
@@ -1421,8 +1402,8 @@ const b4GivenThen = {
1421
1402
  meta: {
1422
1403
  docs: RULE_DOCS["b4-given-then"],
1423
1404
  messages: {
1424
- givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/10-linting.md).",
1425
- missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/10-linting.md — every test needs both `// Given -` and `// Then -`)."
1405
+ givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/13-linting.md).",
1406
+ missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/13-linting.md — every test needs both `// Given -` and `// Then -`)."
1426
1407
  },
1427
1408
  type: "problem"
1428
1409
  }
@@ -1463,7 +1444,7 @@ const b5AwaitUsing = {
1463
1444
  meta: {
1464
1445
  docs: RULE_DOCS["b5-await-using"],
1465
1446
  defaultOptions: [{ runners: [] }],
1466
- 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)." },
1447
+ 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)." },
1467
1448
  schema: [{
1468
1449
  additionalProperties: false,
1469
1450
  properties: { runners: {
@@ -1477,7 +1458,7 @@ const b5AwaitUsing = {
1477
1458
  };
1478
1459
  //#endregion
1479
1460
  //#region src/lint/rules/b6w-redundant-env-url.ts
1480
- const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/;
1461
+ const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/u;
1481
1462
  /**
1482
1463
  * CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
1483
1464
  * already injects `<KEY>_URL` for every service (plus the unambiguous
@@ -1506,14 +1487,14 @@ const b6wRedundantEnvUrl = {
1506
1487
  },
1507
1488
  meta: {
1508
1489
  docs: RULE_DOCS["b6w-redundant-env-url"],
1509
- 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)." },
1490
+ 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)." },
1510
1491
  type: "suggestion"
1511
1492
  }
1512
1493
  };
1513
1494
  //#endregion
1514
1495
  //#region src/lint/rules/b8-kebab-trigger.ts
1515
1496
  /** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
1516
- const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1497
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
1517
1498
  /**
1518
1499
  * CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
1519
1500
  * identifier; it is a contract between the app and its tests. Only string
@@ -1535,7 +1516,7 @@ const b8KebabTrigger = {
1535
1516
  },
1536
1517
  meta: {
1537
1518
  docs: RULE_DOCS["b8-kebab-trigger"],
1538
- 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)." },
1519
+ 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)." },
1539
1520
  type: "problem"
1540
1521
  }
1541
1522
  };
@@ -1590,7 +1571,7 @@ const b9wProductCommand = {
1590
1571
  },
1591
1572
  meta: {
1592
1573
  docs: RULE_DOCS["b9w-product-command"],
1593
- 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." },
1574
+ 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." },
1594
1575
  type: "suggestion"
1595
1576
  }
1596
1577
  };
@@ -1743,14 +1724,14 @@ const c1DomainStructure = {
1743
1724
  defaultOptions: [{ depth: DEFAULT_DEPTH }],
1744
1725
  docs: RULE_DOCS["c1-domain-structure"],
1745
1726
  messages: {
1746
- 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.",
1747
- specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1748
- 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).",
1749
- 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).",
1750
- 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).",
1751
- 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).",
1752
- 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).",
1753
- 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)."
1727
+ 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.",
1728
+ specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/13-linting.md).",
1729
+ 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).",
1730
+ 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).",
1731
+ 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).",
1732
+ 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).",
1733
+ 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).",
1734
+ 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)."
1754
1735
  },
1755
1736
  schema: [{
1756
1737
  additionalProperties: false,
@@ -1769,212 +1750,71 @@ const c1DomainStructure = {
1769
1750
  }
1770
1751
  };
1771
1752
  //#endregion
1772
- //#region src/lint/rules/c10-contracts-boundary.ts
1773
- /** A specifier reaching into a provider folder — the internal half of contracts/. */
1774
- const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1753
+ //#region src/lint/rules/c2-http-only-requests.ts
1754
+ const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/u;
1775
1755
  /**
1776
- * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1777
- * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1778
- * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1779
- * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1780
- * importing a unit is an error — a test routes through the facade, so a
1781
- * scenario is named once, next to the world it derives from.
1756
+ * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1757
+ * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1758
+ * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1759
+ * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1760
+ * is reported on the test file.
1782
1761
  */
1783
- const c10ContractsBoundary = {
1762
+ const c2HttpOnlyRequests = {
1784
1763
  create(context) {
1785
- const parts = segments(context.physicalFilename);
1786
- if (!parts.includes("specs")) return {};
1787
- if (parts.slice(0, -1).includes("contracts")) return {};
1788
- return importSourceVisitor(({ node, source }) => {
1789
- if (INTERNAL_UNIT.test(source)) context.report({
1790
- data: { source },
1791
- messageId: "internal",
1764
+ const file = context.physicalFilename;
1765
+ if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1766
+ return { Program(node) {
1767
+ const requestsDir = join(dirname(file), GROUND_REQUESTS);
1768
+ for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1769
+ data: { entry },
1770
+ messageId: "notHttp",
1792
1771
  node
1793
1772
  });
1794
- });
1773
+ } };
1795
1774
  },
1796
1775
  meta: {
1797
- docs: RULE_DOCS["c10-contracts-boundary"],
1798
- 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)." },
1776
+ docs: RULE_DOCS["c2-http-only-requests"],
1777
+ messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/13-linting.md)." },
1799
1778
  type: "problem"
1800
1779
  }
1801
1780
  };
1802
1781
  //#endregion
1803
- //#region src/lint/rules/c11-contract-data-pairing.ts
1804
- const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/;
1805
- /** The provider folders that may carry data files. */
1806
- const PROVIDERS$1 = [
1782
+ //#region src/lint/rules/c4-contract-shape.ts
1783
+ /** The only directories a `contracts/` root may hold — the provider carriers. */
1784
+ const PROVIDERS$1 = /* @__PURE__ */ new Set([
1807
1785
  "anthropic",
1808
1786
  "http",
1809
1787
  "openai"
1810
- ];
1811
- /** Data files: served payloads and matched inputs, both owned by a contract. */
1812
- const DATA_SUFFIXES = [".request.ts", ".response.json"];
1813
- /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1814
- function ownerOf(entry) {
1815
- return `${entry.slice(0, entry.indexOf("."))}.ts`;
1788
+ ]);
1789
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/u;
1790
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1791
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/u;
1792
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1793
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1794
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1795
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1796
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1797
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1798
+ /** The name a specifier/declaration exports under, when it is `default`. */
1799
+ function exportsDefault(node) {
1800
+ return (node.specifiers ?? []).some((specifier) => {
1801
+ const exported = specifier.exported;
1802
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1803
+ });
1804
+ }
1805
+ /** Is this node a `defineContract(...)` call? */
1806
+ function isDefineContract(node) {
1807
+ const callee = node.callee;
1808
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1809
+ }
1810
+ /** A default export that may legitimately produce a contract (value or factory). */
1811
+ function producesContract(declaration) {
1812
+ if (declaration === void 0) return false;
1813
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1816
1814
  }
1817
1815
  /**
1818
- * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1819
- * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1820
- * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1821
- * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1822
- * file with no owner is dead weight no test can reach — reported on the feature's
1823
- * test file, since oxlint never visits a `.json`.
1824
- */
1825
- const c11ContractDataPairing = {
1826
- create(context) {
1827
- const file = context.physicalFilename;
1828
- if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
1829
- return { Program(node) {
1830
- const contractsDir = join(dirname(file), "contracts");
1831
- for (const provider of PROVIDERS$1) {
1832
- const entries = listDirectory(join(contractsDir, provider));
1833
- if (entries === null) continue;
1834
- const present = new Set(entries);
1835
- for (const entry of entries) {
1836
- if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1837
- const owner = ownerOf(entry);
1838
- if (!present.has(owner)) context.report({
1839
- data: {
1840
- entry,
1841
- owner,
1842
- provider
1843
- },
1844
- messageId: "orphan",
1845
- node
1846
- });
1847
- }
1848
- }
1849
- } };
1850
- },
1851
- meta: {
1852
- docs: RULE_DOCS["c11-contract-data-pairing"],
1853
- 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)." },
1854
- type: "problem"
1855
- }
1856
- };
1857
- //#endregion
1858
- //#region src/lint/rules/c13-underscored-ground.ts
1859
- const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1860
- /**
1861
- * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
1862
- * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
1863
- * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
1864
- * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
1865
- * fallback.
1866
- *
1867
- * The catch this rule exists for: a consumer that upgrades and keeps the old
1868
- * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
1869
- * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
1870
- * and the tree becomes invisible rather than wrong. The failure that follows is
1871
- * "fixture does not exist" on every spec at once, one step removed from the
1872
- * cause; this rule states the cause and the rename.
1873
- *
1874
- * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
1875
- * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
1876
- * nearest specs root. A DOMAIN folder named after the ground it holds
1877
- * (`specs/api/requests/_requests/`) is never a probe target — only what sits
1878
- * beside a spec is.
1879
- */
1880
- const c13UnderscoredGround = {
1881
- create(context) {
1882
- const file = context.physicalFilename;
1883
- const anchor = specsAnchor(file);
1884
- if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
1885
- return { Program(node) {
1886
- const seen = /* @__PURE__ */ new Set();
1887
- const probe = (parent, legacy) => {
1888
- const path = join(parent, legacy);
1889
- if (seen.has(path) || !isDirectory(path)) return;
1890
- seen.add(path);
1891
- context.report({
1892
- data: {
1893
- ground: RENAMED_GROUND_DIRS[legacy],
1894
- legacy
1895
- },
1896
- messageId: "notUnderscored",
1897
- node
1898
- });
1899
- };
1900
- for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe(dirname(file), legacy);
1901
- probe(anchor.directory, "fixtures");
1902
- } };
1903
- },
1904
- meta: {
1905
- docs: RULE_DOCS["c13-underscored-ground"],
1906
- 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)." },
1907
- type: "problem"
1908
- }
1909
- };
1910
- //#endregion
1911
- //#region src/lint/rules/c2-http-only-requests.ts
1912
- const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1913
- /**
1914
- * CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
1915
- * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1916
- * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1917
- * `_requests/` directory is probed (cached readdir) and any non-`.http` entry
1918
- * is reported on the test file.
1919
- */
1920
- const c2HttpOnlyRequests = {
1921
- create(context) {
1922
- const file = context.physicalFilename;
1923
- if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1924
- return { Program(node) {
1925
- const requestsDir = join(dirname(file), GROUND_REQUESTS);
1926
- for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1927
- data: { entry },
1928
- messageId: "notHttp",
1929
- node
1930
- });
1931
- } };
1932
- },
1933
- meta: {
1934
- docs: RULE_DOCS["c2-http-only-requests"],
1935
- messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1936
- type: "problem"
1937
- }
1938
- };
1939
- //#endregion
1940
- //#region src/lint/rules/c4-contract-shape.ts
1941
- /** The only directories a `contracts/` root may hold — the provider carriers. */
1942
- const PROVIDERS = /* @__PURE__ */ new Set([
1943
- "anthropic",
1944
- "http",
1945
- "openai"
1946
- ]);
1947
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1948
- /** A file oxlint itself visits — the AST half of the rule reports on those. */
1949
- const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1950
- /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1951
- const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1952
- /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1953
- const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1954
- /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1955
- const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1956
- /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1957
- const RESPONSE_DATA_FILE = /\.response\.json$/;
1958
- /** The name a specifier/declaration exports under, when it is `default`. */
1959
- function exportsDefault(node) {
1960
- return (node.specifiers ?? []).some((specifier) => {
1961
- const exported = specifier.exported;
1962
- return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1963
- });
1964
- }
1965
- /** Is this node a `defineContract(...)` call? */
1966
- function isDefineContract(node) {
1967
- const callee = node.callee;
1968
- return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1969
- }
1970
- /** A default export that may legitimately produce a contract (value or factory). */
1971
- function producesContract(declaration) {
1972
- if (declaration === void 0) return false;
1973
- return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1974
- }
1975
- /**
1976
- * The provider a `request:` value implies, when the builder is written as
1977
- * `<provider>.<verb>(…)` — the only statically decidable form.
1816
+ * The provider a `request:` value implies, when the builder is written as
1817
+ * `<provider>.<verb>(…)` — the only statically decidable form.
1978
1818
  */
1979
1819
  function requestProvider(request) {
1980
1820
  if (request?.type !== "CallExpression") return;
@@ -2002,7 +1842,7 @@ function checkLayout(context, node, contractsDir) {
2002
1842
  });
2003
1843
  continue;
2004
1844
  }
2005
- if (!PROVIDERS.has(entry)) {
1845
+ if (!PROVIDERS$1.has(entry)) {
2006
1846
  context.report({
2007
1847
  data: { entry },
2008
1848
  messageId: "badProviderDir",
@@ -2018,7 +1858,7 @@ function checkLayout(context, node, contractsDir) {
2018
1858
  messageId: "providerSubfolder",
2019
1859
  node
2020
1860
  });
2021
- else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1861
+ else if (!/\.[cm]?ts$/u.test(child) && !child.endsWith(".response.json")) context.report({
2022
1862
  data: {
2023
1863
  child,
2024
1864
  provider: entry
@@ -2087,7 +1927,7 @@ function checkUnit(context, program, base, provider) {
2087
1927
  if (isDefineContract(node)) {
2088
1928
  const argument = node.arguments?.[0];
2089
1929
  const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
2090
- if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1930
+ if (declared !== void 0 && PROVIDERS$1.has(declared) && declared !== provider) context.report({
2091
1931
  data: {
2092
1932
  declared,
2093
1933
  provider
@@ -2129,7 +1969,7 @@ const c4ContractShape = {
2129
1969
  if (!parts.includes("specs")) return {};
2130
1970
  const contractsIndex = parts.lastIndexOf("contracts");
2131
1971
  if (contractsIndex === -1) {
2132
- if (!TEST_FILE$9.test(file)) return {};
1972
+ if (!TEST_FILE$11.test(file)) return {};
2133
1973
  return { Program(node) {
2134
1974
  checkLayout(context, node, join(dirname(file), "contracts"));
2135
1975
  } };
@@ -2140,8 +1980,8 @@ const c4ContractShape = {
2140
1980
  checkComposite(context, node, base);
2141
1981
  } };
2142
1982
  if (depth === 2) {
2143
- const provider = parts[contractsIndex + 1];
2144
- return PROVIDERS.has(provider) ? { Program(node) {
1983
+ const provider = parts[contractsIndex + 1] ?? "";
1984
+ return PROVIDERS$1.has(provider) ? { Program(node) {
2145
1985
  checkUnit(context, node, base, provider);
2146
1986
  } } : {};
2147
1987
  }
@@ -2156,25 +1996,25 @@ const c4ContractShape = {
2156
1996
  meta: {
2157
1997
  docs: RULE_DOCS["c4-contract-shape"],
2158
1998
  messages: {
2159
- badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/10-linting.md).",
2160
- 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).",
2161
- missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
2162
- missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
2163
- notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
2164
- notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
2165
- providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
2166
- 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).",
2167
- providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
2168
- 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).",
2169
- 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).",
2170
- tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
1999
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/13-linting.md).",
2000
+ 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).",
2001
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/13-linting.md).",
2002
+ missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/13-linting.md).",
2003
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/13-linting.md).",
2004
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/13-linting.md).",
2005
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/13-linting.md).",
2006
+ 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).",
2007
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/13-linting.md).",
2008
+ 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).",
2009
+ 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).",
2010
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/13-linting.md)."
2171
2011
  },
2172
2012
  type: "problem"
2173
2013
  }
2174
2014
  };
2175
2015
  //#endregion
2176
2016
  //#region src/lint/rules/c6-tomatch-extension.ts
2177
- const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/;
2017
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/u;
2178
2018
  /** Does the expect(…) subject chain contain a `.directory(…)` call? */
2179
2019
  function subjectIsDirectory(toMatchCallee) {
2180
2020
  const expectCall = toMatchCallee.object;
@@ -2199,7 +2039,7 @@ function subjectIsDirectory(toMatchCallee) {
2199
2039
  const c6ToMatchExtension = {
2200
2040
  create(context) {
2201
2041
  const file = context.physicalFilename;
2202
- if (!TEST_FILE$8.test(file) || !segments(file).includes("specs")) return {};
2042
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
2203
2043
  return { CallExpression(node) {
2204
2044
  const callee = node.callee;
2205
2045
  if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
@@ -2218,13 +2058,13 @@ const c6ToMatchExtension = {
2218
2058
  },
2219
2059
  meta: {
2220
2060
  docs: RULE_DOCS["c6-tomatch-extension"],
2221
- 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)." },
2061
+ 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)." },
2222
2062
  type: "problem"
2223
2063
  }
2224
2064
  };
2225
2065
  //#endregion
2226
2066
  //#region src/lint/rules/c7-seeds-sql-only.ts
2227
- const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2067
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/u;
2228
2068
  /**
2229
2069
  * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
2230
2070
  * nothing but `*.sql` fragments. File state goes through `.fixture()`; there
@@ -2234,7 +2074,7 @@ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
2234
2074
  const c7SeedsSqlOnly = {
2235
2075
  create(context) {
2236
2076
  const file = context.physicalFilename;
2237
- if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2077
+ if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
2238
2078
  return { Program(node) {
2239
2079
  const seedsDir = join(dirname(file), GROUND_SEEDS);
2240
2080
  for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
@@ -2246,13 +2086,13 @@ const c7SeedsSqlOnly = {
2246
2086
  },
2247
2087
  meta: {
2248
2088
  docs: RULE_DOCS["c7-seeds-sql-only"],
2249
- 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)." },
2089
+ 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)." },
2250
2090
  type: "problem"
2251
2091
  }
2252
2092
  };
2253
2093
  //#endregion
2254
2094
  //#region src/lint/rules/c8-referenced-fixture-exists.ts
2255
- const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/;
2095
+ const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/u;
2256
2096
  /** The ground directory each fixture-referencing verb reads from. */
2257
2097
  const VERB_ROOTS = {
2258
2098
  request: GROUND_REQUESTS,
@@ -2285,7 +2125,7 @@ const c8ReferencedFixtureExists = {
2285
2125
  create(context) {
2286
2126
  const file = context.physicalFilename;
2287
2127
  const anchor = specsAnchor(file);
2288
- if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
2128
+ if (!TEST_FILE$8.test(file) || anchor === void 0) return {};
2289
2129
  const featureDir = dirname(file);
2290
2130
  return { CallExpression(node) {
2291
2131
  const callee = node.callee;
@@ -2295,7 +2135,7 @@ const c8ReferencedFixtureExists = {
2295
2135
  if (verb === "fixture") {
2296
2136
  const value = stringValue(args[0]);
2297
2137
  if (value === void 0 || !looksLikePath(value)) return;
2298
- const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
2138
+ const target = resolveFixture(value.replace(/\/+$/u, ""), featureDir, anchor.directory);
2299
2139
  if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
2300
2140
  data: {
2301
2141
  path: value,
@@ -2310,7 +2150,7 @@ const c8ReferencedFixtureExists = {
2310
2150
  if (root === void 0) return;
2311
2151
  const value = stringValue(args[0]);
2312
2152
  if (value === void 0 || !looksLikePath(value)) return;
2313
- const target = join(featureDir, root, value.replace(/\/+$/, ""));
2153
+ const target = join(featureDir, root, value.replace(/\/+$/u, ""));
2314
2154
  if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
2315
2155
  data: {
2316
2156
  path: `${root}/${value}`,
@@ -2323,236 +2163,147 @@ const c8ReferencedFixtureExists = {
2323
2163
  },
2324
2164
  meta: {
2325
2165
  docs: RULE_DOCS["c8-referenced-fixture-exists"],
2326
- 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." },
2166
+ 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." },
2327
2167
  type: "problem"
2328
2168
  }
2329
2169
  };
2330
2170
  //#endregion
2331
- //#region src/lint/rules/d12w-response-body-probe.ts
2332
- /** A test reading the response body this many times (or more) is a cluster. */
2333
- const DEFAULT_THRESHOLD = 3;
2334
- /** Is `node` the `x.response.body` member access itself? */
2335
- function isResponseBody(node) {
2336
- if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
2337
- const object = node.object;
2338
- return object !== void 0 && memberPropertyName(object) === "response";
2339
- }
2340
- /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
2341
- function unwrap(node) {
2342
- let current = node ?? void 0;
2343
- while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
2344
- else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
2345
- else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
2346
- else return current;
2347
- }
2171
+ //#region src/lint/rules/c10-contracts-boundary.ts
2172
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
2173
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
2348
2174
  /**
2349
- * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
2350
- * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
2351
- * of raw body probes — reading `.response.body` (or a variable cast from it)
2352
- * `threshold`+ times in one test callback — should collapse into one golden:
2353
- * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
2354
- * (a legitimate scalpel: absence, a single-field delta, third-party output).
2355
- *
2356
- * A probe is a body-rooted read: a direct `.response.body` use, or a field
2357
- * access on the local binding a `const body = result.response.body as {…}`
2358
- * introduces. The count is the same threshold the golden transformation applies.
2175
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
2176
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
2177
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
2178
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
2179
+ * importing a unit is an error — a test routes through the facade, so a
2180
+ * scenario is named once, next to the world it derives from.
2359
2181
  */
2360
- const d12wResponseBodyProbe = {
2182
+ const c10ContractsBoundary = {
2361
2183
  create(context) {
2362
- if (!segments(context.filename).includes("specs")) return {};
2363
- const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
2364
- return { CallExpression(node) {
2365
- if (!isTestCallee(node.callee)) return;
2366
- const callback = findTestCallback(node.arguments ?? []);
2367
- if (callback === void 0) return;
2368
- const aliases = /* @__PURE__ */ new Set();
2369
- let responseBodyNodes = 0;
2370
- walk(callback, (inner) => {
2371
- if (isResponseBody(inner)) responseBodyNodes++;
2372
- if (inner.type === "VariableDeclarator") {
2373
- const init = unwrap(inner.init);
2374
- const id = inner.id;
2375
- if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
2376
- }
2377
- });
2378
- let aliasReads = 0;
2379
- walk(callback, (inner) => {
2380
- if (inner.type !== "MemberExpression") return;
2381
- const object = inner.object;
2382
- if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
2383
- });
2384
- const probes = responseBodyNodes - aliases.size + aliasReads;
2385
- if (probes >= threshold) context.report({
2386
- data: { count: String(probes) },
2387
- messageId: "bodyProbeCluster",
2184
+ const parts = segments(context.physicalFilename);
2185
+ if (!parts.includes("specs")) return {};
2186
+ if (parts.slice(0, -1).includes("contracts")) return {};
2187
+ return importSourceVisitor(({ node, source }) => {
2188
+ if (INTERNAL_UNIT.test(source)) context.report({
2189
+ data: { source },
2190
+ messageId: "internal",
2388
2191
  node
2389
2192
  });
2390
- } };
2193
+ });
2391
2194
  },
2392
2195
  meta: {
2393
- docs: RULE_DOCS["d12w-response-body-probe"],
2394
- defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
2395
- 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)." },
2396
- schema: [{
2397
- additionalProperties: false,
2398
- properties: { threshold: {
2399
- minimum: 1,
2400
- type: "integer"
2401
- } },
2402
- type: "object"
2403
- }],
2404
- type: "suggestion"
2196
+ docs: RULE_DOCS["c10-contracts-boundary"],
2197
+ 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)." },
2198
+ type: "problem"
2405
2199
  }
2406
2200
  };
2407
2201
  //#endregion
2408
- //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
2409
- /**
2410
- * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
2411
- * A frozen fixture opts out of update-mode rewriting, so it is exempt.
2412
- */
2413
- function hasFrozenOption(args) {
2414
- const options = args[1];
2415
- if (options === void 0 || options.type !== "ObjectExpression") return false;
2416
- const value = findProperty(options, "frozen")?.value;
2417
- return value?.type === "Literal" && value.value === true;
2202
+ //#region src/lint/rules/c11-contract-data-pairing.ts
2203
+ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/u;
2204
+ /** The provider folders that may carry data files. */
2205
+ const PROVIDERS = [
2206
+ "anthropic",
2207
+ "http",
2208
+ "openai"
2209
+ ];
2210
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
2211
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
2212
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
2213
+ function ownerOf(entry) {
2214
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
2418
2215
  }
2419
2216
  /**
2420
- * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
2421
- * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
2422
- * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
2423
- * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
2424
- * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
2425
- * expression: it stops at the first statement boundary or non-`expect` call, so
2426
- * a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
2217
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
2218
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
2219
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
2220
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
2221
+ * file with no owner is dead weight no test can reach — reported on the feature's
2222
+ * test file, since oxlint never visits a `.json`.
2427
2223
  */
2428
- const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
2429
- "ArrowFunctionExpression",
2430
- "AwaitExpression",
2431
- "ChainExpression",
2432
- "MemberExpression",
2433
- "ParenthesizedExpression",
2434
- "TSAsExpression",
2435
- "TSNonNullExpression"
2436
- ]);
2437
- function isWrappedInExpect(node) {
2438
- let current = node.parent;
2439
- while (current !== void 0) {
2440
- if (current.type === "CallExpression") {
2441
- const callee = current.callee;
2442
- return callee?.type === "Identifier" && callee.name === "expect";
2443
- }
2444
- if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
2445
- current = current.parent;
2446
- }
2447
- return false;
2448
- }
2449
- /**
2450
- * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
2451
- * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
2452
- * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
2453
- * unfrozen fixture is silently rewritten with the actual output — the matcher
2454
- * writes instead of throwing — which destroys the negative case (the assertion
2455
- * then no longer throws). Pass `{ frozen: true }` so the fixture is never
2456
- * rewritten and the mismatch/error still throws in update mode.
2457
- *
2458
- * The structural signal is precise for the wrapped forms; a `toMatch` routed
2459
- * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
2460
- * is out of static reach — see the D13 process note for that residue.
2461
- */
2462
- const d13wUnfrozenNegativeFixture = {
2224
+ const c11ContractDataPairing = {
2463
2225
  create(context) {
2464
- if (!segments(context.filename).includes("specs")) return {};
2465
- return { CallExpression(node) {
2466
- const callee = node.callee;
2467
- if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
2468
- if (!isWrappedInExpect(node)) return;
2469
- if (hasFrozenOption(node.arguments ?? [])) return;
2470
- context.report({
2471
- messageId: "unfrozenNegativeFixture",
2472
- node
2473
- });
2226
+ const file = context.physicalFilename;
2227
+ if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
2228
+ return { Program(node) {
2229
+ const contractsDir = join(dirname(file), "contracts");
2230
+ for (const provider of PROVIDERS) {
2231
+ const entries = listDirectory(join(contractsDir, provider));
2232
+ if (entries === null) continue;
2233
+ const present = new Set(entries);
2234
+ for (const entry of entries) {
2235
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
2236
+ const owner = ownerOf(entry);
2237
+ if (!present.has(owner)) context.report({
2238
+ data: {
2239
+ entry,
2240
+ owner,
2241
+ provider
2242
+ },
2243
+ messageId: "orphan",
2244
+ node
2245
+ });
2246
+ }
2247
+ }
2474
2248
  } };
2475
2249
  },
2476
2250
  meta: {
2477
- docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
2478
- 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)." },
2479
- type: "suggestion"
2251
+ docs: RULE_DOCS["c11-contract-data-pairing"],
2252
+ 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)." },
2253
+ type: "problem"
2480
2254
  }
2481
2255
  };
2482
2256
  //#endregion
2483
- //#region src/lint/rules/d15w-status-only-probe.ts
2484
- /** An HTTP status code lives in this range — the numeric-literal gate. */
2485
- const MIN_STATUS = 100;
2486
- const MAX_STATUS = 599;
2487
- /** Matchers that pin a single value — the shape of a status probe. */
2488
- const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
2489
- /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
2490
- function isExpectCall(node) {
2491
- if (node?.type !== "CallExpression") return false;
2492
- const callee = node.callee;
2493
- return callee?.type === "Identifier" && callee.name === "expect";
2494
- }
2495
- /** Is `node` a numeric literal in the HTTP status range (100–599)? */
2496
- function isStatusLiteral(node) {
2497
- if (node?.type !== "Literal" || typeof node.value !== "number") return false;
2498
- const value = node.value;
2499
- return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
2500
- }
2501
- /**
2502
- * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
2503
- * `X.status` is any `.status` member access and `N` is a numeric literal in the
2504
- * HTTP status range? The numeric-literal gate keeps container-status STRINGS
2505
- * (`expect(neo.status).toBe('running')`) out.
2506
- */
2507
- function isStatusProbe(node) {
2508
- if (node.type !== "CallExpression") return false;
2509
- const callee = node.callee;
2510
- if (callee?.type !== "MemberExpression") return false;
2511
- const matcher = memberPropertyName(callee);
2512
- if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
2513
- const expectCall = callee.object;
2514
- if (!isExpectCall(expectCall)) return false;
2515
- const subject = (expectCall?.arguments ?? [])[0];
2516
- if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
2517
- const expected = (node.arguments ?? [])[0];
2518
- return isStatusLiteral(expected);
2519
- }
2257
+ //#region src/lint/rules/c13-underscored-ground.ts
2258
+ const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/u;
2520
2259
  /**
2521
- * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
2522
- * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
2523
- * 100–599) throws away the full-response golden. Pin the whole response instead:
2524
- * `expect(result.response).toMatch('case.http')`.
2260
+ * CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
2261
+ * directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
2262
+ * `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
2263
+ * `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
2264
+ * fallback.
2525
2265
  *
2526
- * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
2527
- * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
2528
- * threshold yet still hides everything the response carries. The rule fires only
2529
- * when EVERY `expect(…)` in the callback is a status probe — a status probe
2530
- * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
2531
- * the legitimate scalpel and stays silent.
2266
+ * The catch this rule exists for: a consumer that upgrades and keeps the old
2267
+ * names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
2268
+ * `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
2269
+ * and the tree becomes invisible rather than wrong. The failure that follows is
2270
+ * "fixture does not exist" on every spec at once, one step removed from the
2271
+ * cause; this rule states the cause and the rename.
2272
+ *
2273
+ * Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
2274
+ * spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
2275
+ * nearest specs root. A DOMAIN folder named after the ground it holds
2276
+ * (`specs/api/requests/_requests/`) is never a probe target — only what sits
2277
+ * beside a spec is.
2532
2278
  */
2533
- const d15wStatusOnlyProbe = {
2279
+ const c13UnderscoredGround = {
2534
2280
  create(context) {
2535
- if (!segments(context.filename).includes("specs")) return {};
2536
- return { CallExpression(node) {
2537
- if (!isTestCallee(node.callee)) return;
2538
- const callback = findTestCallback(node.arguments ?? []);
2539
- if (callback === void 0) return;
2540
- let expectCalls = 0;
2541
- let statusProbes = 0;
2542
- walk(callback, (inner) => {
2543
- if (isExpectCall(inner)) expectCalls++;
2544
- if (isStatusProbe(inner)) statusProbes++;
2545
- });
2546
- if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
2547
- messageId: "statusOnlyProbe",
2548
- node
2549
- });
2281
+ const file = context.physicalFilename;
2282
+ const anchor = specsAnchor(file);
2283
+ if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
2284
+ return { Program(node) {
2285
+ const seen = /* @__PURE__ */ new Set();
2286
+ const probe = (parent, legacy) => {
2287
+ const path = join(parent, legacy);
2288
+ if (seen.has(path) || !isDirectory(path)) return;
2289
+ seen.add(path);
2290
+ context.report({
2291
+ data: {
2292
+ ground: RENAMED_GROUND_DIRS[legacy] ?? legacy,
2293
+ legacy
2294
+ },
2295
+ messageId: "notUnderscored",
2296
+ node
2297
+ });
2298
+ };
2299
+ for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe(dirname(file), legacy);
2300
+ probe(anchor.directory, "fixtures");
2550
2301
  } };
2551
2302
  },
2552
2303
  meta: {
2553
- docs: RULE_DOCS["d15w-status-only-probe"],
2554
- 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)." },
2555
- type: "suggestion"
2304
+ docs: RULE_DOCS["c13-underscored-ground"],
2305
+ 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)." },
2306
+ type: "problem"
2556
2307
  }
2557
2308
  };
2558
2309
  //#endregion
@@ -2606,7 +2357,7 @@ const d2AwaitIoMatcher = {
2606
2357
  },
2607
2358
  meta: {
2608
2359
  docs: RULE_DOCS["d2-await-io-matcher"],
2609
- 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)." },
2360
+ 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)." },
2610
2361
  type: "problem"
2611
2362
  }
2612
2363
  };
@@ -2660,7 +2411,7 @@ const d2wAwaitSyncMatcher = {
2660
2411
  },
2661
2412
  meta: {
2662
2413
  docs: RULE_DOCS["d2w-await-sync-matcher"],
2663
- 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)." },
2414
+ 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)." },
2664
2415
  type: "suggestion"
2665
2416
  }
2666
2417
  };
@@ -2688,7 +2439,7 @@ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
2688
2439
  "url",
2689
2440
  "uuid",
2690
2441
  "workdir"
2691
- ].join("|")})(?:#[\\w.-]+)?\\}\\}$`);
2442
+ ].join("|")})(?:#[\\w.-]+)?\\}\\}$`, "u");
2692
2443
  /**
2693
2444
  * Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
2694
2445
  * Accepts an arrow with an expression body or a single-return body whose
@@ -2700,8 +2451,9 @@ function isTokenEquivalentTransform(fn) {
2700
2451
  let expression = fn.body;
2701
2452
  if (expression?.type === "BlockStatement") {
2702
2453
  const statements = expression.body ?? [];
2703
- if (statements.length !== 1 || statements[0].type !== "ReturnStatement") return false;
2704
- expression = statements[0].argument;
2454
+ const [only] = statements;
2455
+ if (statements.length !== 1 || only?.type !== "ReturnStatement") return false;
2456
+ expression = only.argument;
2705
2457
  }
2706
2458
  let sawReplace = false;
2707
2459
  while (expression?.type === "CallExpression") {
@@ -2737,124 +2489,352 @@ const d6wTransformTokenEquivalent = {
2737
2489
  },
2738
2490
  meta: {
2739
2491
  docs: RULE_DOCS["d6w-transform-token-equivalent"],
2740
- 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)." },
2492
+ 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)." },
2741
2493
  type: "suggestion"
2742
2494
  }
2743
- };
2744
- //#endregion
2745
- //#region src/lint/rules/d8w-text-bypass.ts
2746
- /** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
2747
- const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
2495
+ };
2496
+ //#endregion
2497
+ //#region src/lint/rules/d8w-text-bypass.ts
2498
+ /** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
2499
+ const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
2500
+ /**
2501
+ * CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
2502
+ * `expect(result.text).toContain(…)` throws away the typed subject (its token
2503
+ * grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
2504
+ * Prefer asserting on the accessor subject itself.
2505
+ */
2506
+ const d8wTextBypass = {
2507
+ create(context) {
2508
+ return { CallExpression(node) {
2509
+ const callee = node.callee;
2510
+ const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
2511
+ if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
2512
+ const expectCall = callee?.object;
2513
+ if (expectCall?.type !== "CallExpression") return;
2514
+ const expectCallee = expectCall.callee;
2515
+ if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
2516
+ const subject = expectCall.arguments?.[0];
2517
+ if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
2518
+ data: { matcher },
2519
+ messageId: "textBypass",
2520
+ node
2521
+ });
2522
+ } };
2523
+ },
2524
+ meta: {
2525
+ docs: RULE_DOCS["d8w-text-bypass"],
2526
+ 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)." },
2527
+ type: "suggestion"
2528
+ }
2529
+ };
2530
+ //#endregion
2531
+ //#region src/lint/rules/d9w-single-use-ref.ts
2532
+ const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/u;
2533
+ /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2534
+ const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/gu;
2535
+ /** Is this call `match.ref(...)`? */
2536
+ function isMatchRef(node) {
2537
+ const callee = node.callee;
2538
+ if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
2539
+ const object = callee.object;
2540
+ return object?.type === "Identifier" && object.name === "match";
2541
+ }
2542
+ /** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
2543
+ function refsInFixture(target) {
2544
+ const refs = [];
2545
+ const collect = (text) => {
2546
+ for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
2547
+ };
2548
+ if (isFile(target)) {
2549
+ const text = readFileCached(target);
2550
+ if (text !== null) collect(text);
2551
+ return refs;
2552
+ }
2553
+ if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture(join(target, entry)));
2554
+ return refs;
2555
+ }
2556
+ /**
2557
+ * CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
2558
+ * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2559
+ * point of a capture is the equality check on later occurrences). Aggregating
2560
+ * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2561
+ * `_expected/` fixtures the file references — a ref that appears exactly once
2562
+ * everywhere is a plain matcher wearing a name.
2563
+ */
2564
+ const d9wSingleUseRef = {
2565
+ create(context) {
2566
+ const file = context.physicalFilename;
2567
+ if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
2568
+ const featureDir = dirname(file);
2569
+ return { Program(program) {
2570
+ const counts = /* @__PURE__ */ new Map();
2571
+ const codeNodes = /* @__PURE__ */ new Map();
2572
+ const fixtureNodes = /* @__PURE__ */ new Map();
2573
+ const bump = (ref) => {
2574
+ counts.set(ref, (counts.get(ref) ?? 0) + 1);
2575
+ };
2576
+ walk(program, (node) => {
2577
+ if (node.type !== "CallExpression") return;
2578
+ if (isMatchRef(node)) {
2579
+ const ref = stringValue(node.arguments?.[0]);
2580
+ if (ref !== void 0) {
2581
+ bump(ref);
2582
+ if (!codeNodes.has(ref)) codeNodes.set(ref, node);
2583
+ }
2584
+ return;
2585
+ }
2586
+ if (memberPropertyName(node.callee) === "toMatch") {
2587
+ const name = stringValue(node.arguments?.[0]);
2588
+ if (name !== void 0) {
2589
+ const target = join(featureDir, GROUND_EXPECTED, name.replace(/\/+$/u, ""));
2590
+ for (const ref of refsInFixture(target)) {
2591
+ bump(ref);
2592
+ if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
2593
+ }
2594
+ }
2595
+ }
2596
+ });
2597
+ for (const [ref, count] of counts) {
2598
+ const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
2599
+ if (count === 1 && node !== void 0) context.report({
2600
+ data: { ref },
2601
+ messageId: "singleUse",
2602
+ node
2603
+ });
2604
+ }
2605
+ } };
2606
+ },
2607
+ meta: {
2608
+ docs: RULE_DOCS["d9w-single-use-ref"],
2609
+ 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)." },
2610
+ type: "suggestion"
2611
+ }
2612
+ };
2613
+ //#endregion
2614
+ //#region src/lint/rules/d12w-response-body-probe.ts
2615
+ /** A test reading the response body this many times (or more) is a cluster. */
2616
+ const DEFAULT_THRESHOLD = 3;
2617
+ /** Is `node` the `x.response.body` member access itself? */
2618
+ function isResponseBody(node) {
2619
+ if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
2620
+ const object = node.object;
2621
+ return object !== void 0 && memberPropertyName(object) === "response";
2622
+ }
2623
+ /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
2624
+ function unwrap(node) {
2625
+ let current = node ?? void 0;
2626
+ while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
2627
+ else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
2628
+ else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
2629
+ else return current;
2630
+ }
2631
+ /**
2632
+ * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
2633
+ * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
2634
+ * of raw body probes — reading `.response.body` (or a variable cast from it)
2635
+ * `threshold`+ times in one test callback — should collapse into one golden:
2636
+ * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
2637
+ * (a legitimate scalpel: absence, a single-field delta, third-party output).
2638
+ *
2639
+ * A probe is a body-rooted read: a direct `.response.body` use, or a field
2640
+ * access on the local binding a `const body = result.response.body as {…}`
2641
+ * introduces. The count is the same threshold the golden transformation applies.
2642
+ */
2643
+ const d12wResponseBodyProbe = {
2644
+ create(context) {
2645
+ if (!segments(context.filename).includes("specs")) return {};
2646
+ const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
2647
+ return { CallExpression(node) {
2648
+ if (!isTestCallee(node.callee)) return;
2649
+ const callback = findTestCallback(node.arguments ?? []);
2650
+ if (callback === void 0) return;
2651
+ const aliases = /* @__PURE__ */ new Set();
2652
+ let responseBodyNodes = 0;
2653
+ walk(callback, (inner) => {
2654
+ if (isResponseBody(inner)) responseBodyNodes++;
2655
+ if (inner.type === "VariableDeclarator") {
2656
+ const init = unwrap(inner.init);
2657
+ const id = inner.id;
2658
+ if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
2659
+ }
2660
+ });
2661
+ let aliasReads = 0;
2662
+ walk(callback, (inner) => {
2663
+ if (inner.type !== "MemberExpression") return;
2664
+ const object = inner.object;
2665
+ if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
2666
+ });
2667
+ const probes = responseBodyNodes - aliases.size + aliasReads;
2668
+ if (probes >= threshold) context.report({
2669
+ data: { count: String(probes) },
2670
+ messageId: "bodyProbeCluster",
2671
+ node
2672
+ });
2673
+ } };
2674
+ },
2675
+ meta: {
2676
+ docs: RULE_DOCS["d12w-response-body-probe"],
2677
+ defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
2678
+ 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)." },
2679
+ schema: [{
2680
+ additionalProperties: false,
2681
+ properties: { threshold: {
2682
+ minimum: 1,
2683
+ type: "integer"
2684
+ } },
2685
+ type: "object"
2686
+ }],
2687
+ type: "suggestion"
2688
+ }
2689
+ };
2690
+ //#endregion
2691
+ //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
2692
+ /**
2693
+ * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
2694
+ * A frozen fixture opts out of update-mode rewriting, so it is exempt.
2695
+ */
2696
+ function hasFrozenOption(args) {
2697
+ const options = args[1];
2698
+ if (options === void 0 || options.type !== "ObjectExpression") return false;
2699
+ const value = findProperty(options, "frozen")?.value;
2700
+ return value?.type === "Literal" && value.value === true;
2701
+ }
2702
+ /**
2703
+ * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
2704
+ * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
2705
+ * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
2706
+ * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
2707
+ * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
2708
+ * expression: it stops at the first statement boundary or non-`expect` call, so
2709
+ * a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
2710
+ */
2711
+ const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
2712
+ "ArrowFunctionExpression",
2713
+ "AwaitExpression",
2714
+ "ChainExpression",
2715
+ "MemberExpression",
2716
+ "ParenthesizedExpression",
2717
+ "TSAsExpression",
2718
+ "TSNonNullExpression"
2719
+ ]);
2720
+ function isWrappedInExpect(node) {
2721
+ let current = node.parent;
2722
+ while (current !== void 0) {
2723
+ if (current.type === "CallExpression") {
2724
+ const callee = current.callee;
2725
+ return callee?.type === "Identifier" && callee.name === "expect";
2726
+ }
2727
+ if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
2728
+ current = current.parent;
2729
+ }
2730
+ return false;
2731
+ }
2748
2732
  /**
2749
- * CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
2750
- * `expect(result.text).toContain(…)` throws away the typed subject (its token
2751
- * grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
2752
- * Prefer asserting on the accessor subject itself.
2733
+ * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
2734
+ * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
2735
+ * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
2736
+ * unfrozen fixture is silently rewritten with the actual output — the matcher
2737
+ * writes instead of throwing — which destroys the negative case (the assertion
2738
+ * then no longer throws). Pass `{ frozen: true }` so the fixture is never
2739
+ * rewritten and the mismatch/error still throws in update mode.
2740
+ *
2741
+ * The structural signal is precise for the wrapped forms; a `toMatch` routed
2742
+ * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
2743
+ * is out of static reach — see the D13 process note for that residue.
2753
2744
  */
2754
- const d8wTextBypass = {
2745
+ const d13wUnfrozenNegativeFixture = {
2755
2746
  create(context) {
2747
+ if (!segments(context.filename).includes("specs")) return {};
2756
2748
  return { CallExpression(node) {
2757
2749
  const callee = node.callee;
2758
- const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
2759
- if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
2760
- const expectCall = callee?.object;
2761
- if (expectCall?.type !== "CallExpression") return;
2762
- const expectCallee = expectCall.callee;
2763
- if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
2764
- const subject = expectCall.arguments?.[0];
2765
- if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
2766
- data: { matcher },
2767
- messageId: "textBypass",
2750
+ if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
2751
+ if (!isWrappedInExpect(node)) return;
2752
+ if (hasFrozenOption(node.arguments ?? [])) return;
2753
+ context.report({
2754
+ messageId: "unfrozenNegativeFixture",
2768
2755
  node
2769
2756
  });
2770
2757
  } };
2771
2758
  },
2772
2759
  meta: {
2773
- docs: RULE_DOCS["d8w-text-bypass"],
2774
- 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)." },
2760
+ docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
2761
+ 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)." },
2775
2762
  type: "suggestion"
2776
2763
  }
2777
2764
  };
2778
2765
  //#endregion
2779
- //#region src/lint/rules/d9w-single-use-ref.ts
2780
- const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/;
2781
- /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2782
- const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/g;
2783
- /** Is this call `match.ref(...)`? */
2784
- function isMatchRef(node) {
2766
+ //#region src/lint/rules/d15w-status-only-probe.ts
2767
+ /** An HTTP status code lives in this range — the numeric-literal gate. */
2768
+ const MIN_STATUS = 100;
2769
+ const MAX_STATUS = 599;
2770
+ /** Matchers that pin a single value — the shape of a status probe. */
2771
+ const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
2772
+ /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
2773
+ function isExpectCall(node) {
2774
+ if (node?.type !== "CallExpression") return false;
2785
2775
  const callee = node.callee;
2786
- if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
2787
- const object = callee.object;
2788
- return object?.type === "Identifier" && object.name === "match";
2776
+ return callee?.type === "Identifier" && callee.name === "expect";
2789
2777
  }
2790
- /** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
2791
- function refsInFixture(target) {
2792
- const refs = [];
2793
- const collect = (text) => {
2794
- for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
2795
- };
2796
- if (isFile(target)) {
2797
- const text = readFileCached(target);
2798
- if (text !== null) collect(text);
2799
- return refs;
2800
- }
2801
- if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture(join(target, entry)));
2802
- return refs;
2778
+ /** Is `node` a numeric literal in the HTTP status range (100–599)? */
2779
+ function isStatusLiteral(node) {
2780
+ if (node?.type !== "Literal" || typeof node.value !== "number") return false;
2781
+ const { value } = node;
2782
+ return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
2803
2783
  }
2804
2784
  /**
2805
- * CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
2806
- * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2807
- * point of a capture is the equality check on later occurrences). Aggregating
2808
- * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2809
- * `_expected/` fixtures the file references — a ref that appears exactly once
2810
- * everywhere is a plain matcher wearing a name.
2785
+ * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
2786
+ * `X.status` is any `.status` member access and `N` is a numeric literal in the
2787
+ * HTTP status range? The numeric-literal gate keeps container-status STRINGS
2788
+ * (`expect(neo.status).toBe('running')`) out.
2811
2789
  */
2812
- const d9wSingleUseRef = {
2790
+ function isStatusProbe(node) {
2791
+ if (node.type !== "CallExpression") return false;
2792
+ const callee = node.callee;
2793
+ if (callee?.type !== "MemberExpression") return false;
2794
+ const matcher = memberPropertyName(callee);
2795
+ if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
2796
+ const expectCall = callee.object;
2797
+ if (!isExpectCall(expectCall)) return false;
2798
+ const subject = (expectCall?.arguments ?? [])[0];
2799
+ if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
2800
+ const expected = (node.arguments ?? [])[0];
2801
+ return isStatusLiteral(expected);
2802
+ }
2803
+ /**
2804
+ * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
2805
+ * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
2806
+ * 100–599) throws away the full-response golden. Pin the whole response instead:
2807
+ * `expect(result.response).toMatch('case.http')`.
2808
+ *
2809
+ * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
2810
+ * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
2811
+ * threshold yet still hides everything the response carries. The rule fires only
2812
+ * when EVERY `expect(…)` in the callback is a status probe — a status probe
2813
+ * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
2814
+ * the legitimate scalpel and stays silent.
2815
+ */
2816
+ const d15wStatusOnlyProbe = {
2813
2817
  create(context) {
2814
- const file = context.physicalFilename;
2815
- if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
2816
- const featureDir = dirname(file);
2817
- return { Program(program) {
2818
- const counts = /* @__PURE__ */ new Map();
2819
- const codeNodes = /* @__PURE__ */ new Map();
2820
- const fixtureNodes = /* @__PURE__ */ new Map();
2821
- const bump = (ref) => {
2822
- counts.set(ref, (counts.get(ref) ?? 0) + 1);
2823
- };
2824
- walk(program, (node) => {
2825
- if (node.type !== "CallExpression") return;
2826
- if (isMatchRef(node)) {
2827
- const ref = stringValue(node.arguments?.[0]);
2828
- if (ref !== void 0) {
2829
- bump(ref);
2830
- if (!codeNodes.has(ref)) codeNodes.set(ref, node);
2831
- }
2832
- return;
2833
- }
2834
- if (memberPropertyName(node.callee) === "toMatch") {
2835
- const name = stringValue(node.arguments?.[0]);
2836
- if (name !== void 0) {
2837
- const target = join(featureDir, GROUND_EXPECTED, name.replace(/\/+$/, ""));
2838
- for (const ref of refsInFixture(target)) {
2839
- bump(ref);
2840
- if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
2841
- }
2842
- }
2843
- }
2818
+ if (!segments(context.filename).includes("specs")) return {};
2819
+ return { CallExpression(node) {
2820
+ if (!isTestCallee(node.callee)) return;
2821
+ const callback = findTestCallback(node.arguments ?? []);
2822
+ if (callback === void 0) return;
2823
+ let expectCalls = 0;
2824
+ let statusProbes = 0;
2825
+ walk(callback, (inner) => {
2826
+ if (isExpectCall(inner)) expectCalls++;
2827
+ if (isStatusProbe(inner)) statusProbes++;
2828
+ });
2829
+ if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
2830
+ messageId: "statusOnlyProbe",
2831
+ node
2844
2832
  });
2845
- for (const [ref, count] of counts) {
2846
- const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
2847
- if (count === 1 && node !== void 0) context.report({
2848
- data: { ref },
2849
- messageId: "singleUse",
2850
- node
2851
- });
2852
- }
2853
2833
  } };
2854
2834
  },
2855
2835
  meta: {
2856
- docs: RULE_DOCS["d9w-single-use-ref"],
2857
- 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)." },
2836
+ docs: RULE_DOCS["d15w-status-only-probe"],
2837
+ 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)." },
2858
2838
  type: "suggestion"
2859
2839
  }
2860
2840
  };
@@ -2912,7 +2892,7 @@ function findOwnManifest(startDir) {
2912
2892
  */
2913
2893
  function declaredSubpaths(startDir) {
2914
2894
  if (startDir === void 0 && cached !== null) return cached;
2915
- const exports = findOwnManifest(startDir ?? dirname(fileURLToPath(import.meta.url)))?.exports;
2895
+ const exports = findOwnManifest(startDir ?? import.meta.dirname)?.exports;
2916
2896
  const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
2917
2897
  if (startDir === void 0) cached = subpaths;
2918
2898
  return subpaths;
@@ -2948,15 +2928,15 @@ const f1NoSubpathImport = {
2948
2928
  },
2949
2929
  meta: {
2950
2930
  docs: RULE_DOCS["f1-no-subpath-import"],
2951
- messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/10-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2931
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/13-linting.md). Published subpaths, exempt everywhere: {{published}}." },
2952
2932
  type: "problem"
2953
2933
  }
2954
2934
  };
2955
2935
  //#endregion
2956
2936
  //#region src/lint/rules/f2-no-test-imports-in-prod.ts
2957
- const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/;
2958
- const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/;
2959
- const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/;
2937
+ const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/u;
2938
+ const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/u;
2939
+ const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/u;
2960
2940
  const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2961
2941
  /**
2962
2942
  * CONVENTIONS F2 — production code never imports a test artefact. In a
@@ -2996,7 +2976,7 @@ const f2NoTestImportsInProd = {
2996
2976
  },
2997
2977
  meta: {
2998
2978
  docs: RULE_DOCS["f2-no-test-imports-in-prod"],
2999
- 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)." },
2979
+ 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)." },
3000
2980
  type: "problem"
3001
2981
  }
3002
2982
  };
@@ -3013,15 +2993,15 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
3013
2993
  * `server: () => createApp()` importing `../../src/app.js`).
3014
2994
  */
3015
2995
  const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
3016
- "core",
3017
2996
  "integrations",
3018
2997
  "lint",
2998
+ "specification",
3019
2999
  "vitest"
3020
3000
  ]);
3021
3001
  /**
3022
3002
  * CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
3023
3003
  * forbidden: a relative path resolving inside the framework repo's
3024
- * `src/{core,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3004
+ * `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
3025
3005
  * other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
3026
3006
  * imports of its OWN app source are always allowed — that is the pattern.
3027
3007
  *
@@ -3047,7 +3027,7 @@ const f3SpecsPublicEntry = {
3047
3027
  }
3048
3028
  const marker = source.indexOf("/src/");
3049
3029
  if (marker === -1 && !source.startsWith("src/")) return;
3050
- const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0];
3030
+ const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0] ?? "";
3051
3031
  if (!FRAMEWORK_LAYERS.has(layer)) return;
3052
3032
  if (underIntegrations && layer === "integrations") return;
3053
3033
  context.report({
@@ -3059,14 +3039,14 @@ const f3SpecsPublicEntry = {
3059
3039
  },
3060
3040
  meta: {
3061
3041
  docs: RULE_DOCS["f3-specs-public-entry"],
3062
- 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)." },
3042
+ 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)." },
3063
3043
  type: "problem"
3064
3044
  }
3065
3045
  };
3066
3046
  //#endregion
3067
3047
  //#region src/lint/rules/f4-no-test-to-test-import.ts
3068
- const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/;
3069
- const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/;
3048
+ const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/u;
3049
+ const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/u;
3070
3050
  /**
3071
3051
  * CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
3072
3052
  * test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
@@ -3085,14 +3065,14 @@ const f4NoTestToTestImport = {
3085
3065
  },
3086
3066
  meta: {
3087
3067
  docs: RULE_DOCS["f4-no-test-to-test-import"],
3088
- 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)." },
3068
+ 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)." },
3089
3069
  type: "problem"
3090
3070
  }
3091
3071
  };
3092
3072
  //#endregion
3093
3073
  //#region src/lint/rules/f5-fixtures-only-from-tests.ts
3094
- const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/;
3095
- const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/;
3074
+ const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/u;
3075
+ const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/u;
3096
3076
  /**
3097
3077
  * CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
3098
3078
  * `*.test.ts` files. Any other importer (prod code, a specification file,
@@ -3111,13 +3091,13 @@ const f5FixturesOnlyFromTests = {
3111
3091
  },
3112
3092
  meta: {
3113
3093
  docs: RULE_DOCS["f5-fixtures-only-from-tests"],
3114
- messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/10-linting.md)." },
3094
+ messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/13-linting.md)." },
3115
3095
  type: "problem"
3116
3096
  }
3117
3097
  };
3118
3098
  //#endregion
3119
3099
  //#region src/lint/rules/i1-layer-boundaries.ts
3120
- const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
3100
+ const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
3121
3101
  function matchesPackage(source, packages) {
3122
3102
  return packages.some((name) => source === name || source.startsWith(`${name}/`));
3123
3103
  }
@@ -3129,7 +3109,7 @@ function pathInsideSrc(path) {
3129
3109
  }
3130
3110
  /** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
3131
3111
  function withoutExtension(path) {
3132
- return path.replace(/\.[cm]?[jt]sx?$/, "");
3112
+ return path.replace(/\.[cm]?[jt]sx?$/u, "");
3133
3113
  }
3134
3114
  /** Does `target` match one of the declared paths (prefix, or exact module)? */
3135
3115
  function matchesPath(target, patterns) {
@@ -3203,8 +3183,8 @@ const i1LayerBoundaries = {
3203
3183
  defaultOptions: [{ layers: {} }],
3204
3184
  docs: RULE_DOCS["i1-layer-boundaries"],
3205
3185
  messages: {
3206
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/10-linting.md).",
3207
- foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/10-linting.md)."
3186
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/13-linting.md).",
3187
+ foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/13-linting.md)."
3208
3188
  },
3209
3189
  schema: [{
3210
3190
  additionalProperties: false,
@@ -3246,7 +3226,7 @@ const i1LayerBoundaries = {
3246
3226
  };
3247
3227
  //#endregion
3248
3228
  //#region src/lint/rules/i2-sibling-test-naming.ts
3249
- const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/;
3229
+ const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
3250
3230
  /**
3251
3231
  * CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
3252
3232
  * `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
@@ -3274,7 +3254,7 @@ const i2SiblingTestNaming = {
3274
3254
  ".js"
3275
3255
  ].map((extension) => file.replace(TEST_FILE$1, extension));
3276
3256
  if (!neighbours.some(isFile)) context.report({
3277
- data: { expected: neighbours[0].split(/[/\\]/).at(-1) ?? "" },
3257
+ data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
3278
3258
  messageId: "orphanTest",
3279
3259
  node
3280
3260
  });
@@ -3292,16 +3272,16 @@ const i2SiblingTestNaming = {
3292
3272
  meta: {
3293
3273
  docs: RULE_DOCS["i2-sibling-test-naming"],
3294
3274
  messages: {
3295
- 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/.",
3296
- 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).",
3297
- testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/10-linting.md)."
3275
+ 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/.",
3276
+ 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).",
3277
+ testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/13-linting.md)."
3298
3278
  },
3299
3279
  type: "problem"
3300
3280
  }
3301
3281
  };
3302
3282
  //#endregion
3303
3283
  //#region src/lint/rules/i4-no-vi-mock-in-src.ts
3304
- const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
3284
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
3305
3285
  const DATA_ASSET = new RegExp(`\\.(?:${[
3306
3286
  "avif",
3307
3287
  "bin",
@@ -3339,10 +3319,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
3339
3319
  "yaml",
3340
3320
  "yml",
3341
3321
  "zip"
3342
- ].join("|")})$`, "i");
3322
+ ].join("|")})$`, "iu");
3343
3323
  /** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
3344
3324
  function withoutSuffix(source) {
3345
- return source.replace(/[?#].*$/, "");
3325
+ return source.replace(/[?#].*$/u, "");
3346
3326
  }
3347
3327
  /**
3348
3328
  * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
@@ -3394,57 +3374,14 @@ const i4NoViMockInSrc = {
3394
3374
  meta: {
3395
3375
  docs: RULE_DOCS["i4-no-vi-mock-in-src"],
3396
3376
  messages: {
3397
- 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).",
3398
- 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).",
3399
- viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/10-linting.md)."
3377
+ 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).",
3378
+ 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).",
3379
+ viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/13-linting.md)."
3400
3380
  },
3401
3381
  type: "problem"
3402
3382
  }
3403
3383
  };
3404
3384
  //#endregion
3405
- //#region src/lint/rules/j1-no-only-skip.ts
3406
- /** Test runners whose `.only` / `.skip` modifiers must never be committed. */
3407
- const RUNNERS = /* @__PURE__ */ new Set([
3408
- "describe",
3409
- "it",
3410
- "test"
3411
- ]);
3412
- /** The focus/skip modifiers CONVENTIONS J1 forbids. */
3413
- const FORBIDDEN = /* @__PURE__ */ new Set(["only", "skip"]);
3414
- /**
3415
- * CONVENTIONS J1 — no committed `.only` / `.skip`.
3416
- *
3417
- * Flags the member form used across the specs: `describe.only`, `test.only`,
3418
- * `it.only` and their `.skip` counterparts. Modifiers that legitimately gate a
3419
- * test (`test.skipIf`, `test.runIf`) are untouched — only the bare `only`/`skip`
3420
- * that pin or disable a test in a commit are reported.
3421
- */
3422
- const j1NoOnlySkip = {
3423
- create(context) {
3424
- return { MemberExpression(node) {
3425
- if (node.computed === true) return;
3426
- const object = node.object;
3427
- const property = node.property;
3428
- if (object?.type !== "Identifier" || property?.type !== "Identifier") return;
3429
- const runner = object.name;
3430
- const modifier = property.name;
3431
- if (RUNNERS.has(runner) && FORBIDDEN.has(modifier)) context.report({
3432
- data: {
3433
- modifier,
3434
- runner
3435
- },
3436
- messageId: "forbidden",
3437
- node
3438
- });
3439
- } };
3440
- },
3441
- meta: {
3442
- docs: RULE_DOCS["j1-no-only-skip"],
3443
- messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (J1 — see docs/10-linting.md). Remove it before committing." },
3444
- type: "problem"
3445
- }
3446
- };
3447
- //#endregion
3448
3385
  //#region src/lint/rules/j2-no-sleep-in-specs.ts
3449
3386
  /**
3450
3387
  * CONVENTIONS J2 — spec files (`specs/**`) contain no arbitrary sleeps:
@@ -3483,138 +3420,13 @@ const j2NoSleepInSpecs = {
3483
3420
  meta: {
3484
3421
  docs: RULE_DOCS["j2-no-sleep-in-specs"],
3485
3422
  messages: {
3486
- sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md).",
3487
- timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md)."
3423
+ sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md).",
3424
+ timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md)."
3488
3425
  },
3489
3426
  type: "problem"
3490
3427
  }
3491
3428
  };
3492
3429
  //#endregion
3493
- //#region src/lint/rules/j3-no-expectless-test.ts
3494
- /** Does `callback` contain at least one `expect(…)` call? */
3495
- function hasExpect(callback) {
3496
- let found = false;
3497
- walk(callback, (node) => {
3498
- if (found || node.type !== "CallExpression") return;
3499
- const callee = node.callee;
3500
- if (callee?.type === "Identifier" && callee.name === "expect") found = true;
3501
- });
3502
- return found;
3503
- }
3504
- /**
3505
- * CONVENTIONS J3 — a test asserts something. A `test('…', () => { … })` whose
3506
- * body contains no `expect(…)` is either dead or a silent no-op.
3507
- *
3508
- * Reuses the shared B4 test-shape helpers. `test.todo(...)` (and any other
3509
- * callback-less form) is skipped — there is nothing to assert yet.
3510
- */
3511
- const j3NoExpectlessTest = {
3512
- create(context) {
3513
- return { CallExpression(node) {
3514
- const callee = node.callee;
3515
- if (!isTestCallee(callee)) return;
3516
- if (testCalleeHasModifier(callee, "todo")) return;
3517
- const callback = findTestCallback(node.arguments ?? []);
3518
- if (callback === void 0) return;
3519
- if (!hasExpect(callback)) context.report({
3520
- messageId: "noExpect",
3521
- node
3522
- });
3523
- } };
3524
- },
3525
- meta: {
3526
- docs: RULE_DOCS["j3-no-expectless-test"],
3527
- messages: { noExpect: "Test has no `expect(…)` — a test must assert something (J3 — see docs/10-linting.md). Use `test.todo` for a pending test." },
3528
- type: "problem"
3529
- }
3530
- };
3531
- //#endregion
3532
- //#region src/lint/rules/j4-unique-test-names.ts
3533
- /**
3534
- * CONVENTIONS J4 — test names are the sole description of behaviour (B3), so two
3535
- * tests sharing a literal name in one file are a copy-paste smell: one shadows
3536
- * the other in the reporter. Flags the second (and later) occurrence.
3537
- *
3538
- * `.each` / parametrized tests are skipped — their name is a template expanded
3539
- * per row, collisions there are by construction.
3540
- */
3541
- const j4UniqueTestNames = {
3542
- create(context) {
3543
- const seen = /* @__PURE__ */ new Set();
3544
- return { CallExpression(node) {
3545
- const callee = node.callee;
3546
- if (!isTestCallee(callee)) return;
3547
- if (testCalleeHasModifier(callee, "each")) return;
3548
- const name = testName(node);
3549
- if (name === void 0) return;
3550
- if (seen.has(name)) {
3551
- context.report({
3552
- data: { name },
3553
- messageId: "duplicate",
3554
- node
3555
- });
3556
- return;
3557
- }
3558
- seen.add(name);
3559
- } };
3560
- },
3561
- meta: {
3562
- docs: RULE_DOCS["j4-unique-test-names"],
3563
- 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)." },
3564
- type: "problem"
3565
- }
3566
- };
3567
- //#endregion
3568
- //#region src/lint/rules/j5-lowercase-title.ts
3569
- /** The block/test introducers whose title literal C1' constrains. */
3570
- const TITLED = /* @__PURE__ */ new Set([
3571
- "describe",
3572
- "it",
3573
- "test"
3574
- ]);
3575
- /** An identifier-shaped, all-caps/underscored first word: `VALID_CATEGORIES`,
3576
- * `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
3577
- * misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
3578
- const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
3579
- /**
3580
- * CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
3581
- * literal must be lowercase.
3582
- *
3583
- * The test name is the sole description of behaviour (B3): a sentence fragment,
3584
- * lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
3585
- * whose first WORD is an identifier-shaped all-caps/underscored token
3586
- * (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
3587
- * lowercasing a named symbol would misspell it — and titles opening on a
3588
- * non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
3589
- * first words are enforced. Covers the modifier and parametrized forms
3590
- * (`test.only`, `describe.each(...)(...)`) via the chain root.
3591
- */
3592
- const j5LowercaseTitle = {
3593
- create(context) {
3594
- return { CallExpression(node) {
3595
- const callee = node.callee;
3596
- const root = chainRootName(callee);
3597
- if (root === void 0 || !TITLED.has(root)) return;
3598
- const title = stringValue(node.arguments?.[0]);
3599
- if (title === void 0) return;
3600
- const first = [...title][0];
3601
- if (first === void 0 || !/\p{L}/u.test(first)) return;
3602
- const firstWord = title.split(/\s+/u)[0];
3603
- if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
3604
- if (first !== first.toLocaleLowerCase()) context.report({
3605
- data: { runner: root },
3606
- messageId: "uppercase",
3607
- node
3608
- });
3609
- } };
3610
- },
3611
- meta: {
3612
- docs: RULE_DOCS["j5-lowercase-title"],
3613
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
3614
- type: "problem"
3615
- }
3616
- };
3617
- //#endregion
3618
3430
  //#region src/lint/rules/w1-scenario-pure.ts
3619
3431
  /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3620
3432
  const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
@@ -3644,7 +3456,7 @@ const w1ScenarioPure = {
3644
3456
  },
3645
3457
  meta: {
3646
3458
  docs: RULE_DOCS["w1-scenario-pure"],
3647
- 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)." },
3459
+ 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)." },
3648
3460
  type: "problem"
3649
3461
  }
3650
3462
  };
@@ -3660,7 +3472,7 @@ const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
3660
3472
  *
3661
3473
  * Registered in a consumer's (or this repo's own) `oxlint.config.ts` via
3662
3474
  * `jsPlugins: ['@jterrazz/test/oxlint']` and referenced as `jterrazz/<rule>` in
3663
- * the `rules` map, e.g. `'jterrazz/j1-no-only-skip': 'error'` — or enabled
3475
+ * the `rules` map, e.g. `'jterrazz/j2-no-sleep-in-specs': 'error'` — or enabled
3664
3476
  * wholesale by spreading {@link recommendedRules}.
3665
3477
  *
3666
3478
  * This entry is bundled by tsdown (`dist/oxlint.js`); rules import nothing from
@@ -3710,11 +3522,7 @@ const plugin = {
3710
3522
  "i1-layer-boundaries": i1LayerBoundaries,
3711
3523
  "i2-sibling-test-naming": i2SiblingTestNaming,
3712
3524
  "i4-no-vi-mock-in-src": i4NoViMockInSrc,
3713
- "j1-no-only-skip": j1NoOnlySkip,
3714
3525
  "j2-no-sleep-in-specs": j2NoSleepInSpecs,
3715
- "j3-no-expectless-test": j3NoExpectlessTest,
3716
- "j4-unique-test-names": j4UniqueTestNames,
3717
- "j5-lowercase-title": j5LowercaseTitle,
3718
3526
  "w1-scenario-pure": w1ScenarioPure,
3719
3527
  "w2w-user-facing-elements": {
3720
3528
  create(context) {
@@ -3736,7 +3544,7 @@ const plugin = {
3736
3544
  },
3737
3545
  meta: {
3738
3546
  docs: RULE_DOCS["w2w-user-facing-elements"],
3739
- 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)." },
3547
+ 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)." },
3740
3548
  type: "suggestion"
3741
3549
  }
3742
3550
  }
@@ -3754,7 +3562,7 @@ const plugin = {
3754
3562
  *
3755
3563
  * 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
3756
3564
  */
3757
- const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/.test(rule) ? "warn" : "error"]));
3565
+ const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/u.test(rule) ? "warn" : "error"]));
3758
3566
  /**
3759
3567
  * The composable testing fragment — wire the plugin, enable the whole
3760
3568
  * catalogue, and ship the one override every strict consumer needs. Designed