@jterrazz/test 7.1.0 → 9.0.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.
Files changed (48) hide show
  1. package/README.md +209 -230
  2. package/dist/checker.d.ts +1 -0
  3. package/dist/checker.js +741 -0
  4. package/dist/index.d.ts +1296 -529
  5. package/dist/index.js +3303 -1261
  6. package/dist/intercept.js +129 -290
  7. package/dist/match.js +153 -0
  8. package/dist/oxlint.cjs +2931 -0
  9. package/dist/oxlint.d.cts +150 -0
  10. package/dist/oxlint.d.ts +150 -0
  11. package/dist/oxlint.js +2925 -0
  12. package/package.json +47 -41
  13. package/dist/chunk.cjs +0 -28
  14. package/dist/index.cjs +0 -1814
  15. package/dist/index.cjs.map +0 -1
  16. package/dist/index.d.cts +0 -734
  17. package/dist/index.js.map +0 -1
  18. package/dist/intercept.cjs +0 -313
  19. package/dist/intercept.cjs.map +0 -1
  20. package/dist/intercept.d.cts +0 -105
  21. package/dist/intercept.d.ts +0 -105
  22. package/dist/intercept.js.map +0 -1
  23. package/dist/intercept2.cjs +0 -81
  24. package/dist/intercept2.cjs.map +0 -1
  25. package/dist/intercept2.js +0 -81
  26. package/dist/intercept2.js.map +0 -1
  27. package/dist/mock-of.cjs +0 -32
  28. package/dist/mock-of.cjs.map +0 -1
  29. package/dist/mock-of.d.cts +0 -27
  30. package/dist/mock-of.d.ts +0 -27
  31. package/dist/mock-of.js +0 -19
  32. package/dist/mock-of.js.map +0 -1
  33. package/dist/mock.cjs +0 -4
  34. package/dist/mock.d.cts +0 -2
  35. package/dist/mock.d.ts +0 -2
  36. package/dist/mock.js +0 -2
  37. package/dist/services.cjs +0 -5
  38. package/dist/services.d.cts +0 -2
  39. package/dist/services.d.ts +0 -2
  40. package/dist/services.js +0 -2
  41. package/dist/sqlite.adapter.cjs +0 -350
  42. package/dist/sqlite.adapter.cjs.map +0 -1
  43. package/dist/sqlite.adapter.d.cts +0 -209
  44. package/dist/sqlite.adapter.d.ts +0 -209
  45. package/dist/sqlite.adapter.js +0 -331
  46. package/dist/sqlite.adapter.js.map +0 -1
  47. package/dist/types.d.cts +0 -42
  48. package/dist/types.d.ts +0 -42
package/dist/oxlint.js ADDED
@@ -0,0 +1,2925 @@
1
+ import { basename, dirname, join, resolve } from "node:path";
2
+ import { readFileSync, readdirSync, statSync } from "node:fs";
3
+ //#region src/lint/ast.ts
4
+ /**
5
+ * Shared AST helpers for the rule files. Everything here is pure and
6
+ * structural: rules narrow nodes by `type` and read fields defensively, so the
7
+ * layer stays decoupled from oxlint's internal (alpha) typings.
8
+ */
9
+ /** Split a path into its non-empty segments (posix or win separators). */
10
+ function segments(path) {
11
+ return path.split(/[/\\]/).filter(Boolean);
12
+ }
13
+ /** The string value of a plain string literal (or a template with no holes). */
14
+ function stringValue(node) {
15
+ if (node === void 0) return;
16
+ if (node.type === "Literal" && typeof node.value === "string") return node.value;
17
+ if (node.type === "TemplateLiteral") {
18
+ const expressions = node.expressions;
19
+ const quasis = node.quasis;
20
+ if (expressions?.length === 0 && quasis?.length === 1) return quasis[0].value?.cooked;
21
+ }
22
+ }
23
+ /** The property name of a non-computed member expression, if identifiable. */
24
+ function memberPropertyName(node) {
25
+ if (node.type !== "MemberExpression" || node.computed === true) return;
26
+ const property = node.property;
27
+ return property?.type === "Identifier" ? property.name : void 0;
28
+ }
29
+ /**
30
+ * The root identifier name of a member/call chain:
31
+ * `dockerCli.fixture(x).exec(y)` → `dockerCli`.
32
+ */
33
+ function chainRootName(node) {
34
+ let current = node;
35
+ while (current !== void 0) {
36
+ if (current.type === "Identifier") return current.name;
37
+ if (current.type === "MemberExpression") current = current.object;
38
+ else if (current.type === "CallExpression") current = current.callee;
39
+ else if (current.type === "AwaitExpression" || current.type === "ChainExpression") current = current.argument ?? current.expression;
40
+ else return;
41
+ }
42
+ }
43
+ /**
44
+ * Is this call `specification.<member>(…)`? Returns the member name when the
45
+ * callee is a non-computed member on the `specification` identifier.
46
+ */
47
+ function specificationMember(callNode) {
48
+ const callee = callNode.callee;
49
+ if (callee?.type !== "MemberExpression") return;
50
+ const object = callee.object;
51
+ if (object?.type !== "Identifier" || object.name !== "specification") return;
52
+ return memberPropertyName(callee);
53
+ }
54
+ /** Find a named, non-computed property in an ObjectExpression. */
55
+ function findProperty(objectNode, name) {
56
+ if (objectNode.type !== "ObjectExpression") return;
57
+ return (objectNode.properties ?? []).find((property) => propertyKeyName(property) === name);
58
+ }
59
+ /** The name of a property key (Identifier or string literal), if identifiable. */
60
+ function propertyKeyName(property) {
61
+ if (property.type !== "Property" || property.computed === true) return;
62
+ const key = property.key;
63
+ if (key?.type === "Identifier") return key.name;
64
+ if (key?.type === "Literal") return String(key.value);
65
+ }
66
+ /**
67
+ * Depth-first walk over every node reachable from `root`, calling `visit` on
68
+ * each. Used by rules that need a whole-file view from the `Program` handler
69
+ * (oxlint runs visitors per node type; cross-node analyses walk manually).
70
+ */
71
+ function walk(root, visit) {
72
+ visit(root);
73
+ for (const key of Object.keys(root)) {
74
+ if (key === "parent") continue;
75
+ const value = root[key];
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) if (isNode(item)) walk(item, visit);
78
+ } else if (isNode(value)) walk(value, visit);
79
+ }
80
+ }
81
+ function isNode(value) {
82
+ return typeof value === "object" && value !== null && typeof value.type === "string";
83
+ }
84
+ /** The source start offset of a node or comment (oxlint `start`, else `range[0]`). */
85
+ function nodeStart(node) {
86
+ if (node === void 0) return -1;
87
+ if (typeof node.start === "number") return node.start;
88
+ const range = node.range;
89
+ return Array.isArray(range) && typeof range[0] === "number" ? range[0] : -1;
90
+ }
91
+ /** Bare identifiers that introduce a test. */
92
+ const TEST_IDENTIFIERS = /* @__PURE__ */ new Set(["it", "test"]);
93
+ /**
94
+ * Is `callee` a `test` / `it` invocation? Handles the modifier forms used across
95
+ * the specs: bare `test(...)`, member `test.only(...)` / `test.concurrent(...)`,
96
+ * and the call-returning wrappers `test.skipIf(cond)(...)` / `test.each(...)(...)`.
97
+ * Shared by B4 / J3 / J4.
98
+ */
99
+ function isTestCallee(callee) {
100
+ if (callee === void 0) return false;
101
+ if (callee.type === "Identifier") return TEST_IDENTIFIERS.has(callee.name);
102
+ if (callee.type === "MemberExpression") return isTestCallee(callee.object);
103
+ if (callee.type === "CallExpression") return isTestCallee(callee.callee);
104
+ return false;
105
+ }
106
+ /** Does the test callee chain carry a modifier member with the given name? */
107
+ function testCalleeHasModifier(callee, modifier) {
108
+ if (callee === void 0) return false;
109
+ if (callee.type === "MemberExpression") return memberPropertyName(callee) === modifier || testCalleeHasModifier(callee.object, modifier);
110
+ if (callee.type === "CallExpression") return testCalleeHasModifier(callee.callee, modifier);
111
+ return false;
112
+ }
113
+ /** The callback argument of a test call (arrow or function expression), if any. */
114
+ function findTestCallback(args) {
115
+ return args.find((arg) => arg.type === "ArrowFunctionExpression" || arg.type === "FunctionExpression");
116
+ }
117
+ /** The static string name of a test call (`test('name', …)`), if a literal. */
118
+ function testName(callNode) {
119
+ return stringValue((callNode.arguments ?? [])[0]);
120
+ }
121
+ /**
122
+ * Visitor fragment collecting every import-like source: static imports,
123
+ * dynamic `import()`, and re-exports. Rules spread this into their visitor.
124
+ */
125
+ function importSourceVisitor(onSource) {
126
+ const fromSourceField = (node) => {
127
+ const source = node.source ?? void 0;
128
+ const value = stringValue(source);
129
+ if (value !== void 0) onSource({
130
+ node: source ?? node,
131
+ source: value
132
+ });
133
+ };
134
+ return {
135
+ ExportAllDeclaration: fromSourceField,
136
+ ExportNamedDeclaration: fromSourceField,
137
+ ImportDeclaration: fromSourceField,
138
+ ImportExpression: fromSourceField
139
+ };
140
+ }
141
+ //#endregion
142
+ //#region src/lint/manifest.ts
143
+ /**
144
+ * The **statique** channel — one entry per shipped `jterrazz/*` rule. Each rule
145
+ * file attaches its entry as `meta.docs`, so a rule and its normative text can
146
+ * never drift (the completeness meta-test asserts every rule carries one).
147
+ */
148
+ const RULE_DOCS = {
149
+ "a1-specification-file": {
150
+ channel: "statique",
151
+ convention: "Un runner ne se crée que dans un fichier `*.specification.ts` sous `specs/` : appeler `specification.*` ailleurs est une erreur.",
152
+ family: "A",
153
+ id: "A1",
154
+ rationale: "Ancrer les runners à un nom de fichier reconnaissable rend le point d’entrée détectable et garde les tests déclaratifs."
155
+ },
156
+ "a10-duplicate-binding": {
157
+ channel: "statique",
158
+ convention: "Dans un même record `services`, deux clés ne peuvent pas se lier au même service compose (même dérivation kebab-case, ou même `composeService`).",
159
+ family: "A",
160
+ id: "A10",
161
+ rationale: "Une seconde liaison masquerait silencieusement la première dans ce qui est une map."
162
+ },
163
+ "a2-known-constructors": {
164
+ channel: "statique",
165
+ convention: "Trois constructeurs et seulement trois : `specification.api()`, `specification.jobs()`, `specification.cli(bin)` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
166
+ family: "A",
167
+ id: "A2",
168
+ rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
169
+ },
170
+ "a3-no-destructure-alias": {
171
+ channel: "statique",
172
+ convention: "Le retour se destructure avec le nom canonique du constructeur, sans alias (`{ api, cleanup, docker }`) ; renommer (`{ api: monApi }`) est une erreur.",
173
+ family: "A",
174
+ id: "A3",
175
+ rationale: "Un nom d’instance unique par facette rend chaque spec lisible sans contexte local."
176
+ },
177
+ "a4-cleanup-afterall": {
178
+ channel: "statique",
179
+ convention: "Le fichier de specification passe `cleanup` à `afterAll` ; un `cleanup` destructuré mais jamais transmis est une erreur.",
180
+ family: "A",
181
+ id: "A4",
182
+ rationale: "Garantir le teardown évite les conteneurs et connexions qui fuient entre fichiers."
183
+ },
184
+ "a5-mode-with-server": {
185
+ channel: "statique",
186
+ convention: "`mode` n’existe que sur `specification.api()` et n’est jamais hardcodé quand `server` est défini — le switch vit dans `vitest.config.ts`.",
187
+ family: "A",
188
+ id: "A5",
189
+ rationale: "Sortir le mode du fichier de spec permet d’exécuter le même test en node et en compose sans le modifier."
190
+ },
191
+ "a6w-redundant-compose-service": {
192
+ channel: "statique",
193
+ convention: "`composeService:` dérivable de la clé (égal à la clé exacte ou à sa conversion kebab-case) est redondant → warning.",
194
+ family: "A",
195
+ id: "A6",
196
+ rationale: "Signaler la redondance garde les records `services` minimaux et évite le bruit qui masque les vrais overrides."
197
+ },
198
+ "a9w-redundant-root": {
199
+ channel: "statique",
200
+ convention: "`root` pointant vers le dossier que la remontée automatique aurait trouvé est redondant → warning.",
201
+ family: "A",
202
+ id: "A9",
203
+ rationale: "La détection par convention doit rester le défaut ; un `root` explicite ne se justifie que là où elle échoue."
204
+ },
205
+ "b2-known-fixture-marker": {
206
+ channel: "statique",
207
+ convention: "Un marqueur `$…` inconnu dans un littéral passé à `.fixture()` est une erreur (seul `$FIXTURES` est connu).",
208
+ family: "B",
209
+ id: "B2",
210
+ rationale: "Attraper un marqueur fautif statiquement évite un chemin de fixture résolu au hasard à l’exécution."
211
+ },
212
+ "b4-given-then": {
213
+ channel: "statique",
214
+ convention: "Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur.",
215
+ family: "B",
216
+ id: "B4",
217
+ rationale: "La narration Given/Then rend l’intention du test lisible sans lire les assertions."
218
+ },
219
+ "b5-await-using": {
220
+ channel: "statique",
221
+ convention: "Le résultat d’un runner docker-aware se lie avec `await using` ; une assignation nue est une erreur. Canal principal : l’inférence checker (b5-await-using-inference).",
222
+ family: "B",
223
+ id: "B5",
224
+ rationale: "`await using` garantit le nettoyage des conteneurs créés par le binaire testé, même en cas d’échec."
225
+ },
226
+ "b6w-redundant-env-url": {
227
+ channel: "statique",
228
+ convention: "`.env({ <SERVICE>_URL: ….connectionString })` répète l’injection automatique du framework → warning.",
229
+ family: "B",
230
+ id: "B6",
231
+ rationale: "L’injection couvre déjà les URLs de services ; les réécrire à la main invite au décalage."
232
+ },
233
+ "b8-kebab-trigger": {
234
+ channel: "statique",
235
+ convention: "`.trigger(name)` prend un identifiant kebab-case stable ; un `name` non kebab-case est une erreur.",
236
+ family: "B",
237
+ id: "B8",
238
+ rationale: "Le nom de job est un contrat entre l’app et les tests — un identifiant stable interdit les traductions divergentes."
239
+ },
240
+ "b9w-product-command": {
241
+ channel: "statique",
242
+ convention: "Un `specification.cli(bin)` dont le binaire résout dans le `node_modules/.bin` d’une dépendance teste l’outil tiers, pas la commande produit → warning (suppression avec raison admise).",
243
+ family: "B",
244
+ id: "B9",
245
+ rationale: "Une spec doit exercer la vraie commande du produit ; les assertions par outil passent par `result.grep`."
246
+ },
247
+ "c1-domain-structure": {
248
+ channel: "statique",
249
+ convention: "Un `*.test.ts` vit à la profondeur facet/domain, un `*.specification.ts` au root de la facette ; toute autre profondeur est une erreur.",
250
+ family: "C",
251
+ id: "C1",
252
+ rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
253
+ },
254
+ "c2-http-only-requests": {
255
+ channel: "statique",
256
+ convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
257
+ family: "C",
258
+ id: "C2",
259
+ rationale: "Une entrée de requête est un `.http` complet — homogénéiser le dossier interdit les formats ad hoc."
260
+ },
261
+ "c4-contract-shape": {
262
+ channel: "statique",
263
+ convention: "Un fichier de `contracts/` respecte `<nom>.<provider>.ts` (provider ∈ openai|anthropic|http), à plat, `export default defineContract(...)`, imports depuis le point d’entrée public.",
264
+ family: "C",
265
+ id: "C4",
266
+ rationale: "Une forme figée rend les contrats découvrables et typables sans convention locale."
267
+ },
268
+ "c6-tomatch-extension": {
269
+ channel: "statique",
270
+ convention: "L’argument de `toMatch` porte son extension (`'help.txt'`), sauf pour les snapshots d’arborescence (dossiers) ; un sujet fichier sans extension est une erreur.",
271
+ family: "C",
272
+ id: "C6",
273
+ rationale: "L’extension fait partie du nom du fichier attendu — l’omettre casse la résolution `expected/`."
274
+ },
275
+ "c7-seeds-sql-only": {
276
+ channel: "statique",
277
+ convention: "`seeds/` ne contient que des `*.sql` ; tout autre fichier est une erreur.",
278
+ family: "C",
279
+ id: "C7",
280
+ rationale: "`.seed()` porte l’état des bases uniquement — pas de seed-handler ni de dispatch par préfixe."
281
+ },
282
+ "c8-referenced-fixture-exists": {
283
+ channel: "statique",
284
+ convention: "Un littéral de `.request`/`.seed`/`.fixture`/`toMatch` doit exister sur disque sous sa racine conventionnelle ; un chemin absent est une erreur.",
285
+ family: "C",
286
+ id: "C8",
287
+ rationale: "Attraper un typo statiquement évite un échec qui ne surviendrait qu’à l’exécution."
288
+ },
289
+ "d2-await-io-matcher": {
290
+ channel: "statique",
291
+ convention: "Un matcher IO (`toMatchRows`/`toBeEmpty`/`toBeRunning`) doit être awaité ou retourné ; sinon l’assertion ne s’exécute jamais → erreur.",
292
+ family: "D",
293
+ id: "D2",
294
+ rationale: "Une assertion IO non attendue passe silencieusement — le pire mode d’échec d’un test."
295
+ },
296
+ "d2w-await-sync-matcher": {
297
+ channel: "statique",
298
+ convention: "`await` sur un matcher toujours synchrone (`toBe`/`toEqual`/`toContain`/`toHaveLength`) est redondant → warning.",
299
+ family: "D",
300
+ id: "D2",
301
+ rationale: "Un await inutile masque le signal qui distingue les vrais matchers IO."
302
+ },
303
+ "d6w-transform-token-equivalent": {
304
+ channel: "statique",
305
+ convention: "Un `transform` qui ne fait que réécrire vers des équivalents de tokens standard duplique la grammaire → warning.",
306
+ family: "D",
307
+ id: "D6",
308
+ rationale: "`transform` est une échappatoire pour le bruit non couvert par les tokens — pas un doublon des tokens."
309
+ },
310
+ "d8w-text-bypass": {
311
+ channel: "statique",
312
+ convention: "`expect(x.text).toContain/toMatch` court-circuite le sujet accesseur typé → warning.",
313
+ family: "D",
314
+ id: "D8",
315
+ rationale: "Asserter sur `.text` jette la grammaire de tokens et la résolution `toMatch('fichier')` du sujet."
316
+ },
317
+ "d9w-single-use-ref": {
318
+ channel: "statique",
319
+ convention: "Une ref de capture (`match.ref`, `{{kind#ref}}`) qui n’apparaît qu’une seule fois dans tout le fichier (code + fixtures `expected/` référencées) porte un nom inutilement → warning.",
320
+ family: "D",
321
+ id: "D9",
322
+ rationale: "Une ref ne se justifie que si elle asserte l’égalité sur au moins deux occurrences."
323
+ },
324
+ "d12w-response-body-probe": {
325
+ channel: "statique",
326
+ convention: "Un test qui accumule un AMAS de sondes brutes sur `.response.body` (≥ `threshold`, défaut 3 ; une variable castée depuis `.response.body` compte ses lectures) → warning : ce cas veut un golden complet (`expect(result.response).toMatch('cas.http')`). Une ou deux sondes restent silencieuses (scalpel légitime).",
327
+ family: "D",
328
+ id: "D12",
329
+ rationale: "Le golden complet capture toute la forme et sa grammaire de tokens ; un amas de sondes brutes le remplace par des checks ad hoc qui dérivent (mécanise la frontière D11 pour les réponses API)."
330
+ },
331
+ "d13w-unfrozen-negative-fixture": {
332
+ channel: "statique",
333
+ convention: "Un `toMatch` dont l’échec EST le sujet du test (enveloppé dans `expect(() => …).toThrow()` ou `expect(…).rejects.toThrow()`) doit porter `{ frozen: true }` → sinon `TEST_UPDATE=1` réécrit silencieusement la fixture délibérément-fausse au lieu de lever → warning. Le résidu passé par un helper (`catchMessage(() => …toMatch(…))`) échappe à l’analyse statique (voir la note process D13).",
334
+ family: "D",
335
+ id: "D13",
336
+ rationale: "En mode update, un matcher non gelé écrit au lieu de lever : la fixture négative est corrompue par sa propre sortie réelle et l’assertion ne teste plus rien. `frozen` fige la fixture négative."
337
+ },
338
+ "d15w-status-only-probe": {
339
+ channel: "statique",
340
+ convention: "Un test de spec dont les SEULES assertions sont des sondes de statut HTTP (`expect(X.status).toBe(N)` / `.toEqual(N)`, N littéral numérique 100–599) → warning : ce cas veut un golden complet (`expect(result.response).toMatch('cas.http')`). Une sonde de statut À CÔTÉ d’une vraie assertion (golden, `toMatchRows`, `toContain`…) reste silencieuse (scalpel légitime).",
341
+ family: "D",
342
+ id: "D15",
343
+ rationale: "Un statut isolé ne fige que le code de réponse et jette tout le reste du payload ; le golden complet capture la forme entière et sa grammaire de tokens (complète d12w, qui exige un amas de sondes de corps et manque le cas de la sonde de statut solitaire)."
344
+ },
345
+ "f1-no-subpath-import": {
346
+ channel: "statique",
347
+ convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf le subpath tool-facing `@jterrazz/test/oxlint` (exempté partout).",
348
+ family: "F",
349
+ id: "F1",
350
+ rationale: "Un point d’entrée unique garde l’API publique explicite et les subpaths internes invisibles."
351
+ },
352
+ "f2-no-test-imports-in-prod": {
353
+ channel: "statique",
354
+ convention: "Un fichier de prod n’importe jamais `vitest`, `@jterrazz/test`, un `*.test.*`, un `*.fixtures.*` ni `mockOf`/`mockOfDate` (exception : `@jterrazz/test/oxlint`).",
355
+ family: "F",
356
+ id: "F2",
357
+ rationale: "Empêcher les artefacts de test de fuir en prod protège le bundle applicatif du consommateur."
358
+ },
359
+ "f3-specs-public-entry": {
360
+ channel: "statique",
361
+ 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/**`.",
362
+ family: "F",
363
+ id: "F3",
364
+ 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."
365
+ },
366
+ "f4-no-test-to-test-import": {
367
+ channel: "statique",
368
+ convention: "Un `*.test.ts` n’importe jamais un autre `*.test.ts`.",
369
+ family: "F",
370
+ id: "F4",
371
+ rationale: "Le partage entre tests passe par des `*.fixtures.ts`, pas par des imports test-à-test qui couplent les fichiers."
372
+ },
373
+ "f5-fixtures-only-from-tests": {
374
+ channel: "statique",
375
+ convention: "Un `*.fixtures.ts` n’est importable que depuis des `*.test.ts`.",
376
+ family: "F",
377
+ id: "F5",
378
+ rationale: "Cantonner les fixtures aux tests empêche la donnée de test de fuir dans le code de prod."
379
+ },
380
+ "i1-layer-boundaries": {
381
+ channel: "statique",
382
+ convention: "Quatre couches sous `src/` (core/integrations/vitest/lint) ; un import externe depuis `core/`, une integration important une dépendance qui n’est pas la sienne, ou un import hors whitelist entre couches est une erreur.",
383
+ family: "I",
384
+ id: "I1",
385
+ rationale: "Des frontières strictes gardent `core/` pur et chaque intégration confinée à sa dépendance."
386
+ },
387
+ "i2-sibling-test-naming": {
388
+ channel: "statique",
389
+ convention: "Le test de `<fichier>.ts` est `<fichier>.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur.",
390
+ family: "I",
391
+ id: "I2",
392
+ rationale: "Des tests voisins (parité avec le `foo_test.go` de Go) gardent test et code ensemble et découvrables."
393
+ },
394
+ "i4-no-vi-mock-in-src": {
395
+ channel: "statique",
396
+ convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset non-`.ts` depuis un `.test.ts` sont interdits.",
397
+ family: "I",
398
+ id: "I4",
399
+ rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
400
+ },
401
+ "j1-no-only-skip": {
402
+ channel: "statique",
403
+ convention: "Aucun `.only` / `.skip` committé (`describe.only`, `test.only`, `test.skip`).",
404
+ family: "J",
405
+ id: "J1",
406
+ rationale: "Un `.only`/`.skip` oublié désactive silencieusement une partie de la suite."
407
+ },
408
+ "j2-no-sleep-in-specs": {
409
+ channel: "statique",
410
+ convention: "Aucun sleep arbitraire (`setTimeout`/`setInterval`/`Atomics.wait`) sous `specs/**` — la synchronisation passe par `waitFor`.",
411
+ family: "J",
412
+ id: "J2",
413
+ rationale: "Un sleep fixe rend les tests lents et instables ; attendre une condition est déterministe."
414
+ },
415
+ "j3-no-expectless-test": {
416
+ channel: "statique",
417
+ convention: "Un `test(...)` avec callback contient au moins un `expect(…)` ; `test.todo` (sans callback) est ignoré.",
418
+ family: "J",
419
+ id: "J3",
420
+ rationale: "Un test sans assertion est mort ou muet — il passe toujours sans rien vérifier."
421
+ },
422
+ "j4-unique-test-names": {
423
+ channel: "statique",
424
+ convention: "Deux tests d’un même fichier ne partagent pas un nom littéral (`.each` ignoré).",
425
+ family: "J",
426
+ id: "J4",
427
+ rationale: "Le nom du test est son unique description — deux noms identiques rendent un échec ambigu."
428
+ },
429
+ "j5-lowercase-title": {
430
+ channel: "statique",
431
+ 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.",
432
+ family: "J",
433
+ id: "J5",
434
+ rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
435
+ }
436
+ };
437
+ /**
438
+ * The **checker** channel — the non-oxlint static passes bundled as
439
+ * `dist/checker.js` (token/HTTP grammar + cross-file analyses). Oxlint never
440
+ * visits data fixtures nor reads two files at once, so these ship separately.
441
+ */
442
+ const CHECKER_PASSES = [
443
+ {
444
+ channel: "checker",
445
+ convention: "Tout `{{token}}` dans une fixture `expected/` appartient au vocabulaire figé ; un token inconnu est une erreur.",
446
+ family: "D",
447
+ id: "D4",
448
+ name: "d4-unknown-token",
449
+ rationale: "Une grammaire fermée partagée avec le matcher runtime empêche la dérive entre canaux."
450
+ },
451
+ {
452
+ channel: "checker",
453
+ convention: "Une ref malformée d’un kind connu (`{{iso8601#}}`, `{{uuid #id}}`) dans un fichier texte sous `expected/` est une erreur.",
454
+ family: "D",
455
+ id: "D4",
456
+ name: "d4-malformed-ref",
457
+ rationale: "Une capture malformée échouerait silencieusement — la signaler la rend visible tôt."
458
+ },
459
+ {
460
+ channel: "checker",
461
+ convention: "La première ligne d’un `.http` de profondeur 1 suit sa grammaire : requête (`MÉTHODE /path`) sous `requests/`, statut (`HTTP/1.1 <status>`) sous `expected/`.",
462
+ family: "D",
463
+ id: "D4b",
464
+ name: "d4b-http-first-line",
465
+ rationale: "La ligne d’ouverture distingue une requête d’une réponse — la contraindre attrape les fichiers mal placés."
466
+ },
467
+ {
468
+ channel: "checker",
469
+ convention: "Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées.",
470
+ family: "D",
471
+ id: "D10",
472
+ name: "d10w-tokens-in-requests",
473
+ rationale: "Un token dans une entrée ne sera ni validé ni substitué — c’est presque toujours une erreur."
474
+ },
475
+ {
476
+ channel: "checker",
477
+ convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`intercepts/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
478
+ family: "C",
479
+ id: "C9",
480
+ name: "c9-dead-fixtures",
481
+ rationale: "Le miroir de C8 — une fixture que rien ne référence est du poids mort qui trompe le lecteur."
482
+ },
483
+ {
484
+ channel: "checker",
485
+ convention: "Canal principal de B5 : les runners docker-aware sont inférés de l’option `docker:` du fichier de specification importé, puis chaque résultat `.exec()` lié sans `await using` est signalé.",
486
+ family: "B",
487
+ id: "B5",
488
+ name: "b5-await-using-inference",
489
+ rationale: "Inférer les runners supprime la liste à maintenir à la main de la règle oxlint."
490
+ },
491
+ {
492
+ channel: "checker",
493
+ convention: "Avec ≥ 2 bases, `database:` est obligatoire sur chaque `.seed()`/`.table()` ; avec une seule, il est interdit — vérifié en croisant le record `services:` avec les appels des tests.",
494
+ family: "A",
495
+ id: "A7",
496
+ name: "a7-database-property",
497
+ rationale: "Le nombre de bases fixe l’API d’appel ; l’analyse cross-fichier attrape l’omission avant le runtime."
498
+ }
499
+ ];
500
+ /**
501
+ * The **runtime** channel — refusals and behaviours the framework enforces at
502
+ * execution time, where static analysis abstains (non-literal arguments) or
503
+ * cannot reach (network, container lifecycle). Several double a static pass.
504
+ */
505
+ const RUNTIME_RULES = [
506
+ {
507
+ channel: "runtime",
508
+ convention: "Un binding ambigu (le compose déclare à la fois la clé exacte ET sa forme kebab-case) est refusé à l’exécution.",
509
+ family: "A",
510
+ id: "A6",
511
+ name: "a6-ambiguous-binding",
512
+ rationale: "Le framework ne devine pas — il exige un renommage ou un `composeService` explicite."
513
+ },
514
+ {
515
+ channel: "runtime",
516
+ convention: "Le framework lève à l’exécution si `database:` est absent avec ≥ 2 bases, ou présent avec une seule (double le canal checker).",
517
+ family: "A",
518
+ id: "A7",
519
+ name: "a7-database-runtime",
520
+ rationale: "Le runtime garde la garantie même là où l’analyse statique s’abstient."
521
+ },
522
+ {
523
+ channel: "runtime",
524
+ convention: "Le framework refuse à l’exécution un marqueur `$…` inconnu, ou un `$FIXTURES` sans dossier `specs` ancêtre (message guidant).",
525
+ family: "B",
526
+ id: "B2",
527
+ name: "b2-unknown-marker-runtime",
528
+ rationale: "Un message runtime guide l’auteur quand la faute échappe au canal statique (argument non littéral)."
529
+ },
530
+ {
531
+ channel: "runtime",
532
+ convention: "En mode `cli` avec `services`, le framework injecte `<CLÉ>_URL` (CONSTANT_CASE camel-aware) plus les alias non ambigus `DATABASE_URL`/`REDIS_URL` ; `.env()` override (`null` retire).",
533
+ family: "B",
534
+ id: "B6",
535
+ name: "b6-url-injection",
536
+ rationale: "Injecter les URLs évite le câblage manuel répété et son décalage (voir b6w)."
537
+ },
538
+ {
539
+ channel: "runtime",
540
+ convention: "Dès qu’une chaîne `api`/`jobs` déclare un intercept, toute requête sortante non matchée (y compris une file épuisée) fait échouer le spec avec une erreur explicite.",
541
+ family: "D",
542
+ id: "D7",
543
+ name: "d7-strict-intercepts",
544
+ rationale: "Un réseau gardé rend les interactions externes exhaustives et intentionnelles."
545
+ },
546
+ {
547
+ channel: "runtime",
548
+ convention: "`toMatch` sur un sujet accesseur (`stream`/`json`/`response`/arborescence) attend un NOM de fixture (extension comprise) : passer une `RegExp` (ou tout non-string) lève immédiatement, en nommant le sujet et l’échappatoire `expect(x.text).toMatch(/re/)`.",
549
+ family: "D",
550
+ id: "D14",
551
+ name: "d14-tomatch-fixture-name",
552
+ rationale: "L’instinct hérité de vitest (`toMatch(/re/)`) tomberait sinon sur l’erreur d’extension ou coercerait la regex en `\"/re/\"`. L’argument accesseur n’est jamais littéral côté valeur, et une heuristique statique confondrait le `expect(chaîne).toMatch(/re/)` légitime (D3/D8) — seul le canal runtime refuse proprement, sans faux positifs."
553
+ },
554
+ {
555
+ channel: "runtime",
556
+ convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
557
+ family: "I",
558
+ id: "I3",
559
+ name: "i3-intercept-compose",
560
+ rationale: "Un child process ou un conteneur n’est pas interceptable par MSW — l’erreur oriente vers un projet node-only."
561
+ }
562
+ ];
563
+ /**
564
+ * The **process** channel — rules no channel can fully mechanize: they are a
565
+ * matter of review judgement. Listed here so the catalogue is complete across
566
+ * all four faces; the constitution keeps their full rationale.
567
+ */
568
+ const PROCESS_RULES = [
569
+ {
570
+ channel: "process",
571
+ convention: "Le dossier suit les assets : un test avec ses propres dossiers d’assets a son propre domaine ; des tests sans assets locaux se regroupent en `<aspect>.test.ts` frères. La règle statique ne vérifie que la profondeur.",
572
+ family: "C",
573
+ id: "C1",
574
+ name: "c1-asset-grouping",
575
+ rationale: "Ce sont les assets qui tranchent le regroupement — un critère qu’aucun canal ne peut décider seul."
576
+ },
577
+ {
578
+ channel: "process",
579
+ convention: "La sortie d’un outil s’asserte en snapshot complet par use case scopé, pas en grappe de `grep` ; `.grep()` reste le scalpel pour les sondes ciblées.",
580
+ family: "D",
581
+ id: "D11",
582
+ name: "d11-golden-file",
583
+ rationale: "Jugement de revue — le canal statique ne distingue pas un grep légitime d’un grep paresseux."
584
+ },
585
+ {
586
+ channel: "process",
587
+ convention: "Une fixture délibérément-fausse ou manquante, asservie à un test négatif, porte `{ frozen: true }` sur son `toMatch`. La règle statique d13w couvre les formes enveloppées (`expect(() => …).toThrow()` / `.rejects`) ; le résidu — un `toMatch` routé via un helper qui possède le try/catch (`catchMessage(() => …)`) — relève de la revue, faute d’analyse inter-procédurale.",
588
+ family: "D",
589
+ id: "D13",
590
+ name: "d13-frozen-negative-fixture",
591
+ rationale: "Le helper masque le point de capture au canal statique ; la revue garde la même invariante que d13w là où l’AST ne suffit pas."
592
+ },
593
+ {
594
+ channel: "process",
595
+ convention: "Toute classe de défaut découverte produit, dans le même change, la garde qui l’empêche de revenir (règle statique, meta-test ou erreur runtime) — ou documente pourquoi aucun canal n’est possible.",
596
+ family: "K",
597
+ id: "K1",
598
+ name: "k1-retro-propagation",
599
+ rationale: "C’est la règle qui fait croître les trois autres canaux au lieu de les laisser pourrir."
600
+ }
601
+ ];
602
+ /** Split an id into (family letter, numeric, variant) for natural ordering. */
603
+ function sortKey(entry) {
604
+ const match = /^(?<letter>[A-Z]+)(?<number>\d+)(?<variant>.*)$/u.exec(entry.id);
605
+ return [
606
+ match?.groups?.letter ?? entry.id,
607
+ Number(match?.groups?.number ?? 0),
608
+ `${match?.groups?.variant ?? ""}:${entry.name}`
609
+ ];
610
+ }
611
+ [
612
+ ...Object.entries(RULE_DOCS).map(([name, doc]) => ({
613
+ channel: doc.channel,
614
+ convention: doc.convention,
615
+ family: doc.family,
616
+ id: doc.id,
617
+ name,
618
+ rationale: doc.rationale
619
+ })),
620
+ ...CHECKER_PASSES,
621
+ ...RUNTIME_RULES,
622
+ ...PROCESS_RULES
623
+ ].sort((a, b) => {
624
+ const [al, an, av] = sortKey(a);
625
+ const [bl, bn, bv] = sortKey(b);
626
+ if (al !== bl) return al.localeCompare(bl);
627
+ if (an !== bn) return an - bn;
628
+ return av.localeCompare(bv);
629
+ });
630
+ //#endregion
631
+ //#region src/lint/rules/a1-specification-file.ts
632
+ const SPECIFICATION_SUFFIX$2 = ".specification.ts";
633
+ /**
634
+ * CONVENTIONS A1 — a runner is created in a `*.specification.ts` file under
635
+ * `specs/`. Any `specification.<member>(…)` call in a file that is not a
636
+ * specification file is flagged.
637
+ *
638
+ * Framework-internal unit tests of the constructors themselves (this repo's
639
+ * `src/**\/*.test.ts`) disable the rule via a config override — for consumers
640
+ * the rule is universal.
641
+ */
642
+ const a1SpecificationFile = {
643
+ create(context) {
644
+ if (context.filename.endsWith(SPECIFICATION_SUFFIX$2)) return {};
645
+ return { CallExpression(node) {
646
+ const member = specificationMember(node);
647
+ if (member !== void 0) context.report({
648
+ data: { member },
649
+ messageId: "outsideSpecification",
650
+ node
651
+ });
652
+ } };
653
+ },
654
+ meta: {
655
+ docs: RULE_DOCS["a1-specification-file"],
656
+ messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (CONVENTIONS A1)." },
657
+ type: "problem"
658
+ }
659
+ };
660
+ //#endregion
661
+ //#region src/core/specification/shared/binding.ts
662
+ /**
663
+ * Compose-binding resolution and the case conversions it shares with the
664
+ * automatic env injection (CONVENTIONS A6 / B6).
665
+ *
666
+ * A services-record key is written in the natural TypeScript style
667
+ * (`analyticsDb`), while a compose service name is kebab-case
668
+ * (`analytics-db`). These helpers bridge the two vocabularies deterministically
669
+ * so the record key stays the single source of truth and `composeService`
670
+ * remains a rare escape hatch.
671
+ */
672
+ /**
673
+ * Convert a record key to its kebab-case form: camelCase boundaries become
674
+ * `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
675
+ */
676
+ function toKebabCase(key) {
677
+ return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
678
+ }
679
+ //#endregion
680
+ //#region src/lint/rules/a10-duplicate-binding.ts
681
+ /** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
682
+ function canonicalBinding(entry, key) {
683
+ const factory = entry.value;
684
+ if (factory?.type === "CallExpression") {
685
+ const options = factory.arguments?.[0];
686
+ if (options?.type === "ObjectExpression") {
687
+ const explicit = stringValue(findProperty(options, "composeService")?.value);
688
+ if (explicit !== void 0) return explicit;
689
+ }
690
+ }
691
+ return toKebabCase(key);
692
+ }
693
+ /**
694
+ * CONVENTIONS A10 — within one `services` record, two keys that derive (or
695
+ * pin via `composeService`) the SAME compose service name are a silent
696
+ * collision: the record is a map, and the second binding would shadow the
697
+ * first. Detected on the same kebab derivation the runtime binding uses
698
+ * (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
699
+ * `composeService` values.
700
+ */
701
+ const a10DuplicateBinding = {
702
+ create(context) {
703
+ return { CallExpression(node) {
704
+ if (specificationMember(node) === void 0) return;
705
+ for (const argument of node.arguments ?? []) {
706
+ const record = findProperty(argument, "services")?.value;
707
+ if (record?.type !== "ObjectExpression") continue;
708
+ const seen = /* @__PURE__ */ new Map();
709
+ for (const entry of record.properties ?? []) {
710
+ const key = propertyKeyName(entry);
711
+ if (key === void 0) continue;
712
+ const binding = canonicalBinding(entry, key);
713
+ const first = seen.get(binding);
714
+ if (first !== void 0) {
715
+ context.report({
716
+ data: {
717
+ binding,
718
+ first,
719
+ key
720
+ },
721
+ messageId: "duplicate",
722
+ node: entry
723
+ });
724
+ continue;
725
+ }
726
+ seen.set(binding, key);
727
+ }
728
+ }
729
+ } };
730
+ },
731
+ meta: {
732
+ docs: RULE_DOCS["a10-duplicate-binding"],
733
+ 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 (CONVENTIONS A10)." },
734
+ type: "problem"
735
+ }
736
+ };
737
+ //#endregion
738
+ //#region src/lint/rules/a2-known-constructors.ts
739
+ /** The three constructors, and only three (CONVENTIONS A2). */
740
+ const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
741
+ "api",
742
+ "cli",
743
+ "jobs"
744
+ ]);
745
+ /**
746
+ * CONVENTIONS A2 — `specification.api()`, `specification.jobs()` and
747
+ * `specification.cli()` are the only members. Any other access
748
+ * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
749
+ */
750
+ const a2KnownConstructors = {
751
+ create(context) {
752
+ return { MemberExpression(node) {
753
+ const object = node.object;
754
+ if (object?.type !== "Identifier" || object.name !== "specification") return;
755
+ const member = memberPropertyName(node);
756
+ if (member !== void 0 && !KNOWN_CONSTRUCTORS.has(member)) context.report({
757
+ data: { member },
758
+ messageId: "unknownConstructor",
759
+ node
760
+ });
761
+ } };
762
+ },
763
+ meta: {
764
+ docs: RULE_DOCS["a2-known-constructors"],
765
+ messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs() and specification.cli() (CONVENTIONS A2)." },
766
+ type: "problem"
767
+ }
768
+ };
769
+ //#endregion
770
+ //#region src/lint/rules/a3-no-destructure-alias.ts
771
+ /** Unwrap `await <expr>` down to the call expression, if that is what it is. */
772
+ function unwrapAwaitedCall(node) {
773
+ let current = node;
774
+ while (current?.type === "AwaitExpression") current = current.argument;
775
+ return current?.type === "CallExpression" ? current : void 0;
776
+ }
777
+ /**
778
+ * CONVENTIONS A3 — the constructor's return is destructured with the canonical
779
+ * names, no aliasing: `const { api, cleanup, docker } = await
780
+ * specification.api(…)`. A renamed binding (`{ api: myApi }`) is flagged.
781
+ *
782
+ * Renaming at the IMPORT site (`import { cli as dockerCli } from …`) stays
783
+ * legal — the canonical name is enforced where the record is destructured.
784
+ */
785
+ const a3NoDestructureAlias = {
786
+ create(context) {
787
+ return { VariableDeclarator(node) {
788
+ const call = unwrapAwaitedCall(node.init);
789
+ if (call === void 0 || specificationMember(call) === void 0) return;
790
+ const id = node.id;
791
+ if (id?.type !== "ObjectPattern") return;
792
+ for (const property of id.properties ?? []) {
793
+ const key = propertyKeyName(property);
794
+ if (key === void 0) continue;
795
+ let value = property.value;
796
+ if (value?.type === "AssignmentPattern") value = value.left;
797
+ if (value?.type === "Identifier" && value.name !== key) context.report({
798
+ data: {
799
+ alias: value.name,
800
+ key
801
+ },
802
+ messageId: "aliased",
803
+ node: property
804
+ });
805
+ }
806
+ } };
807
+ },
808
+ meta: {
809
+ docs: RULE_DOCS["a3-no-destructure-alias"],
810
+ messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (CONVENTIONS A3). Rename at the import site if a different local name is needed." },
811
+ type: "problem"
812
+ }
813
+ };
814
+ //#endregion
815
+ //#region src/lint/rules/a4-cleanup-afterall.ts
816
+ const SPECIFICATION_SUFFIX$1 = ".specification.ts";
817
+ /** Does this declarator destructure `cleanup` out of a specification call? */
818
+ function destructuresCleanup(node) {
819
+ if (node.type !== "VariableDeclarator") return false;
820
+ let init = node.init;
821
+ while (init?.type === "AwaitExpression") init = init.argument;
822
+ if (init?.type !== "CallExpression" || specificationMember(init) === void 0) return false;
823
+ const id = node.id;
824
+ if (id?.type !== "ObjectPattern") return false;
825
+ return (id.properties ?? []).some((property) => propertyKeyName(property) === "cleanup");
826
+ }
827
+ /** Is this an `afterAll(…)` call whose subtree references `cleanup`? */
828
+ function isAfterAllWithCleanup(node) {
829
+ if (node.type !== "CallExpression") return false;
830
+ const callee = node.callee;
831
+ if (callee?.type !== "Identifier" || callee.name !== "afterAll") return false;
832
+ let found = false;
833
+ for (const argument of node.arguments ?? []) walk(argument, (child) => {
834
+ if (child.type === "Identifier" && child.name === "cleanup") found = true;
835
+ });
836
+ return found;
837
+ }
838
+ /**
839
+ * CONVENTIONS A4 — a specification file calls `afterAll(cleanup)`. Flags a
840
+ * `*.specification.ts` file that destructures `cleanup` from a
841
+ * `specification.*` call but never hands it to `afterAll` (directly or inside
842
+ * a callback).
843
+ */
844
+ const a4CleanupAfterall = {
845
+ create(context) {
846
+ if (!context.filename.endsWith(SPECIFICATION_SUFFIX$1)) return {};
847
+ return { Program(node) {
848
+ let cleanupDeclarator;
849
+ let registered = false;
850
+ walk(node, (child) => {
851
+ if (cleanupDeclarator === void 0 && destructuresCleanup(child)) cleanupDeclarator = child;
852
+ if (!registered && isAfterAllWithCleanup(child)) registered = true;
853
+ });
854
+ if (cleanupDeclarator !== void 0 && !registered) context.report({
855
+ messageId: "unregistered",
856
+ node: cleanupDeclarator
857
+ });
858
+ } };
859
+ },
860
+ meta: {
861
+ docs: RULE_DOCS["a4-cleanup-afterall"],
862
+ messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (CONVENTIONS A4)." },
863
+ type: "problem"
864
+ }
865
+ };
866
+ //#endregion
867
+ //#region src/lint/rules/a5-mode-with-server.ts
868
+ /**
869
+ * CONVENTIONS A5 — `mode` is never hardcoded in a specification file: the
870
+ * node/compose switch lives in `vitest.config.ts` (`env: { TEST_MODE:
871
+ * 'compose' }`). The one sanctioned use is a non-Node app (no `server`), where
872
+ * `mode: 'compose'` is mandatory and permanent — so the rule flags a `mode`
873
+ * property ONLY when `server` is also defined in the same options object.
874
+ */
875
+ const a5ModeWithServer = {
876
+ create(context) {
877
+ return { CallExpression(node) {
878
+ if (specificationMember(node) !== "api") return;
879
+ for (const argument of node.arguments ?? []) {
880
+ if (argument.type !== "ObjectExpression") continue;
881
+ const mode = findProperty(argument, "mode");
882
+ const server = findProperty(argument, "server");
883
+ if (mode !== void 0 && server !== void 0) context.report({
884
+ messageId: "hardcodedMode",
885
+ node: mode
886
+ });
887
+ }
888
+ } };
889
+ },
890
+ meta: {
891
+ docs: RULE_DOCS["a5-mode-with-server"],
892
+ 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\" }` (CONVENTIONS A5)." },
893
+ type: "problem"
894
+ }
895
+ };
896
+ //#endregion
897
+ //#region src/lint/rules/a6w-redundant-compose-service.ts
898
+ /**
899
+ * CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
900
+ * non-derivable names. When its literal equals the record key itself or the
901
+ * key's kebab-case conversion (the two names the deterministic binding would
902
+ * try anyway, via the same `toKebabCase` the runtime uses), it is redundant.
903
+ */
904
+ const a6wRedundantComposeService = {
905
+ create(context) {
906
+ return { CallExpression(node) {
907
+ if (specificationMember(node) === void 0) return;
908
+ for (const argument of node.arguments ?? []) {
909
+ const record = findProperty(argument, "services")?.value;
910
+ if (record?.type !== "ObjectExpression") continue;
911
+ for (const entry of record.properties ?? []) {
912
+ const key = propertyKeyName(entry);
913
+ const factory = entry.value;
914
+ if (key === void 0 || factory?.type !== "CallExpression") continue;
915
+ const options = factory.arguments?.[0];
916
+ if (options?.type !== "ObjectExpression") continue;
917
+ const composeService = findProperty(options, "composeService");
918
+ const name = stringValue(composeService?.value);
919
+ if (name !== void 0 && (name === key || name === toKebabCase(key))) context.report({
920
+ data: {
921
+ key,
922
+ name
923
+ },
924
+ messageId: "redundant",
925
+ node: composeService ?? entry
926
+ });
927
+ }
928
+ }
929
+ } };
930
+ },
931
+ meta: {
932
+ docs: RULE_DOCS["a6w-redundant-compose-service"],
933
+ messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, CONVENTIONS A6)." },
934
+ type: "suggestion"
935
+ }
936
+ };
937
+ //#endregion
938
+ //#region src/lint/fs-cache.ts
939
+ /**
940
+ * Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
941
+ *
942
+ * Rules only ever look at directories NEXT TO files oxlint is already
943
+ * visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
944
+ * file), so the probe set is small — but several rules probe the same feature
945
+ * directory, and oxlint lints many files per process. A module-level cache
946
+ * keeps each `readdir`/`stat` to one syscall per lint run.
947
+ */
948
+ const dirListings = /* @__PURE__ */ new Map();
949
+ function listDirectory(dir) {
950
+ const cached = dirListings.get(dir);
951
+ if (cached !== void 0) return cached;
952
+ let result;
953
+ try {
954
+ result = readdirSync(dir);
955
+ } catch {
956
+ result = null;
957
+ }
958
+ dirListings.set(dir, result);
959
+ return result;
960
+ }
961
+ const kinds = /* @__PURE__ */ new Map();
962
+ function kindOf(path) {
963
+ const cached = kinds.get(path);
964
+ if (cached !== void 0) return cached;
965
+ let result;
966
+ try {
967
+ result = statSync(path).isDirectory() ? "dir" : "file";
968
+ } catch {
969
+ result = "missing";
970
+ }
971
+ kinds.set(path, result);
972
+ return result;
973
+ }
974
+ function isDirectory(path) {
975
+ return kindOf(path) === "dir";
976
+ }
977
+ function isFile(path) {
978
+ return kindOf(path) === "file";
979
+ }
980
+ const fileContents = /* @__PURE__ */ new Map();
981
+ /** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
982
+ function readFileCached(path) {
983
+ const cached = fileContents.get(path);
984
+ if (cached !== void 0) return cached;
985
+ let result;
986
+ try {
987
+ result = readFileSync(path, "utf8");
988
+ } catch {
989
+ result = null;
990
+ }
991
+ fileContents.set(path, result);
992
+ return result;
993
+ }
994
+ //#endregion
995
+ //#region src/lint/rules/a9w-redundant-root.ts
996
+ /**
997
+ * The root the convention would derive (CONVENTIONS A9): walk up from the
998
+ * specification file to the first directory containing
999
+ * `docker/compose.test.yaml`, else the first containing `package.json`.
1000
+ */
1001
+ function derivedRoot(startDir) {
1002
+ for (const probe of ["docker/compose.test.yaml", "package.json"]) {
1003
+ let dir = startDir;
1004
+ for (;;) {
1005
+ if (isFile(join(dir, probe))) return dir;
1006
+ const parent = dirname(dir);
1007
+ if (parent === dir) break;
1008
+ dir = parent;
1009
+ }
1010
+ }
1011
+ }
1012
+ /**
1013
+ * CONVENTIONS A9 (warning) — `root` is an override reserved for cases the
1014
+ * convention cannot resolve. When the literal points at the very directory the
1015
+ * walk-up would have found, it is redundant and flagged.
1016
+ */
1017
+ const a9wRedundantRoot = {
1018
+ create(context) {
1019
+ return { CallExpression(node) {
1020
+ if (specificationMember(node) === void 0) return;
1021
+ for (const argument of node.arguments ?? []) {
1022
+ const root = findProperty(argument, "root");
1023
+ const literal = stringValue(root?.value);
1024
+ if (root === void 0 || literal === void 0) continue;
1025
+ const specDir = dirname(context.physicalFilename);
1026
+ if (resolve(specDir, literal) === derivedRoot(specDir)) context.report({
1027
+ data: { root: literal },
1028
+ messageId: "redundant",
1029
+ node: root
1030
+ });
1031
+ }
1032
+ } };
1033
+ },
1034
+ meta: {
1035
+ docs: RULE_DOCS["a9w-redundant-root"],
1036
+ messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (CONVENTIONS A9)." },
1037
+ type: "suggestion"
1038
+ }
1039
+ };
1040
+ //#endregion
1041
+ //#region src/core/specification/shared/fixtures.ts
1042
+ /**
1043
+ * Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
1044
+ *
1045
+ * A fixture path is one of two shapes:
1046
+ *
1047
+ * - `$FIXTURES/<rest>` — the shared pool at `<specs-root>/fixtures/<rest>`,
1048
+ * where `<specs-root>` is the nearest ancestor directory named `specs`.
1049
+ * - `<path>` (no marker) — feature-local, at `<test-dir>/fixtures/<path>`.
1050
+ *
1051
+ * Any other `$`-prefixed marker is a usage error (listed against the known
1052
+ * markers). Copy semantics mirror rsync's trailing-slash rule (see
1053
+ * {@link copyPlan}).
1054
+ */
1055
+ /**
1056
+ * Markers understood in a `.fixture()` path. Extend here + in
1057
+ * {@link resolveFixtureSource}. Exported so the lint layer (rule
1058
+ * `jterrazz/b2-known-fixture-marker`) validates literals against the same list.
1059
+ */
1060
+ const KNOWN_FIXTURE_MARKERS = ["$FIXTURES"];
1061
+ //#endregion
1062
+ //#region src/lint/rules/b2-known-fixture-marker.ts
1063
+ const KNOWN = new Set(KNOWN_FIXTURE_MARKERS);
1064
+ /**
1065
+ * The leading literal text of a `.fixture()` argument: a plain string, or the
1066
+ * first quasi of a template literal (`` `$FIXTURES/${name}` `` → `$FIXTURES/`),
1067
+ * so a `$…` marker on an interpolated path is still validated.
1068
+ */
1069
+ function leadingText(node) {
1070
+ const literal = stringValue(node);
1071
+ if (literal !== void 0) return literal;
1072
+ if (node?.type === "TemplateLiteral") return ((node.quasis?.[0])?.value)?.cooked;
1073
+ }
1074
+ /**
1075
+ * CONVENTIONS B2 — a `$…` marker in a `.fixture()` path literal must be one of
1076
+ * the known markers (`$FIXTURES`). The list is imported from the same core
1077
+ * module the runtime resolution uses, so the two channels can never drift.
1078
+ */
1079
+ const b2KnownFixtureMarker = {
1080
+ create(context) {
1081
+ return { CallExpression(node) {
1082
+ const callee = node.callee;
1083
+ if (callee === void 0 || memberPropertyName(callee) !== "fixture") return;
1084
+ const args = node.arguments ?? [];
1085
+ const path = leadingText(args[0]);
1086
+ if (path === void 0 || !path.startsWith("$")) return;
1087
+ const marker = path.replace(/\/+$/, "").split("/")[0];
1088
+ if (!KNOWN.has(marker)) context.report({
1089
+ data: {
1090
+ known: [...KNOWN_FIXTURE_MARKERS].join(", "),
1091
+ marker
1092
+ },
1093
+ messageId: "unknownMarker",
1094
+ node: args[0]
1095
+ });
1096
+ } };
1097
+ },
1098
+ meta: {
1099
+ docs: RULE_DOCS["b2-known-fixture-marker"],
1100
+ messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (CONVENTIONS B2)." },
1101
+ type: "problem"
1102
+ }
1103
+ };
1104
+ //#endregion
1105
+ //#region src/lint/rules/b4-given-then.ts
1106
+ /** Does any comment start (after leading whitespace) with `<marker> -`? */
1107
+ function hasMarker(comments, marker) {
1108
+ const needle = `${marker} -`;
1109
+ return comments.some((comment) => comment.value.trimStart().startsWith(needle));
1110
+ }
1111
+ /** The source start offset of the first comment opening with `<marker> -`. */
1112
+ function firstMarkerOffset(comments, marker) {
1113
+ const needle = `${marker} -`;
1114
+ let best = -1;
1115
+ for (const comment of comments) {
1116
+ if (!comment.value.trimStart().startsWith(needle)) continue;
1117
+ const start = nodeStart(comment);
1118
+ if (start >= 0 && (best === -1 || start < best)) best = start;
1119
+ }
1120
+ return best;
1121
+ }
1122
+ /**
1123
+ * CONVENTIONS B4 — every test carries `// Given -` and `// Then -` (always both),
1124
+ * in that order. `// When -` is optional: the spec chain is usually the "when".
1125
+ *
1126
+ * Presence is the keystone (comments are reachable via
1127
+ * `sourceCode.getCommentsInside(callback)`); the position upgrade adds the
1128
+ * unambiguous narrative ordering — Given before Then, judged on FIRST
1129
+ * occurrences for multi-`Then` bodies. (The stricter "first expect after Then"
1130
+ * variant is intentionally NOT enforced: setup-phase assertions — precondition
1131
+ * checks, update-mode writes before the Then narrative — are idiomatic here.)
1132
+ */
1133
+ const b4GivenThen = {
1134
+ create(context) {
1135
+ return { CallExpression(node) {
1136
+ if (!isTestCallee(node.callee)) return;
1137
+ const callback = findTestCallback(node.arguments ?? []);
1138
+ if (callback === void 0) return;
1139
+ const comments = context.sourceCode.getCommentsInside(callback);
1140
+ const hasGiven = hasMarker(comments, "Given");
1141
+ const hasThen = hasMarker(comments, "Then");
1142
+ if (!hasGiven) context.report({
1143
+ data: { marker: "Given" },
1144
+ messageId: "missing",
1145
+ node
1146
+ });
1147
+ if (!hasThen) context.report({
1148
+ data: { marker: "Then" },
1149
+ messageId: "missing",
1150
+ node
1151
+ });
1152
+ if (!hasGiven || !hasThen) return;
1153
+ const givenOffset = firstMarkerOffset(comments, "Given");
1154
+ const thenOffset = firstMarkerOffset(comments, "Then");
1155
+ if (givenOffset < 0 || thenOffset < 0) return;
1156
+ if (givenOffset > thenOffset) context.report({
1157
+ messageId: "givenAfterThen",
1158
+ node
1159
+ });
1160
+ } };
1161
+ },
1162
+ meta: {
1163
+ docs: RULE_DOCS["b4-given-then"],
1164
+ messages: {
1165
+ givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (CONVENTIONS B4).",
1166
+ missing: "Test is missing a `// {{marker}} -` comment (CONVENTIONS B4 — every test needs both `// Given -` and `// Then -`)."
1167
+ },
1168
+ type: "problem"
1169
+ }
1170
+ };
1171
+ //#endregion
1172
+ //#region src/lint/rules/b5-await-using.ts
1173
+ /**
1174
+ * CONVENTIONS B5 — a docker-aware runner's result must be bound with `await
1175
+ * using` so tracked containers are disposed deterministically.
1176
+ *
1177
+ * Which runner identifiers are docker-aware is not statically derivable from a
1178
+ * test file (the `docker:` option lives in the specification file), so the
1179
+ * rule reads them from its options:
1180
+ *
1181
+ * 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
1182
+ *
1183
+ * With no configured runners the rule is inert.
1184
+ */
1185
+ const b5AwaitUsing = {
1186
+ create(context) {
1187
+ const runners = new Set(context.options[0]?.runners);
1188
+ if (runners.size === 0) return {};
1189
+ return { VariableDeclaration(node) {
1190
+ if (node.kind === "await using") return;
1191
+ for (const declarator of node.declarations ?? []) {
1192
+ let init = declarator.init;
1193
+ while (init?.type === "AwaitExpression") init = init.argument;
1194
+ if (init?.type !== "CallExpression") continue;
1195
+ const root = chainRootName(init);
1196
+ if (root !== void 0 && runners.has(root)) context.report({
1197
+ data: { runner: root },
1198
+ messageId: "requireAwaitUsing",
1199
+ node: declarator
1200
+ });
1201
+ }
1202
+ } };
1203
+ },
1204
+ meta: {
1205
+ docs: RULE_DOCS["b5-await-using"],
1206
+ defaultOptions: [{ runners: [] }],
1207
+ messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (CONVENTIONS B5)." },
1208
+ schema: [{
1209
+ additionalProperties: false,
1210
+ properties: { runners: {
1211
+ items: { type: "string" },
1212
+ type: "array"
1213
+ } },
1214
+ type: "object"
1215
+ }],
1216
+ type: "problem"
1217
+ }
1218
+ };
1219
+ //#endregion
1220
+ //#region src/lint/rules/b6w-redundant-env-url.ts
1221
+ const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/;
1222
+ /**
1223
+ * CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
1224
+ * already injects `<KEY>_URL` for every service (plus the unambiguous
1225
+ * `DATABASE_URL` / `REDIS_URL` aliases). A `.env()` entry that assigns a
1226
+ * `*_URL` key from a service's `.connectionString` re-does that injection by
1227
+ * hand and is flagged as redundant.
1228
+ */
1229
+ const b6wRedundantEnvUrl = {
1230
+ create(context) {
1231
+ return { CallExpression(node) {
1232
+ const callee = node.callee;
1233
+ if (callee === void 0 || memberPropertyName(callee) !== "env") return;
1234
+ const argument = node.arguments?.[0];
1235
+ if (argument?.type !== "ObjectExpression") return;
1236
+ for (const property of argument.properties ?? []) {
1237
+ const key = propertyKeyName(property);
1238
+ if (key === void 0 || !URL_KEY.test(key)) continue;
1239
+ const value = property.value;
1240
+ if (value?.type === "MemberExpression" && memberPropertyName(value) === "connectionString") context.report({
1241
+ data: { key },
1242
+ messageId: "redundant",
1243
+ node: property
1244
+ });
1245
+ }
1246
+ } };
1247
+ },
1248
+ meta: {
1249
+ docs: RULE_DOCS["b6w-redundant-env-url"],
1250
+ messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (CONVENTIONS B6)." },
1251
+ type: "suggestion"
1252
+ }
1253
+ };
1254
+ //#endregion
1255
+ //#region src/lint/rules/b8-kebab-trigger.ts
1256
+ /** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
1257
+ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
1258
+ /**
1259
+ * CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
1260
+ * identifier; it is a contract between the app and its tests. Only string
1261
+ * literals are checked — dynamic names are out of static reach.
1262
+ */
1263
+ const b8KebabTrigger = {
1264
+ create(context) {
1265
+ return { CallExpression(node) {
1266
+ const callee = node.callee;
1267
+ if (callee === void 0 || memberPropertyName(callee) !== "trigger") return;
1268
+ const args = node.arguments ?? [];
1269
+ const name = stringValue(args[0]);
1270
+ if (name !== void 0 && !KEBAB_CASE.test(name)) context.report({
1271
+ data: { name },
1272
+ messageId: "notKebab",
1273
+ node: args[0]
1274
+ });
1275
+ } };
1276
+ },
1277
+ meta: {
1278
+ docs: RULE_DOCS["b8-kebab-trigger"],
1279
+ 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 (CONVENTIONS B8)." },
1280
+ type: "problem"
1281
+ }
1282
+ };
1283
+ //#endregion
1284
+ //#region src/lint/rules/b9w-product-command.ts
1285
+ /** A path segment that marks a third-party package's installed binary. */
1286
+ const THIRD_PARTY_BIN = /node_modules[/\\]\.bin[/\\]/u;
1287
+ /** Does any string literal reachable from `root` resolve into a `.bin` dir? */
1288
+ function embedsThirdPartyBin(root) {
1289
+ if (root === null || root === void 0) return false;
1290
+ let found = false;
1291
+ walk(root, (node) => {
1292
+ if (found) return;
1293
+ const value = stringValue(node);
1294
+ if (value !== void 0 && THIRD_PARTY_BIN.test(value)) found = true;
1295
+ });
1296
+ return found;
1297
+ }
1298
+ /**
1299
+ * CONVENTIONS B9 (warning) — a spec exercises the product's REAL commands, not
1300
+ * the third-party binaries underneath it.
1301
+ *
1302
+ * When the binary handed to `specification.cli(<bin>, …)` resolves into a
1303
+ * dependency's `node_modules/.bin/`, the spec is testing the tool directly
1304
+ * rather than the product command that composes it. Detects the literal both
1305
+ * inline (`specification.cli('…/node_modules/.bin/oxlint')`, or wrapped in a
1306
+ * `resolve(...)`) and via a local `const BIN = resolve(…, '…/.bin/oxlint')`
1307
+ * hoisted out of the call — the idiom real specs use. Advisory: it may be
1308
+ * legitimately suppressed with a reason when the product genuinely IS that
1309
+ * binary (e.g. a package whose product is a lint plugin driving oxlint).
1310
+ */
1311
+ const b9wProductCommand = {
1312
+ create(context) {
1313
+ return { Program(program) {
1314
+ const binVars = /* @__PURE__ */ new Set();
1315
+ walk(program, (node) => {
1316
+ if (node.type !== "VariableDeclarator") return;
1317
+ const id = node.id;
1318
+ const init = node.init;
1319
+ if (id?.type === "Identifier" && embedsThirdPartyBin(init)) binVars.add(id.name);
1320
+ });
1321
+ walk(program, (node) => {
1322
+ if (node.type !== "CallExpression" || specificationMember(node) !== "cli") return;
1323
+ const arg = node.arguments?.[0];
1324
+ if (arg === void 0) return;
1325
+ if (embedsThirdPartyBin(arg) || arg.type === "Identifier" && binVars.has(arg.name)) context.report({
1326
+ messageId: "thirdPartyBinary",
1327
+ node
1328
+ });
1329
+ });
1330
+ } };
1331
+ },
1332
+ meta: {
1333
+ docs: RULE_DOCS["b9w-product-command"],
1334
+ 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 (CONVENTIONS B9). Suppress with a reason if the product genuinely is this binary." },
1335
+ type: "suggestion"
1336
+ }
1337
+ };
1338
+ //#endregion
1339
+ //#region src/lint/rules/c1-domain-structure.ts
1340
+ const TEST_SUFFIX = ".test.ts";
1341
+ const SPECIFICATION_SUFFIX = ".specification.ts";
1342
+ /**
1343
+ * CONVENTIONS C1' — facet-root specifications, domain-nested tests.
1344
+ *
1345
+ * A facet (`specs/<facet>/` — api, jobs, cli, integrations, lint, …) is the
1346
+ * master folder. It carries its runner(s) at its ROOT
1347
+ * (`specs/<facet>/<name>.specification.ts`) and holds DOMAIN folders, each a
1348
+ * product command/area with one or more `<aspect>.test.ts` files plus their
1349
+ * shared asset dirs.
1350
+ *
1351
+ * Two placement invariants, checked from the filename:
1352
+ * - a `*.test.ts` must sit at facet/domain depth — exactly
1353
+ * `specs/<facet>/<domain>/<file>.test.ts`. A test directly at the facet root
1354
+ * (or nested deeper than a domain) is rejected.
1355
+ * - a `*.specification.ts` must sit at the facet root — exactly
1356
+ * `specs/<facet>/<file>.specification.ts`, never inside a domain.
1357
+ *
1358
+ * Module tests under `src/` follow the neighbour rule (I2) and are out of scope.
1359
+ */
1360
+ const c1DomainStructure = {
1361
+ create(context) {
1362
+ return { Program(node) {
1363
+ const parts = segments(context.filename);
1364
+ const base = parts.at(-1) ?? "";
1365
+ const specsIndex = parts.lastIndexOf("specs");
1366
+ if (specsIndex === -1) return;
1367
+ const depth = parts.length - 1 - (specsIndex + 1);
1368
+ if (base.endsWith(TEST_SUFFIX)) {
1369
+ if (depth < 2) context.report({
1370
+ messageId: "testAtFacetRoot",
1371
+ node
1372
+ });
1373
+ else if (depth > 2) context.report({
1374
+ messageId: "testTooDeep",
1375
+ node
1376
+ });
1377
+ return;
1378
+ }
1379
+ if (base.endsWith(SPECIFICATION_SUFFIX)) {
1380
+ if (depth !== 1) context.report({
1381
+ messageId: "specNotAtFacetRoot",
1382
+ node
1383
+ });
1384
+ }
1385
+ } };
1386
+ },
1387
+ meta: {
1388
+ docs: RULE_DOCS["c1-domain-structure"],
1389
+ messages: {
1390
+ specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (CONVENTIONS C1).",
1391
+ testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (CONVENTIONS C1).",
1392
+ testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (CONVENTIONS C1)."
1393
+ },
1394
+ type: "problem"
1395
+ }
1396
+ };
1397
+ //#endregion
1398
+ //#region src/lint/rules/c2-http-only-requests.ts
1399
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1400
+ /**
1401
+ * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1402
+ * a COMPLETE request: method, path, headers, body). Anchored on the feature's
1403
+ * visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
1404
+ * `requests/` directory is probed (cached readdir) and any non-`.http` entry
1405
+ * is reported on the test file.
1406
+ */
1407
+ const c2HttpOnlyRequests = {
1408
+ create(context) {
1409
+ const file = context.physicalFilename;
1410
+ if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
1411
+ return { Program(node) {
1412
+ const requestsDir = join(dirname(file), "requests");
1413
+ for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
1414
+ data: { entry },
1415
+ messageId: "notHttp",
1416
+ node
1417
+ });
1418
+ } };
1419
+ },
1420
+ meta: {
1421
+ docs: RULE_DOCS["c2-http-only-requests"],
1422
+ messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (CONVENTIONS C2)." },
1423
+ type: "problem"
1424
+ }
1425
+ };
1426
+ //#endregion
1427
+ //#region src/lint/rules/c4-contract-shape.ts
1428
+ /** Providers a contract file may declare (CONVENTIONS C4). */
1429
+ const PROVIDERS = /* @__PURE__ */ new Set([
1430
+ "anthropic",
1431
+ "http",
1432
+ "openai"
1433
+ ]);
1434
+ const CONTRACT_NAME = /^[A-Za-z0-9][\w-]*\.(?<provider>[a-z]+)\.[cm]?ts$/;
1435
+ /** Is the import source the framework's public entry (`@jterrazz/test` / `src/index`)? */
1436
+ function isPublicEntry(source) {
1437
+ return source === "@jterrazz/test" || source.endsWith("/src/index.js") || source.endsWith("/src/index.ts");
1438
+ }
1439
+ /**
1440
+ * CONVENTIONS C4 — a contract file (`contracts/<name>.<provider>.ts`) has a
1441
+ * rigid shape: flat under `contracts/` (no subfolders), `provider ∈ { openai,
1442
+ * anthropic, http }`, a single `export default defineContract(...)`, no named
1443
+ * exports, and imports only from the public entry. The runtime channel (the
1444
+ * loader) only catches a bad default export; this rule closes the rest.
1445
+ */
1446
+ const c4ContractShape = {
1447
+ create(context) {
1448
+ const parts = segments(context.physicalFilename);
1449
+ const contractsIndex = parts.lastIndexOf("contracts");
1450
+ if (contractsIndex === -1 || contractsIndex >= parts.length - 1 || !parts.includes("specs")) return {};
1451
+ const base = parts.at(-1) ?? "";
1452
+ return { Program(node) {
1453
+ if (contractsIndex !== parts.length - 2) context.report({
1454
+ data: { base },
1455
+ messageId: "subfolder",
1456
+ node
1457
+ });
1458
+ const match = CONTRACT_NAME.exec(base);
1459
+ if (match === null || !PROVIDERS.has(match.groups?.provider ?? "")) context.report({
1460
+ data: { base },
1461
+ messageId: "badName",
1462
+ node
1463
+ });
1464
+ let hasDefault = false;
1465
+ for (const statement of node.body ?? []) if (statement.type === "ImportDeclaration") {
1466
+ const source = stringValue(statement.source);
1467
+ if (source !== void 0 && !isPublicEntry(source)) context.report({
1468
+ data: { source },
1469
+ messageId: "foreignImport",
1470
+ node: statement
1471
+ });
1472
+ } else if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") context.report({
1473
+ messageId: "namedExport",
1474
+ node: statement
1475
+ });
1476
+ else if (statement.type === "ExportDefaultDeclaration") {
1477
+ hasDefault = true;
1478
+ const declaration = statement.declaration;
1479
+ const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
1480
+ if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
1481
+ messageId: "notDefineContract",
1482
+ node: statement
1483
+ });
1484
+ }
1485
+ if (!hasDefault) context.report({
1486
+ messageId: "missingDefault",
1487
+ node
1488
+ });
1489
+ } };
1490
+ },
1491
+ meta: {
1492
+ docs: RULE_DOCS["c4-contract-shape"],
1493
+ messages: {
1494
+ badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (CONVENTIONS C4).",
1495
+ foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (CONVENTIONS C4).",
1496
+ missingDefault: "Contract file has no `export default defineContract(...)` (CONVENTIONS C4).",
1497
+ namedExport: "Contract files have no named exports — only `export default defineContract(...)` (CONVENTIONS C4).",
1498
+ notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (CONVENTIONS C4).",
1499
+ subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (CONVENTIONS C4)."
1500
+ },
1501
+ type: "problem"
1502
+ }
1503
+ };
1504
+ //#endregion
1505
+ //#region src/lint/rules/c6-tomatch-extension.ts
1506
+ const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/;
1507
+ /** Does the expect(…) subject chain contain a `.directory(…)` call? */
1508
+ function subjectIsDirectory(toMatchCallee) {
1509
+ const expectCall = toMatchCallee.object;
1510
+ if (expectCall?.type !== "CallExpression") return false;
1511
+ const callee = expectCall.callee;
1512
+ if (callee?.type !== "Identifier" || callee.name !== "expect") return false;
1513
+ let isDirectorySubject = false;
1514
+ for (const argument of expectCall.arguments ?? []) walk(argument, (node) => {
1515
+ if (node.type === "CallExpression" && memberPropertyName(node.callee ?? { type: "" }) === "directory") isDirectorySubject = true;
1516
+ });
1517
+ return isDirectorySubject;
1518
+ }
1519
+ /**
1520
+ * CONVENTIONS C6 — the extension is part of the fixture name and mandatory in
1521
+ * `toMatch` (`'help.txt'`, never `'help'`) — except for directory-tree
1522
+ * snapshots, which are directories under `expected/`.
1523
+ *
1524
+ * The directory exception is resolved two ways: statically, when the expect
1525
+ * subject visibly chains `.directory(…)`; else on disk, when
1526
+ * `expected/<name>/` exists next to the spec (cached stat).
1527
+ */
1528
+ const c6ToMatchExtension = {
1529
+ create(context) {
1530
+ const file = context.physicalFilename;
1531
+ if (!TEST_FILE$8.test(file) || !segments(file).includes("specs")) return {};
1532
+ return { CallExpression(node) {
1533
+ const callee = node.callee;
1534
+ if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
1535
+ const argument = node.arguments?.[0];
1536
+ const name = stringValue(argument);
1537
+ if (name === void 0 || name.length === 0) return;
1538
+ if ((name.split("/").at(-1) ?? "").includes(".")) return;
1539
+ if (subjectIsDirectory(callee)) return;
1540
+ if (isDirectory(join(dirname(file), "expected", name))) return;
1541
+ context.report({
1542
+ data: { name },
1543
+ messageId: "missingExtension",
1544
+ node: argument
1545
+ });
1546
+ } };
1547
+ },
1548
+ meta: {
1549
+ docs: RULE_DOCS["c6-tomatch-extension"],
1550
+ 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 (CONVENTIONS C6)." },
1551
+ type: "problem"
1552
+ }
1553
+ };
1554
+ //#endregion
1555
+ //#region src/lint/rules/c7-seeds-sql-only.ts
1556
+ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
1557
+ /**
1558
+ * CONVENTIONS C7 — `.seed()` carries DATABASE state only: `seeds/` contains
1559
+ * nothing but `*.sql` fragments. File state goes through `.fixture()`; there
1560
+ * is no seed handler and no prefix dispatch. Anchored on the feature's visited
1561
+ * test file (cached readdir of the sibling `seeds/`).
1562
+ */
1563
+ const c7SeedsSqlOnly = {
1564
+ create(context) {
1565
+ const file = context.physicalFilename;
1566
+ if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
1567
+ return { Program(node) {
1568
+ const seedsDir = join(dirname(file), "seeds");
1569
+ for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
1570
+ data: { entry },
1571
+ messageId: "notSql",
1572
+ node
1573
+ });
1574
+ } };
1575
+ },
1576
+ meta: {
1577
+ docs: RULE_DOCS["c7-seeds-sql-only"],
1578
+ messages: { notSql: "seeds/{{entry}} is not a .sql file — seeds/ carries database state only; file state goes through .fixture() (CONVENTIONS C7)." },
1579
+ type: "problem"
1580
+ }
1581
+ };
1582
+ //#endregion
1583
+ //#region src/lint/rules/c8-referenced-fixture-exists.ts
1584
+ const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/;
1585
+ /** The conventional sibling directory each fixture-referencing verb reads from. */
1586
+ const VERB_ROOTS = {
1587
+ request: "requests",
1588
+ seed: "seeds",
1589
+ toMatch: "expected"
1590
+ };
1591
+ /**
1592
+ * A fixture reference is a filename/path, never inline content. `.seed()` also
1593
+ * accepts a raw SQL string (integration tests), and any literal with whitespace
1594
+ * or a newline is inline content, not a path — out of C8's reach.
1595
+ */
1596
+ function looksLikePath(value) {
1597
+ return value.length > 0 && !/\s/u.test(value);
1598
+ }
1599
+ /** Nearest ancestor directory named `specs` — the pool root for `$FIXTURES/`. */
1600
+ function specsRoot(file) {
1601
+ let dir = dirname(file);
1602
+ for (;;) {
1603
+ if (basename(dir) === "specs") return dir;
1604
+ const parent = dirname(dir);
1605
+ if (parent === dir) return;
1606
+ dir = parent;
1607
+ }
1608
+ }
1609
+ /** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
1610
+ function resolveFixture(argument, featureDir, file) {
1611
+ if (argument.startsWith("$FIXTURES/")) {
1612
+ const root = specsRoot(file);
1613
+ return root === void 0 ? void 0 : join(root, "fixtures", argument.slice(10));
1614
+ }
1615
+ if (argument.startsWith("$")) return;
1616
+ return join(featureDir, "fixtures", argument);
1617
+ }
1618
+ /**
1619
+ * CONVENTIONS C8 — a fixture referenced by a literal must exist on disk under
1620
+ * its conventional root: `.request(x)`→`requests/x`, `.seed(x)`→`seeds/x`,
1621
+ * `.fixture(x)`→feature `fixtures/` (or the `$FIXTURES/` pool at the nearest
1622
+ * `specs/fixtures/`), `toMatch(x)`→`expected/x` (file or tree snapshot). A typo
1623
+ * that would fail only at runtime is caught statically. Non-literal arguments
1624
+ * are out of static reach and skipped.
1625
+ */
1626
+ const c8ReferencedFixtureExists = {
1627
+ create(context) {
1628
+ const file = context.physicalFilename;
1629
+ if (!TEST_FILE$6.test(file) || !segments(file).includes("specs")) return {};
1630
+ const featureDir = dirname(file);
1631
+ return { CallExpression(node) {
1632
+ const callee = node.callee;
1633
+ const verb = callee === void 0 ? void 0 : memberPropertyName(callee);
1634
+ if (verb === void 0) return;
1635
+ const args = node.arguments ?? [];
1636
+ if (verb === "fixture") {
1637
+ const value = stringValue(args[0]);
1638
+ if (value === void 0 || !looksLikePath(value)) return;
1639
+ const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, file);
1640
+ if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
1641
+ data: {
1642
+ path: value,
1643
+ root: "fixtures"
1644
+ },
1645
+ messageId: "missing",
1646
+ node: args[0]
1647
+ });
1648
+ return;
1649
+ }
1650
+ const root = VERB_ROOTS[verb];
1651
+ if (root === void 0) return;
1652
+ const value = stringValue(args[0]);
1653
+ if (value === void 0 || !looksLikePath(value)) return;
1654
+ const target = join(featureDir, root, value.replace(/\/+$/, ""));
1655
+ if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
1656
+ data: {
1657
+ path: `${root}/${value}`,
1658
+ root
1659
+ },
1660
+ messageId: "missing",
1661
+ node: args[0]
1662
+ });
1663
+ } };
1664
+ },
1665
+ meta: {
1666
+ docs: RULE_DOCS["c8-referenced-fixture-exists"],
1667
+ messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its conventional {{root}}/ root (CONVENTIONS C8). Create it or fix the reference." },
1668
+ type: "problem"
1669
+ }
1670
+ };
1671
+ //#endregion
1672
+ //#region src/lint/rules/d12w-response-body-probe.ts
1673
+ /** A test reading the response body this many times (or more) is a cluster. */
1674
+ const DEFAULT_THRESHOLD = 3;
1675
+ /** Is `node` the `x.response.body` member access itself? */
1676
+ function isResponseBody(node) {
1677
+ if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
1678
+ const object = node.object;
1679
+ return object !== void 0 && memberPropertyName(object) === "response";
1680
+ }
1681
+ /** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
1682
+ function unwrap(node) {
1683
+ let current = node ?? void 0;
1684
+ while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
1685
+ else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
1686
+ else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
1687
+ else return current;
1688
+ }
1689
+ /**
1690
+ * CONVENTIONS D12 (warning) — a full-response golden is the norm for API
1691
+ * assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
1692
+ * of raw body probes — reading `.response.body` (or a variable cast from it)
1693
+ * `threshold`+ times in one test callback — should collapse into one golden:
1694
+ * `expect(result.response).toMatch('case.http')`. One or two probes stay silent
1695
+ * (a legitimate scalpel: absence, a single-field delta, third-party output).
1696
+ *
1697
+ * A probe is a body-rooted read: a direct `.response.body` use, or a field
1698
+ * access on the local binding a `const body = result.response.body as {…}`
1699
+ * introduces. The count is the same threshold the golden transformation applies.
1700
+ */
1701
+ const d12wResponseBodyProbe = {
1702
+ create(context) {
1703
+ if (!segments(context.filename).includes("specs")) return {};
1704
+ const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
1705
+ return { CallExpression(node) {
1706
+ if (!isTestCallee(node.callee)) return;
1707
+ const callback = findTestCallback(node.arguments ?? []);
1708
+ if (callback === void 0) return;
1709
+ const aliases = /* @__PURE__ */ new Set();
1710
+ let responseBodyNodes = 0;
1711
+ walk(callback, (inner) => {
1712
+ if (isResponseBody(inner)) responseBodyNodes++;
1713
+ if (inner.type === "VariableDeclarator") {
1714
+ const init = unwrap(inner.init);
1715
+ const id = inner.id;
1716
+ if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
1717
+ }
1718
+ });
1719
+ let aliasReads = 0;
1720
+ walk(callback, (inner) => {
1721
+ if (inner.type !== "MemberExpression") return;
1722
+ const object = inner.object;
1723
+ if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
1724
+ });
1725
+ const probes = responseBodyNodes - aliases.size + aliasReads;
1726
+ if (probes >= threshold) context.report({
1727
+ data: { count: String(probes) },
1728
+ messageId: "bodyProbeCluster",
1729
+ node
1730
+ });
1731
+ } };
1732
+ },
1733
+ meta: {
1734
+ docs: RULE_DOCS["d12w-response-body-probe"],
1735
+ defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
1736
+ messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (CONVENTIONS D12)." },
1737
+ schema: [{
1738
+ additionalProperties: false,
1739
+ properties: { threshold: {
1740
+ minimum: 1,
1741
+ type: "integer"
1742
+ } },
1743
+ type: "object"
1744
+ }],
1745
+ type: "suggestion"
1746
+ }
1747
+ };
1748
+ //#endregion
1749
+ //#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
1750
+ /**
1751
+ * Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
1752
+ * A frozen fixture opts out of update-mode rewriting, so it is exempt.
1753
+ */
1754
+ function hasFrozenOption(args) {
1755
+ const options = args[1];
1756
+ if (options === void 0 || options.type !== "ObjectExpression") return false;
1757
+ const value = findProperty(options, "frozen")?.value;
1758
+ return value?.type === "Literal" && value.value === true;
1759
+ }
1760
+ /**
1761
+ * Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
1762
+ * negative assertion (`expect(() => …toMatch(…)).toThrow()` or
1763
+ * `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
1764
+ * member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
1765
+ * `expect(x).toMatch('f')` never trips this. The walk stays inside the current
1766
+ * expression: it stops at the first statement boundary or non-`expect` call, so
1767
+ * a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
1768
+ */
1769
+ const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
1770
+ "ArrowFunctionExpression",
1771
+ "AwaitExpression",
1772
+ "ChainExpression",
1773
+ "MemberExpression",
1774
+ "ParenthesizedExpression",
1775
+ "TSAsExpression",
1776
+ "TSNonNullExpression"
1777
+ ]);
1778
+ function isWrappedInExpect(node) {
1779
+ let current = node.parent;
1780
+ while (current !== void 0) {
1781
+ if (current.type === "CallExpression") {
1782
+ const callee = current.callee;
1783
+ return callee?.type === "Identifier" && callee.name === "expect";
1784
+ }
1785
+ if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
1786
+ current = current.parent;
1787
+ }
1788
+ return false;
1789
+ }
1790
+ /**
1791
+ * CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
1792
+ * test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
1793
+ * asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
1794
+ * unfrozen fixture is silently rewritten with the actual output — the matcher
1795
+ * writes instead of throwing — which destroys the negative case (the assertion
1796
+ * then no longer throws). Pass `{ frozen: true }` so the fixture is never
1797
+ * rewritten and the mismatch/error still throws in update mode.
1798
+ *
1799
+ * The structural signal is precise for the wrapped forms; a `toMatch` routed
1800
+ * through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
1801
+ * is out of static reach — see the D13 process note for that residue.
1802
+ */
1803
+ const d13wUnfrozenNegativeFixture = {
1804
+ create(context) {
1805
+ if (!segments(context.filename).includes("specs")) return {};
1806
+ return { CallExpression(node) {
1807
+ const callee = node.callee;
1808
+ if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
1809
+ if (!isWrappedInExpect(node)) return;
1810
+ if (hasFrozenOption(node.arguments ?? [])) return;
1811
+ context.report({
1812
+ messageId: "unfrozenNegativeFixture",
1813
+ node
1814
+ });
1815
+ } };
1816
+ },
1817
+ meta: {
1818
+ docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
1819
+ 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 (CONVENTIONS D13)." },
1820
+ type: "suggestion"
1821
+ }
1822
+ };
1823
+ //#endregion
1824
+ //#region src/lint/rules/d15w-status-only-probe.ts
1825
+ /** An HTTP status code lives in this range — the numeric-literal gate. */
1826
+ const MIN_STATUS = 100;
1827
+ const MAX_STATUS = 599;
1828
+ /** Matchers that pin a single value — the shape of a status probe. */
1829
+ const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
1830
+ /** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
1831
+ function isExpectCall(node) {
1832
+ if (node?.type !== "CallExpression") return false;
1833
+ const callee = node.callee;
1834
+ return callee?.type === "Identifier" && callee.name === "expect";
1835
+ }
1836
+ /** Is `node` a numeric literal in the HTTP status range (100–599)? */
1837
+ function isStatusLiteral(node) {
1838
+ if (node?.type !== "Literal" || typeof node.value !== "number") return false;
1839
+ const value = node.value;
1840
+ return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
1841
+ }
1842
+ /**
1843
+ * Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
1844
+ * `X.status` is any `.status` member access and `N` is a numeric literal in the
1845
+ * HTTP status range? The numeric-literal gate keeps container-status STRINGS
1846
+ * (`expect(neo.status).toBe('running')`) out.
1847
+ */
1848
+ function isStatusProbe(node) {
1849
+ if (node.type !== "CallExpression") return false;
1850
+ const callee = node.callee;
1851
+ if (callee?.type !== "MemberExpression") return false;
1852
+ const matcher = memberPropertyName(callee);
1853
+ if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
1854
+ const expectCall = callee.object;
1855
+ if (!isExpectCall(expectCall)) return false;
1856
+ const subject = (expectCall?.arguments ?? [])[0];
1857
+ if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
1858
+ const expected = (node.arguments ?? [])[0];
1859
+ return isStatusLiteral(expected);
1860
+ }
1861
+ /**
1862
+ * CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
1863
+ * status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
1864
+ * 100–599) throws away the full-response golden. Pin the whole response instead:
1865
+ * `expect(result.response).toMatch('case.http')`.
1866
+ *
1867
+ * This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
1868
+ * `expect(result.status).toBe(422)` as a test's sole assertion falls below that
1869
+ * threshold yet still hides everything the response carries. The rule fires only
1870
+ * when EVERY `expect(…)` in the callback is a status probe — a status probe
1871
+ * sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
1872
+ * the legitimate scalpel and stays silent.
1873
+ */
1874
+ const d15wStatusOnlyProbe = {
1875
+ create(context) {
1876
+ if (!segments(context.filename).includes("specs")) return {};
1877
+ return { CallExpression(node) {
1878
+ if (!isTestCallee(node.callee)) return;
1879
+ const callback = findTestCallback(node.arguments ?? []);
1880
+ if (callback === void 0) return;
1881
+ let expectCalls = 0;
1882
+ let statusProbes = 0;
1883
+ walk(callback, (inner) => {
1884
+ if (isExpectCall(inner)) expectCalls++;
1885
+ if (isStatusProbe(inner)) statusProbes++;
1886
+ });
1887
+ if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
1888
+ messageId: "statusOnlyProbe",
1889
+ node
1890
+ });
1891
+ } };
1892
+ },
1893
+ meta: {
1894
+ docs: RULE_DOCS["d15w-status-only-probe"],
1895
+ messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (CONVENTIONS D11)." },
1896
+ type: "suggestion"
1897
+ }
1898
+ };
1899
+ //#endregion
1900
+ //#region src/lint/rules/d2-await-io-matcher.ts
1901
+ /** Matchers backed by IO (SQL query, disk walk, docker) — must be awaited (D2). */
1902
+ const IO_MATCHERS = /* @__PURE__ */ new Set([
1903
+ "toBeEmpty",
1904
+ "toBeRunning",
1905
+ "toMatchRows"
1906
+ ]);
1907
+ /** Does the member-call spine root at an `expect(…)` call? */
1908
+ function rootsAtExpect(node) {
1909
+ let current = node;
1910
+ while (current !== void 0) if (current.type === "CallExpression") {
1911
+ const callee = current.callee;
1912
+ if (callee?.type === "Identifier" && callee.name === "expect") return true;
1913
+ current = callee;
1914
+ } else if (current.type === "MemberExpression") current = current.object;
1915
+ else return false;
1916
+ return false;
1917
+ }
1918
+ /**
1919
+ * The matcher's promise is dropped only when the call is a bare statement
1920
+ * (`expect(rows).toMatchRows([]);`). Any other position uses the value —
1921
+ * awaited, returned, an arrow body, assigned, or passed to an outer
1922
+ * `expect(promise).rejects.toThrow(…)` — so the promise is handled.
1923
+ */
1924
+ function isDropped(node) {
1925
+ return node.parent?.type === "ExpressionStatement";
1926
+ }
1927
+ /**
1928
+ * CONVENTIONS D2 — the IO-backed matchers (`toMatchRows` runs SQL,
1929
+ * `toBeEmpty`/`toBeRunning` walk disk / query docker) return a promise and must
1930
+ * be `await`ed (or returned). A bare `expect(rows).toMatchRows(...)` silently
1931
+ * resolves nothing and the assertion never runs.
1932
+ */
1933
+ const d2AwaitIoMatcher = {
1934
+ create(context) {
1935
+ return { CallExpression(node) {
1936
+ const callee = node.callee;
1937
+ if (callee === void 0) return;
1938
+ const matcher = memberPropertyName(callee);
1939
+ if (matcher === void 0 || !IO_MATCHERS.has(matcher)) return;
1940
+ if (!rootsAtExpect(callee.object)) return;
1941
+ if (isDropped(node)) context.report({
1942
+ data: { matcher },
1943
+ messageId: "mustAwait",
1944
+ node
1945
+ });
1946
+ } };
1947
+ },
1948
+ meta: {
1949
+ docs: RULE_DOCS["d2-await-io-matcher"],
1950
+ messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (CONVENTIONS D2)." },
1951
+ type: "problem"
1952
+ }
1953
+ };
1954
+ //#endregion
1955
+ //#region src/lint/rules/d2w-await-sync-matcher.ts
1956
+ /** Always-synchronous matchers — awaiting them is redundant noise (D2). */
1957
+ const SYNC_MATCHERS = /* @__PURE__ */ new Set([
1958
+ "toBe",
1959
+ "toContain",
1960
+ "toEqual",
1961
+ "toHaveLength"
1962
+ ]);
1963
+ /**
1964
+ * Does the member-call spine root at an `expect(…)` call, WITHOUT a `.resolves`
1965
+ * / `.rejects` modifier? Those modifiers make an otherwise-sync matcher async,
1966
+ * so `await expect(p).resolves.toEqual(…)` is NOT a redundant await.
1967
+ */
1968
+ function rootsAtSyncExpect(node) {
1969
+ let current = node;
1970
+ while (current !== void 0) if (current.type === "CallExpression") {
1971
+ const callee = current.callee;
1972
+ if (callee?.type === "Identifier" && callee.name === "expect") return true;
1973
+ current = callee;
1974
+ } else if (current.type === "MemberExpression") {
1975
+ const property = memberPropertyName(current);
1976
+ if (property === "resolves" || property === "rejects") return false;
1977
+ current = current.object;
1978
+ } else return false;
1979
+ return false;
1980
+ }
1981
+ /**
1982
+ * CONVENTIONS D2 (warning) — the mirror of {@link d2AwaitIoMatcher}: only the
1983
+ * IO-backed matchers are async. `await expect(x).toBe(y)` awaits a plain value,
1984
+ * which reads as if the assertion were IO and hides the D2 signal. Drop the
1985
+ * `await`.
1986
+ */
1987
+ const d2wAwaitSyncMatcher = {
1988
+ create(context) {
1989
+ return { CallExpression(node) {
1990
+ if (node.parent?.type !== "AwaitExpression") return;
1991
+ const callee = node.callee;
1992
+ if (callee === void 0) return;
1993
+ const matcher = memberPropertyName(callee);
1994
+ if (matcher === void 0 || !SYNC_MATCHERS.has(matcher)) return;
1995
+ if (rootsAtSyncExpect(callee.object)) context.report({
1996
+ data: { matcher },
1997
+ messageId: "redundantAwait",
1998
+ node
1999
+ });
2000
+ } };
2001
+ },
2002
+ meta: {
2003
+ docs: RULE_DOCS["d2w-await-sync-matcher"],
2004
+ messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (CONVENTIONS D2)." },
2005
+ type: "suggestion"
2006
+ }
2007
+ };
2008
+ //#endregion
2009
+ //#region src/lint/rules/d6w-transform-token-equivalent.ts
2010
+ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
2011
+ "any",
2012
+ "base64",
2013
+ "date",
2014
+ "duration",
2015
+ "email",
2016
+ "float",
2017
+ "hex",
2018
+ "int",
2019
+ "ip",
2020
+ "iso8601",
2021
+ "number",
2022
+ "path",
2023
+ "port",
2024
+ "semver",
2025
+ "sha",
2026
+ "string",
2027
+ "time",
2028
+ "ulid",
2029
+ "url",
2030
+ "uuid",
2031
+ "workdir"
2032
+ ].join("|")})(?:#[\\w.-]+)?\\}\\}$`);
2033
+ /**
2034
+ * Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
2035
+ * Accepts an arrow with an expression body or a single-return body whose
2036
+ * expression is nested `.replace()` calls; every replacement must be a string
2037
+ * literal that is exactly a known `{{token}}`.
2038
+ */
2039
+ function isTokenEquivalentTransform(fn) {
2040
+ if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") return false;
2041
+ let expression = fn.body;
2042
+ if (expression?.type === "BlockStatement") {
2043
+ const statements = expression.body ?? [];
2044
+ if (statements.length !== 1 || statements[0].type !== "ReturnStatement") return false;
2045
+ expression = statements[0].argument;
2046
+ }
2047
+ let sawReplace = false;
2048
+ while (expression?.type === "CallExpression") {
2049
+ const callee = expression.callee;
2050
+ if (callee === void 0 || memberPropertyName(callee) !== "replace") return false;
2051
+ const replacement = stringValue(expression.arguments?.[1]);
2052
+ if (replacement === void 0 || !KNOWN_TOKEN_LITERAL.test(replacement)) return false;
2053
+ sawReplace = true;
2054
+ expression = callee.object;
2055
+ }
2056
+ return sawReplace && expression?.type === "Identifier";
2057
+ }
2058
+ /**
2059
+ * CONVENTIONS D6 (warning) — `transform` survives only as an escape hatch for
2060
+ * applicative noise the tokens do not cover (ANSI is already stripped by
2061
+ * default). A transform that only rewrites dynamic segments into known
2062
+ * `{{token}}` literals duplicates the token grammar: put the tokens in the
2063
+ * fixture instead.
2064
+ */
2065
+ const d6wTransformTokenEquivalent = {
2066
+ create(context) {
2067
+ return { CallExpression(node) {
2068
+ if (specificationMember(node) === void 0) return;
2069
+ for (const argument of node.arguments ?? []) {
2070
+ const transform = findProperty(argument, "transform");
2071
+ const fn = transform?.value;
2072
+ if (fn !== void 0 && isTokenEquivalentTransform(fn)) context.report({
2073
+ messageId: "tokenEquivalent",
2074
+ node: transform ?? node
2075
+ });
2076
+ }
2077
+ } };
2078
+ },
2079
+ meta: {
2080
+ docs: RULE_DOCS["d6w-transform-token-equivalent"],
2081
+ 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 (CONVENTIONS D6)." },
2082
+ type: "suggestion"
2083
+ }
2084
+ };
2085
+ //#endregion
2086
+ //#region src/lint/rules/d8w-text-bypass.ts
2087
+ /** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
2088
+ const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
2089
+ /**
2090
+ * CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
2091
+ * `expect(result.text).toContain(…)` throws away the typed subject (its token
2092
+ * grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
2093
+ * Prefer asserting on the accessor subject itself.
2094
+ */
2095
+ const d8wTextBypass = {
2096
+ create(context) {
2097
+ return { CallExpression(node) {
2098
+ const callee = node.callee;
2099
+ const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
2100
+ if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
2101
+ const expectCall = callee?.object;
2102
+ if (expectCall?.type !== "CallExpression") return;
2103
+ const expectCallee = expectCall.callee;
2104
+ if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
2105
+ const subject = expectCall.arguments?.[0];
2106
+ if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
2107
+ data: { matcher },
2108
+ messageId: "textBypass",
2109
+ node
2110
+ });
2111
+ } };
2112
+ },
2113
+ meta: {
2114
+ docs: RULE_DOCS["d8w-text-bypass"],
2115
+ 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 (CONVENTIONS D8)." },
2116
+ type: "suggestion"
2117
+ }
2118
+ };
2119
+ //#endregion
2120
+ //#region src/lint/rules/d9w-single-use-ref.ts
2121
+ const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/;
2122
+ /** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
2123
+ const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/g;
2124
+ /** Is this call `match.ref(...)`? */
2125
+ function isMatchRef(node) {
2126
+ const callee = node.callee;
2127
+ if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
2128
+ const object = callee.object;
2129
+ return object?.type === "Identifier" && object.name === "match";
2130
+ }
2131
+ /** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
2132
+ function refsInFixture(target) {
2133
+ const refs = [];
2134
+ const collect = (text) => {
2135
+ for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
2136
+ };
2137
+ if (isFile(target)) {
2138
+ const text = readFileCached(target);
2139
+ if (text !== null) collect(text);
2140
+ return refs;
2141
+ }
2142
+ if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture(join(target, entry)));
2143
+ return refs;
2144
+ }
2145
+ /**
2146
+ * CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
2147
+ * `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
2148
+ * point of a capture is the equality check on later occurrences). Aggregating
2149
+ * per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
2150
+ * `expected/` fixtures the file references — a ref that appears exactly once
2151
+ * everywhere is a plain matcher wearing a name.
2152
+ */
2153
+ const d9wSingleUseRef = {
2154
+ create(context) {
2155
+ const file = context.physicalFilename;
2156
+ if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
2157
+ const featureDir = dirname(file);
2158
+ return { Program(program) {
2159
+ const counts = /* @__PURE__ */ new Map();
2160
+ const codeNodes = /* @__PURE__ */ new Map();
2161
+ const fixtureNodes = /* @__PURE__ */ new Map();
2162
+ const bump = (ref) => {
2163
+ counts.set(ref, (counts.get(ref) ?? 0) + 1);
2164
+ };
2165
+ walk(program, (node) => {
2166
+ if (node.type !== "CallExpression") return;
2167
+ if (isMatchRef(node)) {
2168
+ const ref = stringValue(node.arguments?.[0]);
2169
+ if (ref !== void 0) {
2170
+ bump(ref);
2171
+ if (!codeNodes.has(ref)) codeNodes.set(ref, node);
2172
+ }
2173
+ return;
2174
+ }
2175
+ if (memberPropertyName(node.callee) === "toMatch") {
2176
+ const name = stringValue(node.arguments?.[0]);
2177
+ if (name !== void 0) {
2178
+ const target = join(featureDir, "expected", name.replace(/\/+$/, ""));
2179
+ for (const ref of refsInFixture(target)) {
2180
+ bump(ref);
2181
+ if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
2182
+ }
2183
+ }
2184
+ }
2185
+ });
2186
+ for (const [ref, count] of counts) {
2187
+ const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
2188
+ if (count === 1 && node !== void 0) context.report({
2189
+ data: { ref },
2190
+ messageId: "singleUse",
2191
+ node
2192
+ });
2193
+ }
2194
+ } };
2195
+ },
2196
+ meta: {
2197
+ docs: RULE_DOCS["d9w-single-use-ref"],
2198
+ 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 (CONVENTIONS D9)." },
2199
+ type: "suggestion"
2200
+ }
2201
+ };
2202
+ //#endregion
2203
+ //#region src/lint/rules/f1-no-subpath-import.ts
2204
+ const PACKAGE$1 = "@jterrazz/test";
2205
+ const TOOL_SUBPATH$2 = "@jterrazz/test/oxlint";
2206
+ /**
2207
+ * CONVENTIONS F1 — everything is imported from `@jterrazz/test`; subpaths do
2208
+ * not exist. Sanctioned exception: `@jterrazz/test/oxlint`, the tool-facing,
2209
+ * zero-runtime plugin entry — exempt from any file (an oxlint config, a shared
2210
+ * preset, whatever wires the lint layer).
2211
+ */
2212
+ const f1NoSubpathImport = {
2213
+ create(context) {
2214
+ return { ...importSourceVisitor(({ node, source }) => {
2215
+ if (!source.startsWith(`${PACKAGE$1}/`)) return;
2216
+ if (source === TOOL_SUBPATH$2) return;
2217
+ context.report({
2218
+ data: { source },
2219
+ messageId: "subpath",
2220
+ node
2221
+ });
2222
+ }) };
2223
+ },
2224
+ meta: {
2225
+ docs: RULE_DOCS["f1-no-subpath-import"],
2226
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (CONVENTIONS F1; only the tool-facing @jterrazz/test/oxlint is exempt)." },
2227
+ type: "problem"
2228
+ }
2229
+ };
2230
+ //#endregion
2231
+ //#region src/lint/rules/f2-no-test-imports-in-prod.ts
2232
+ const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/;
2233
+ const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/;
2234
+ const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/;
2235
+ const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
2236
+ /**
2237
+ * CONVENTIONS F2 — production code never imports a test artefact. In a
2238
+ * non-test file (not `*.test.ts` / `*.fixtures.ts` / `*.specification.ts`,
2239
+ * not under `specs/`, not a tool config), importing `vitest`,
2240
+ * `@jterrazz/test`, a `*.test.*` module or a `*.fixtures.*` module is flagged.
2241
+ *
2242
+ * Sanctioned exception: `@jterrazz/test/oxlint`, the tool-facing, zero-runtime
2243
+ * lint entry (F1) — a shared oxlint preset legitimately imports it from prod
2244
+ * code.
2245
+ *
2246
+ * This repo's `src/vitest/` layer (the framework's own runner coupling, I1)
2247
+ * disables the rule via a config override.
2248
+ */
2249
+ const f2NoTestImportsInProd = {
2250
+ create(context) {
2251
+ const file = context.filename;
2252
+ if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || segments(file).includes("specs")) return {};
2253
+ return { ...importSourceVisitor(({ node, source }) => {
2254
+ if (source === TOOL_SUBPATH$1) return;
2255
+ if (source === "vitest" || source.startsWith("vitest/")) context.report({
2256
+ data: { source },
2257
+ messageId: "testImport",
2258
+ node
2259
+ });
2260
+ else if (source === "@jterrazz/test" || source.startsWith("@jterrazz/test/")) context.report({
2261
+ data: { source },
2262
+ messageId: "testImport",
2263
+ node
2264
+ });
2265
+ else if (TEST_IMPORT$1.test(source)) context.report({
2266
+ data: { source },
2267
+ messageId: "testImport",
2268
+ node
2269
+ });
2270
+ }) };
2271
+ },
2272
+ meta: {
2273
+ docs: RULE_DOCS["f2-no-test-imports-in-prod"],
2274
+ 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 (CONVENTIONS F2)." },
2275
+ type: "problem"
2276
+ }
2277
+ };
2278
+ //#endregion
2279
+ //#region src/lint/rules/f3-specs-public-entry.ts
2280
+ const PACKAGE = "@jterrazz/test";
2281
+ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
2282
+ /**
2283
+ * The framework's own top-level `src/` layers. Deep-importing any of these from
2284
+ * a spec couples the spec to internals the public entry exists to hide. These
2285
+ * segment names are only meaningful inside the framework repo itself — a
2286
+ * consumer app that happens to have `src/app.ts` never reaches one, so its own
2287
+ * spec-to-app imports stay allowed (that IS the documented pattern:
2288
+ * `server: () => createApp()` importing `../../src/app.js`).
2289
+ */
2290
+ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
2291
+ "core",
2292
+ "integrations",
2293
+ "lint",
2294
+ "vitest"
2295
+ ]);
2296
+ /**
2297
+ * CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
2298
+ * forbidden: a relative path resolving inside the framework repo's
2299
+ * `src/{core,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
2300
+ * other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
2301
+ * imports of its OWN app source are always allowed — that is the pattern.
2302
+ *
2303
+ * Distinct from F1 (which bans `@jterrazz/test/<subpath>` from any file): F3 is
2304
+ * the specs-specific guard that also reaches relative framework-internal paths.
2305
+ * Documented exception: specs under `specs/integrations/` may deep-import the
2306
+ * integration module they cover (`src/integrations/**`).
2307
+ */
2308
+ const f3SpecsPublicEntry = {
2309
+ create(context) {
2310
+ const parts = segments(context.filename);
2311
+ const specsIndex = parts.indexOf("specs");
2312
+ if (specsIndex === -1) return {};
2313
+ const underIntegrations = parts[specsIndex + 1] === "integrations";
2314
+ return { ...importSourceVisitor(({ node, source }) => {
2315
+ if (source.startsWith(`${PACKAGE}/`)) {
2316
+ if (source === TOOL_SUBPATH) return;
2317
+ context.report({
2318
+ data: { source },
2319
+ messageId: "deepImport",
2320
+ node
2321
+ });
2322
+ return;
2323
+ }
2324
+ const marker = source.indexOf("/src/");
2325
+ if (marker === -1 && !source.startsWith("src/")) return;
2326
+ const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0];
2327
+ if (!FRAMEWORK_LAYERS.has(layer)) return;
2328
+ if (underIntegrations && layer === "integrations") return;
2329
+ context.report({
2330
+ data: { source },
2331
+ messageId: "deepImport",
2332
+ node
2333
+ });
2334
+ }) };
2335
+ },
2336
+ meta: {
2337
+ docs: RULE_DOCS["f3-specs-public-entry"],
2338
+ 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}}\" (CONVENTIONS F3; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
2339
+ type: "problem"
2340
+ }
2341
+ };
2342
+ //#endregion
2343
+ //#region src/lint/rules/f4-no-test-to-test-import.ts
2344
+ const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/;
2345
+ const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/;
2346
+ /**
2347
+ * CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
2348
+ * test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
2349
+ * `*.specification.ts` file (A1).
2350
+ */
2351
+ const f4NoTestToTestImport = {
2352
+ create(context) {
2353
+ if (!TEST_FILE$3.test(context.filename)) return {};
2354
+ return { ...importSourceVisitor(({ node, source }) => {
2355
+ if (TEST_IMPORT.test(source)) context.report({
2356
+ data: { source },
2357
+ messageId: "testToTest",
2358
+ node
2359
+ });
2360
+ }) };
2361
+ },
2362
+ meta: {
2363
+ docs: RULE_DOCS["f4-no-test-to-test-import"],
2364
+ messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (CONVENTIONS F4)." },
2365
+ type: "problem"
2366
+ }
2367
+ };
2368
+ //#endregion
2369
+ //#region src/lint/rules/f5-fixtures-only-from-tests.ts
2370
+ const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/;
2371
+ const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/;
2372
+ /**
2373
+ * CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
2374
+ * `*.test.ts` files. Any other importer (prod code, a specification file,
2375
+ * another fixtures module) is flagged.
2376
+ */
2377
+ const f5FixturesOnlyFromTests = {
2378
+ create(context) {
2379
+ if (TEST_FILE$2.test(context.filename)) return {};
2380
+ return { ...importSourceVisitor(({ node, source }) => {
2381
+ if (FIXTURES_IMPORT.test(source)) context.report({
2382
+ data: { source },
2383
+ messageId: "fixturesImport",
2384
+ node
2385
+ });
2386
+ }) };
2387
+ },
2388
+ meta: {
2389
+ docs: RULE_DOCS["f5-fixtures-only-from-tests"],
2390
+ messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (CONVENTIONS F5)." },
2391
+ type: "problem"
2392
+ }
2393
+ };
2394
+ //#endregion
2395
+ //#region src/lint/rules/i1-layer-boundaries.ts
2396
+ /**
2397
+ * One folder = one external dependency (CONVENTIONS I1): the packages each
2398
+ * `src/integrations/<folder>/` may import.
2399
+ */
2400
+ const INTEGRATION_DEPS = {
2401
+ anthropic: ["@anthropic-ai/sdk"],
2402
+ compose: ["yaml"],
2403
+ docker: [],
2404
+ hono: ["hono", "@hono/node-server"],
2405
+ msw: ["msw"],
2406
+ openai: ["openai"],
2407
+ postgres: ["pg"],
2408
+ redis: ["redis"],
2409
+ sqlite: ["better-sqlite3"],
2410
+ testcontainers: ["testcontainers"]
2411
+ };
2412
+ /** External packages the vitest layer (ALL runner coupling) may import. */
2413
+ const VITEST_DEPS = [
2414
+ "vitest",
2415
+ "vitest-mock-extended",
2416
+ "mockdate"
2417
+ ];
2418
+ /** The pure core helpers the tool-facing lint layer may reach (I1). */
2419
+ const LINT_CORE_WHITELIST = /* @__PURE__ */ new Set([
2420
+ "core/matching/match",
2421
+ "core/specification/shared/binding",
2422
+ "core/specification/shared/fixtures"
2423
+ ]);
2424
+ const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
2425
+ function matchesPackage(source, packages) {
2426
+ return packages.some((name) => source === name || source.startsWith(`${name}/`));
2427
+ }
2428
+ /** Path relative to the (last) `src` segment, `undefined` when outside src. */
2429
+ function pathInsideSrc(path) {
2430
+ const parts = segments(path);
2431
+ const srcIndex = parts.lastIndexOf("src");
2432
+ return srcIndex === -1 ? void 0 : parts.slice(srcIndex + 1).join("/");
2433
+ }
2434
+ /** Strip a `.js`/`.ts`-style extension for whitelist comparison. */
2435
+ function withoutExtension(path) {
2436
+ return path.replace(/\.[cm]?[jt]sx?$/, "");
2437
+ }
2438
+ /**
2439
+ * CONVENTIONS I1 — the four layers under `src/` and their sanctioned edges:
2440
+ *
2441
+ * - `core/` — zero external imports; may reach `integrations/docker`,
2442
+ * `integrations/hono`, `vitest/matchers`, and (from `builder.ts` only, the
2443
+ * lazy MSW seam) `integrations/msw`.
2444
+ * - `integrations/<dep>/` — its own external dependency and `core/` only.
2445
+ * - `vitest/` — the runner coupling: `vitest`, `vitest-mock-extended`,
2446
+ * `mockdate`, plus `core/` and `integrations/docker` (the matchers recognise
2447
+ * the zero-dependency ContainerAccessor subject).
2448
+ * - `lint/` — zero runtime imports: no external packages, and from `core/`
2449
+ * only the pure helpers (token list, case conversions, fixture markers).
2450
+ *
2451
+ * Module tests and fixtures files are exempt (their imports are governed by
2452
+ * F2/I4); `src/index.ts` is the composition root and lives above the layers.
2453
+ */
2454
+ const i1LayerBoundaries = {
2455
+ create(context) {
2456
+ const file = context.physicalFilename;
2457
+ if (TEST_OR_FIXTURES.test(file)) return {};
2458
+ const inside = pathInsideSrc(file);
2459
+ const layer = inside?.split("/")[0];
2460
+ if (inside === void 0 || layer === void 0 || ![
2461
+ "core",
2462
+ "integrations",
2463
+ "lint",
2464
+ "vitest"
2465
+ ].includes(layer)) return {};
2466
+ const integrationFolder = layer === "integrations" ? inside.split("/")[1] : void 0;
2467
+ const checkExternal = (source) => {
2468
+ if (layer === "core") return "coreExternal";
2469
+ if (layer === "lint") return "lintRuntime";
2470
+ if (layer === "vitest") return matchesPackage(source, VITEST_DEPS) ? null : "foreignDependency";
2471
+ return matchesPackage(source, INTEGRATION_DEPS[integrationFolder ?? ""] ?? []) ? null : "foreignDependency";
2472
+ };
2473
+ const checkInternal = (target) => {
2474
+ if (layer === "core") {
2475
+ if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2476
+ if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2477
+ return "crossLayer";
2478
+ }
2479
+ if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
2480
+ if (layer === "vitest") return target.startsWith("core/") || target.startsWith("vitest/") || target.startsWith("integrations/docker/") ? null : "crossLayer";
2481
+ return target.startsWith("lint/") || LINT_CORE_WHITELIST.has(withoutExtension(target)) ? null : "lintRuntime";
2482
+ };
2483
+ return { ...importSourceVisitor(({ node, source }) => {
2484
+ if (source.startsWith("node:")) return;
2485
+ let messageId;
2486
+ if (source.startsWith(".")) {
2487
+ const target = pathInsideSrc(resolve(dirname(file), source));
2488
+ messageId = target === void 0 ? "crossLayer" : checkInternal(target);
2489
+ } else messageId = checkExternal(source);
2490
+ if (messageId !== null) context.report({
2491
+ data: {
2492
+ layer,
2493
+ source
2494
+ },
2495
+ messageId,
2496
+ node
2497
+ });
2498
+ }) };
2499
+ },
2500
+ meta: {
2501
+ docs: RULE_DOCS["i1-layer-boundaries"],
2502
+ messages: {
2503
+ coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (CONVENTIONS I1).",
2504
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (CONVENTIONS I1).",
2505
+ foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (CONVENTIONS I1).",
2506
+ lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (CONVENTIONS I1)."
2507
+ },
2508
+ type: "problem"
2509
+ }
2510
+ };
2511
+ //#endregion
2512
+ //#region src/lint/rules/i2-sibling-test-naming.ts
2513
+ const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/;
2514
+ /**
2515
+ * CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
2516
+ * `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
2517
+ * files:
2518
+ *
2519
+ * - a `src/**` test whose neighbour module `<file>.ts` does not exist;
2520
+ * - any file living in a `__tests__/` directory under `src/`;
2521
+ * - any file under a root-level `tests/` directory (the only test roots are
2522
+ * sibling module tests under `src/` and product specifications in `specs/`).
2523
+ */
2524
+ const i2SiblingTestNaming = {
2525
+ create(context) {
2526
+ const file = context.physicalFilename;
2527
+ const parts = segments(file);
2528
+ return { Program(node) {
2529
+ if (parts.includes("src")) {
2530
+ if (parts.includes("__tests__")) context.report({
2531
+ messageId: "testsDir",
2532
+ node
2533
+ });
2534
+ else if (TEST_FILE$1.test(file)) {
2535
+ const neighbours = [
2536
+ ".ts",
2537
+ ".tsx",
2538
+ ".js"
2539
+ ].map((extension) => file.replace(TEST_FILE$1, extension));
2540
+ if (!neighbours.some(isFile)) context.report({
2541
+ data: { expected: neighbours[0].split(/[/\\]/).at(-1) ?? "" },
2542
+ messageId: "orphanTest",
2543
+ node
2544
+ });
2545
+ }
2546
+ }
2547
+ const testsIndex = parts.indexOf("tests");
2548
+ if (testsIndex > 0) {
2549
+ if (isFile(`${`/${parts.slice(0, testsIndex).join("/")}`}/package.json`)) context.report({
2550
+ messageId: "rootTests",
2551
+ node
2552
+ });
2553
+ }
2554
+ } };
2555
+ },
2556
+ meta: {
2557
+ docs: RULE_DOCS["i2-sibling-test-naming"],
2558
+ messages: {
2559
+ orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (CONVENTIONS I2). A test needing more than its module is a specification: move it to specs/.",
2560
+ rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (CONVENTIONS I2).",
2561
+ testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (CONVENTIONS I2)."
2562
+ },
2563
+ type: "problem"
2564
+ }
2565
+ };
2566
+ //#endregion
2567
+ //#region src/lint/rules/i4-no-vi-mock-in-src.ts
2568
+ const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
2569
+ /** Module-ish sources a src test may import; anything else with an extension is a data asset. */
2570
+ const CODE_IMPORT = /\.[cm]?[jt]sx?$/;
2571
+ /** Does this import path even carry an extension? Bare/extension-less ones are code. */
2572
+ const HAS_EXTENSION = /\.[a-z0-9]+$/i;
2573
+ /**
2574
+ * CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
2575
+ * `mockOf`/`mockOfDate` inline, large payloads in a `*.fixtures.ts` neighbour.
2576
+ * Flags, under `src/`:
2577
+ *
2578
+ * - `vi.mock(…)` calls (module mocking) in any file;
2579
+ * - files living in a `__mocks__/` or `__fixtures__/` directory;
2580
+ * - a `*.test.ts` importing a non-code asset (`.json`, `.txt`, `.sql`, …) —
2581
+ * a test needing a real file is a specification and belongs in `specs/`.
2582
+ */
2583
+ const i4NoViMockInSrc = {
2584
+ create(context) {
2585
+ const parts = segments(context.filename);
2586
+ if (!parts.includes("src")) return {};
2587
+ const banned = parts.find((part) => part === "__mocks__" || part === "__fixtures__");
2588
+ const isTest = TEST_FILE.test(context.filename);
2589
+ const visitor = {
2590
+ CallExpression(node) {
2591
+ const callee = node.callee;
2592
+ if (callee?.type !== "MemberExpression" || callee.computed === true) return;
2593
+ const object = callee.object;
2594
+ const property = callee.property;
2595
+ if (object?.type === "Identifier" && object.name === "vi" && property?.type === "Identifier" && (property.name === "mock" || property.name === "doMock")) context.report({
2596
+ messageId: "viMock",
2597
+ node
2598
+ });
2599
+ },
2600
+ Program(node) {
2601
+ if (banned !== void 0) context.report({
2602
+ data: { dir: banned },
2603
+ messageId: "bannedDir",
2604
+ node
2605
+ });
2606
+ }
2607
+ };
2608
+ if (isTest) Object.assign(visitor, importSourceVisitor(({ node, source }) => {
2609
+ if (HAS_EXTENSION.test(source) && !CODE_IMPORT.test(source)) context.report({
2610
+ data: { source },
2611
+ messageId: "assetImport",
2612
+ node
2613
+ });
2614
+ }));
2615
+ return visitor;
2616
+ },
2617
+ meta: {
2618
+ docs: RULE_DOCS["i4-no-vi-mock-in-src"],
2619
+ messages: {
2620
+ assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (CONVENTIONS I4).",
2621
+ bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (CONVENTIONS I4).",
2622
+ viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (CONVENTIONS I4)."
2623
+ },
2624
+ type: "problem"
2625
+ }
2626
+ };
2627
+ //#endregion
2628
+ //#region src/lint/rules/j1-no-only-skip.ts
2629
+ /** Test runners whose `.only` / `.skip` modifiers must never be committed. */
2630
+ const RUNNERS = /* @__PURE__ */ new Set([
2631
+ "describe",
2632
+ "it",
2633
+ "test"
2634
+ ]);
2635
+ /** The focus/skip modifiers CONVENTIONS J1 forbids. */
2636
+ const FORBIDDEN = /* @__PURE__ */ new Set(["only", "skip"]);
2637
+ /**
2638
+ * CONVENTIONS J1 — no committed `.only` / `.skip`.
2639
+ *
2640
+ * Flags the member form used across the specs: `describe.only`, `test.only`,
2641
+ * `it.only` and their `.skip` counterparts. Modifiers that legitimately gate a
2642
+ * test (`test.skipIf`, `test.runIf`) are untouched — only the bare `only`/`skip`
2643
+ * that pin or disable a test in a commit are reported.
2644
+ */
2645
+ const j1NoOnlySkip = {
2646
+ create(context) {
2647
+ return { MemberExpression(node) {
2648
+ if (node.computed === true) return;
2649
+ const object = node.object;
2650
+ const property = node.property;
2651
+ if (object?.type !== "Identifier" || property?.type !== "Identifier") return;
2652
+ const runner = object.name;
2653
+ const modifier = property.name;
2654
+ if (RUNNERS.has(runner) && FORBIDDEN.has(modifier)) context.report({
2655
+ data: {
2656
+ modifier,
2657
+ runner
2658
+ },
2659
+ messageId: "forbidden",
2660
+ node
2661
+ });
2662
+ } };
2663
+ },
2664
+ meta: {
2665
+ docs: RULE_DOCS["j1-no-only-skip"],
2666
+ messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (CONVENTIONS J1). Remove it before committing." },
2667
+ type: "problem"
2668
+ }
2669
+ };
2670
+ //#endregion
2671
+ //#region src/lint/rules/j2-no-sleep-in-specs.ts
2672
+ /**
2673
+ * CONVENTIONS J2 — spec files (`specs/**`) contain no arbitrary sleeps:
2674
+ * synchronisation goes through `waitFor` / the framework's own mechanisms.
2675
+ * Flags `setTimeout(…)` calls (bare or as a member, e.g.
2676
+ * `globalThis.setTimeout`) and imports of `node:timers/promises`.
2677
+ */
2678
+ const j2NoSleepInSpecs = {
2679
+ create(context) {
2680
+ if (!segments(context.filename).includes("specs")) return {};
2681
+ return {
2682
+ CallExpression(node) {
2683
+ const callee = node.callee;
2684
+ if (callee === void 0) return;
2685
+ const name = callee.type === "Identifier" ? callee.name : memberPropertyName(callee);
2686
+ if (name === "setTimeout" || name === "setInterval") {
2687
+ context.report({
2688
+ messageId: "sleep",
2689
+ node
2690
+ });
2691
+ return;
2692
+ }
2693
+ if (name === "wait" && callee.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "Atomics") context.report({
2694
+ messageId: "sleep",
2695
+ node
2696
+ });
2697
+ },
2698
+ ...importSourceVisitor(({ node, source }) => {
2699
+ if (source === "node:timers/promises" || source === "timers/promises") context.report({
2700
+ messageId: "timersImport",
2701
+ node
2702
+ });
2703
+ })
2704
+ };
2705
+ },
2706
+ meta: {
2707
+ docs: RULE_DOCS["j2-no-sleep-in-specs"],
2708
+ messages: {
2709
+ sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2).",
2710
+ timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2)."
2711
+ },
2712
+ type: "problem"
2713
+ }
2714
+ };
2715
+ //#endregion
2716
+ //#region src/lint/rules/j3-no-expectless-test.ts
2717
+ /** Does `callback` contain at least one `expect(…)` call? */
2718
+ function hasExpect(callback) {
2719
+ let found = false;
2720
+ walk(callback, (node) => {
2721
+ if (found || node.type !== "CallExpression") return;
2722
+ const callee = node.callee;
2723
+ if (callee?.type === "Identifier" && callee.name === "expect") found = true;
2724
+ });
2725
+ return found;
2726
+ }
2727
+ /**
2728
+ * CONVENTIONS J3 — a test asserts something. A `test('…', () => { … })` whose
2729
+ * body contains no `expect(…)` is either dead or a silent no-op.
2730
+ *
2731
+ * Reuses the shared B4 test-shape helpers. `test.todo(...)` (and any other
2732
+ * callback-less form) is skipped — there is nothing to assert yet.
2733
+ */
2734
+ const j3NoExpectlessTest = {
2735
+ create(context) {
2736
+ return { CallExpression(node) {
2737
+ const callee = node.callee;
2738
+ if (!isTestCallee(callee)) return;
2739
+ if (testCalleeHasModifier(callee, "todo")) return;
2740
+ const callback = findTestCallback(node.arguments ?? []);
2741
+ if (callback === void 0) return;
2742
+ if (!hasExpect(callback)) context.report({
2743
+ messageId: "noExpect",
2744
+ node
2745
+ });
2746
+ } };
2747
+ },
2748
+ meta: {
2749
+ docs: RULE_DOCS["j3-no-expectless-test"],
2750
+ messages: { noExpect: "Test has no `expect(…)` — a test must assert something (CONVENTIONS J3). Use `test.todo` for a pending test." },
2751
+ type: "problem"
2752
+ }
2753
+ };
2754
+ //#endregion
2755
+ //#region src/lint/rules/j4-unique-test-names.ts
2756
+ /**
2757
+ * CONVENTIONS J4 — test names are the sole description of behaviour (B3), so two
2758
+ * tests sharing a literal name in one file are a copy-paste smell: one shadows
2759
+ * the other in the reporter. Flags the second (and later) occurrence.
2760
+ *
2761
+ * `.each` / parametrized tests are skipped — their name is a template expanded
2762
+ * per row, collisions there are by construction.
2763
+ */
2764
+ const j4UniqueTestNames = {
2765
+ create(context) {
2766
+ const seen = /* @__PURE__ */ new Set();
2767
+ return { CallExpression(node) {
2768
+ const callee = node.callee;
2769
+ if (!isTestCallee(callee)) return;
2770
+ if (testCalleeHasModifier(callee, "each")) return;
2771
+ const name = testName(node);
2772
+ if (name === void 0) return;
2773
+ if (seen.has(name)) {
2774
+ context.report({
2775
+ data: { name },
2776
+ messageId: "duplicate",
2777
+ node
2778
+ });
2779
+ return;
2780
+ }
2781
+ seen.add(name);
2782
+ } };
2783
+ },
2784
+ meta: {
2785
+ docs: RULE_DOCS["j4-unique-test-names"],
2786
+ messages: { duplicate: "Duplicate test name \"{{name}}\" in this file — the test name is the sole description of behaviour, so it must be unique (CONVENTIONS J4)." },
2787
+ type: "problem"
2788
+ }
2789
+ };
2790
+ //#endregion
2791
+ //#region src/lint/rules/j5-lowercase-title.ts
2792
+ /** The block/test introducers whose title literal C1' constrains. */
2793
+ const TITLED = /* @__PURE__ */ new Set([
2794
+ "describe",
2795
+ "it",
2796
+ "test"
2797
+ ]);
2798
+ /** An identifier-shaped, all-caps/underscored first word: `VALID_CATEGORIES`,
2799
+ * `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
2800
+ * misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
2801
+ const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
2802
+ //#endregion
2803
+ //#region src/lint/plugin.ts
2804
+ /**
2805
+ * The `@jterrazz/test` oxlint plugin — the tool-facing lint layer that enforces
2806
+ * the statically-checkable CONVENTIONS rules (the `Lint(statique)` catalogue).
2807
+ *
2808
+ * Registered in a consumer's (or this repo's own) `oxlint.config.ts` via
2809
+ * `jsPlugins: ['@jterrazz/test/oxlint']` and referenced as `jterrazz/<rule>` in
2810
+ * the `rules` map, e.g. `'jterrazz/j1-no-only-skip': 'error'` — or enabled
2811
+ * wholesale by spreading {@link recommendedRules}.
2812
+ *
2813
+ * This entry is bundled by tsdown (`dist/oxlint.js`); rules import nothing from
2814
+ * the framework runtime (only pure core helpers: the token list, the case
2815
+ * conversions, the fixture-marker list), so the bundle stays free of the heavy
2816
+ * adapters (msw, pg, testcontainers, …) that the main entry pulls in.
2817
+ */
2818
+ const plugin = {
2819
+ meta: { name: "jterrazz" },
2820
+ rules: {
2821
+ "a1-specification-file": a1SpecificationFile,
2822
+ "a10-duplicate-binding": a10DuplicateBinding,
2823
+ "a2-known-constructors": a2KnownConstructors,
2824
+ "a3-no-destructure-alias": a3NoDestructureAlias,
2825
+ "a4-cleanup-afterall": a4CleanupAfterall,
2826
+ "a5-mode-with-server": a5ModeWithServer,
2827
+ "a6w-redundant-compose-service": a6wRedundantComposeService,
2828
+ "a9w-redundant-root": a9wRedundantRoot,
2829
+ "b2-known-fixture-marker": b2KnownFixtureMarker,
2830
+ "b4-given-then": b4GivenThen,
2831
+ "b5-await-using": b5AwaitUsing,
2832
+ "b6w-redundant-env-url": b6wRedundantEnvUrl,
2833
+ "b8-kebab-trigger": b8KebabTrigger,
2834
+ "b9w-product-command": b9wProductCommand,
2835
+ "c1-domain-structure": c1DomainStructure,
2836
+ "c2-http-only-requests": c2HttpOnlyRequests,
2837
+ "c4-contract-shape": c4ContractShape,
2838
+ "c6-tomatch-extension": c6ToMatchExtension,
2839
+ "c7-seeds-sql-only": c7SeedsSqlOnly,
2840
+ "c8-referenced-fixture-exists": c8ReferencedFixtureExists,
2841
+ "d2-await-io-matcher": d2AwaitIoMatcher,
2842
+ "d2w-await-sync-matcher": d2wAwaitSyncMatcher,
2843
+ "d6w-transform-token-equivalent": d6wTransformTokenEquivalent,
2844
+ "d8w-text-bypass": d8wTextBypass,
2845
+ "d9w-single-use-ref": d9wSingleUseRef,
2846
+ "d12w-response-body-probe": d12wResponseBodyProbe,
2847
+ "d13w-unfrozen-negative-fixture": d13wUnfrozenNegativeFixture,
2848
+ "d15w-status-only-probe": d15wStatusOnlyProbe,
2849
+ "f1-no-subpath-import": f1NoSubpathImport,
2850
+ "f2-no-test-imports-in-prod": f2NoTestImportsInProd,
2851
+ "f3-specs-public-entry": f3SpecsPublicEntry,
2852
+ "f4-no-test-to-test-import": f4NoTestToTestImport,
2853
+ "f5-fixtures-only-from-tests": f5FixturesOnlyFromTests,
2854
+ "i1-layer-boundaries": i1LayerBoundaries,
2855
+ "i2-sibling-test-naming": i2SiblingTestNaming,
2856
+ "i4-no-vi-mock-in-src": i4NoViMockInSrc,
2857
+ "j1-no-only-skip": j1NoOnlySkip,
2858
+ "j2-no-sleep-in-specs": j2NoSleepInSpecs,
2859
+ "j3-no-expectless-test": j3NoExpectlessTest,
2860
+ "j4-unique-test-names": j4UniqueTestNames,
2861
+ "j5-lowercase-title": {
2862
+ create(context) {
2863
+ return { CallExpression(node) {
2864
+ const callee = node.callee;
2865
+ const root = chainRootName(callee);
2866
+ if (root === void 0 || !TITLED.has(root)) return;
2867
+ const title = stringValue(node.arguments?.[0]);
2868
+ if (title === void 0) return;
2869
+ const first = [...title][0];
2870
+ if (first === void 0 || !/\p{L}/u.test(first)) return;
2871
+ const firstWord = title.split(/\s+/u)[0];
2872
+ if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
2873
+ if (first !== first.toLocaleLowerCase()) context.report({
2874
+ data: { runner: root },
2875
+ messageId: "uppercase",
2876
+ node
2877
+ });
2878
+ } };
2879
+ },
2880
+ meta: {
2881
+ docs: RULE_DOCS["j5-lowercase-title"],
2882
+ messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (CONVENTIONS J5)." },
2883
+ type: "problem"
2884
+ }
2885
+ }
2886
+ }
2887
+ };
2888
+ /**
2889
+ * The full catalogue at its intended severities — spread into an oxlint
2890
+ * `rules` map to enable everything in one line:
2891
+ *
2892
+ * rules: { ...recommendedRules }
2893
+ *
2894
+ * Hard conventions are errors; redundancy heuristics (`<id>w-*` rule ids) are
2895
+ * warnings. `b5-await-using` is enabled but inert until re-declared with the
2896
+ * project's docker-aware runner names:
2897
+ *
2898
+ * 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
2899
+ */
2900
+ const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/.test(rule) ? "warn" : "error"]));
2901
+ /**
2902
+ * The composable testing fragment — wire the plugin, enable the whole
2903
+ * catalogue, and ship the one override every strict consumer needs. Designed
2904
+ * to be composed with a base preset (e.g. `@jterrazz/typescript/oxlint`):
2905
+ *
2906
+ * import { compose, node } from '@jterrazz/typescript/oxlint';
2907
+ * import { testing } from '@jterrazz/test/oxlint';
2908
+ * export default compose(node, testing);
2909
+ *
2910
+ * `jsPlugins` registers the tool-facing entry; `rules` is {@link recommendedRules};
2911
+ * `overrides` relaxes `import/exports-last` for `*.specification.ts` — the A4 idiom
2912
+ * (`export const { cli, cleanup } … ; afterAll(cleanup)`) legitimately ends a spec
2913
+ * file on a non-export statement, so the relaxation ships here rather than being
2914
+ * hand-rolled in every consumer.
2915
+ */
2916
+ const testing = {
2917
+ jsPlugins: ["@jterrazz/test/oxlint"],
2918
+ overrides: [{
2919
+ files: ["**/*.specification.ts"],
2920
+ rules: { "import/exports-last": "off" }
2921
+ }],
2922
+ rules: recommendedRules
2923
+ };
2924
+ //#endregion
2925
+ export { plugin as default, recommendedRules, testing };