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