# 10 — Linting > **Maintenance note** — This documentation is living: when implementation or usage reveals a new edge case, document it in the relevant chapter in the same change. `@jterrazz/test` ships the static enforcement channel of its own conventions: an **oxlint JS plugin** (`@jterrazz/test/oxlint`, the one sanctioned subpath) carrying every **statique** convention rule (the constitution is [09 — Conventions](09-conventions.md)), plus a small **conventions checker** binary for the file kinds oxlint never visits (data fixtures). Together they make the conventions programmatic instead of review-borne. ## Enabling the plugin The composable model — `testing` is the fragment every consumer composes into its base preset. With `@jterrazz/typescript` the whole wiring is two lines in `oxlint.config.ts`: ```typescript import { testing } from '@jterrazz/test/oxlint'; import { compose, node } from '@jterrazz/typescript/oxlint'; export default compose(node, testing); ``` `compose(...fragments)` merges deterministically: `jsPlugins`/`plugins`/`ignorePatterns` concatenated + deduped, `rules`/`categories` shallow-merged (last wins), `overrides` concatenated. To deviate on a rule, compose an extra fragment last: ```typescript export default compose(node, testing, { rules: { // Docker-aware runner names in YOUR specs (B5 is inert without them): 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }], }, }); ``` - `testing` = `{ jsPlugins: ['@jterrazz/test/oxlint'], rules: recommendedRules, overrides }`. Its `overrides` relaxes `import/exports-last` for `**/*.specification.ts` — the A4 idiom (`export const { cli, cleanup } … ; afterAll(cleanup)`) legitimately ends a spec file on a non-export statement, so the relaxation ships here instead of being hand-rolled in every strict consumer. - Without `@jterrazz/typescript`, spread the fragment into your own config (`defineConfig({ ...testing })`) — it is a plain object. - **Deviation fragments are plain objects — no extra imports.** The trailing `compose(node, testing, { … })` argument is a bare object literal; `compose()` accepts it directly. You do **not** need to import `oxlint`'s `defineConfig` (nor anything from `oxfmt`) to shape a config — the base preset already carries the wiring, and `oxfmt.config.ts` is a one-line re-export (`export { oxfmt as default } from '@jterrazz/typescript'`). This matters most under a **local `file:` link** (developing the framework and a consumer side by side): pulling a linked package's config internals can resolve to an unbuilt path, whereas plain objects composed by the base preset never do. - `recommendedRules` (also exported standalone) enables the whole catalogue in one spread: hard conventions at `error`, redundancy heuristics (ids ending in `w-…`) at `warn`. - Wiring is **explicit** — the preset never auto-detects `@jterrazz/test` from your dependencies. If you don't compose `testing`, no `jterrazz/*` rule runs. (Only the `typescript check` orchestration still detects deps + `specs/` to decide whether to RUN the conventions-checker step — a runner decision, not config identity.) - Rules are individually addressable as `jterrazz/` if you want to deviate — prefer scoped `overrides` with a comment over global downgrades. - `@jterrazz/test/oxlint` is **tool-facing only**: referenced from `oxlint.config.ts` (or a shared oxlint preset), never imported by test or production code. It is the one specifier exempt from rules F1/F2 — a zero-runtime lint entry, so a preset that wires the plugin may import it from anywhere. ### Standalone — without `@jterrazz/typescript` A repo that is not primarily TypeScript (a Go service with a thin TS test suite, say) can adopt just the conventions without inheriting any formatting or base-preset opinions. The `testing` fragment is a **complete oxlint config on its own** — `jsPlugins` + `rules` + the A4 `overrides`, no `extends` — so it can be the whole config: ```typescript // oxlint.config.ts — conventions only, no @jterrazz/typescript import { testing } from '@jterrazz/test/oxlint'; export default testing; ``` This registers the plugin and enables the whole `jterrazz/*` catalogue at its intended severities; oxlint's own default rules still run, but no `@jterrazz/typescript` formatting/style preset is imposed. To deviate, spread and override: `export default { ...testing, rules: { ...testing.rules, 'jterrazz/j5-lowercase-title': 'off' } }`. The static plugin is only one of the four channels. The **conventions checker** (token/HTTP grammar + cross-file passes — D4, C8/C9, A7, B5) ships as a standalone binary; run it as the minimal second channel with no `@jterrazz/typescript` orchestration: ```bash node node_modules/@jterrazz/test/dist/checker.js specs ``` It walks `specs/` (or the path you pass), reports token/reference/cross-file violations, and exits non-zero on an error — wire it into CI next to `oxlint`. ## The rule catalogue The catalogue below is **generated** from `src/lint/manifest.ts` (each rule carries its normative text as `meta.docs`) — do not edit it by hand; run `npm run docs`. It is the **full four-channel catalogue** (statique + checker + runtime + process), grouped by convention family. The same set, trimmed to what an agent needs to apply and cite a rule, is regenerated alongside it into [`skills/jterrazz-test/references/rules.md`](../skills/jterrazz-test/references/rules.md). Every rule the framework enforces, across its four channels — **statique** (43 oxlint rules), **checker** (7 bundled passes), **runtime** (7 execution-time refusals), **process** (4 review-borne rules) — sourced from one manifest so the code and the catalogue can never drift. ## A — Création des runners | Code | Implementation | Channel | Convention | Rationale | | ---- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | A1 | `a1-specification-file` | statique | Un runner ne se crée que dans un fichier `*.specification.ts` sous `specs/` : appeler `specification.*` ailleurs est une erreur. | Ancrer les runners à un nom de fichier reconnaissable rend le point d’entrée détectable et garde les tests déclaratifs. | | A2 | `a2-known-constructors` | statique | Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur. | Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable. | | A3 | `a3-no-destructure-alias` | statique | Le retour se destructure avec le nom canonique du constructeur, sans alias (`{ api, cleanup, docker }`) ; renommer (`{ api: monApi }`) est une erreur. | Un nom d’instance unique par facette rend chaque spec lisible sans contexte local. | | A4 | `a4-cleanup-afterall` | statique | Le fichier de specification passe `cleanup` à `afterAll` ; un `cleanup` destructuré mais jamais transmis est une erreur. | Garantir le teardown évite les conteneurs et connexions qui fuient entre fichiers. | | A5 | `a5-mode-with-server` | statique | `mode` n’existe que sur `specification.api()` et n’est jamais hardcodé quand `server` est défini — le switch vit dans `vitest.config.ts`. | Sortir le mode du fichier de spec permet d’exécuter le même test en node et en compose sans le modifier. | | A6 | `a6w-redundant-compose-service` | statique | `composeService:` dérivable de la clé (égal à la clé exacte ou à sa conversion kebab-case) est redondant → warning. | Signaler la redondance garde les records `services` minimaux et évite le bruit qui masque les vrais overrides. | | A9 | `a9w-redundant-root` | statique | `root` pointant vers le dossier que la remontée automatique aurait trouvé est redondant → warning. | La détection par convention doit rester le défaut ; un `root` explicite ne se justifie que là où elle échoue. | | A10 | `a10-duplicate-binding` | statique | 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`). | Une seconde liaison masquerait silencieusement la première dans ce qui est une map. | | A7 | `a7-database-property` | checker | 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. | Le nombre de bases fixe l’API d’appel ; l’analyse cross-fichier attrape l’omission avant le runtime. | | A6 | `a6-ambiguous-binding` | runtime | Un binding ambigu (le compose déclare à la fois la clé exacte ET sa forme kebab-case) est refusé à l’exécution. | Le framework ne devine pas — il exige un renommage ou un `composeService` explicite. | | A7 | `a7-database-runtime` | runtime | Le framework lève à l’exécution si `database:` est absent avec ≥ 2 bases, ou présent avec une seule (double le canal checker). | Le runtime garde la garantie même là où l’analyse statique s’abstient. | ## B — Chaînes de spec | Code | Implementation | Channel | Convention | Rationale | | ---- | --------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | B2 | `b2-known-fixture-marker` | statique | Un marqueur `$…` inconnu dans un littéral passé à `.fixture()` est une erreur (seul `$FIXTURES` est connu). | Attraper un marqueur fautif statiquement évite un chemin de fixture résolu au hasard à l’exécution. | | B4 | `b4-given-then` | statique | Chaque test contient `// Given -` puis `// Then -` (les deux, dans cet ordre) ; Given déclaré après Then est une erreur. | La narration Given/Then rend l’intention du test lisible sans lire les assertions. | | B5 | `b5-await-using` | statique | 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). | `await using` garantit le nettoyage des conteneurs créés par le binaire testé, même en cas d’échec. | | B6 | `b6w-redundant-env-url` | statique | `.env({ _URL: ….connectionString })` répète l’injection automatique du framework → warning. | L’injection couvre déjà les URLs de services ; les réécrire à la main invite au décalage. | | B8 | `b8-kebab-trigger` | statique | `.trigger(name)` prend un identifiant kebab-case stable ; un `name` non kebab-case est une erreur. | Le nom de job est un contrat entre l’app et les tests — un identifiant stable interdit les traductions divergentes. | | B9 | `b9w-product-command` | statique | 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). | Une spec doit exercer la vraie commande du produit ; les assertions par outil passent par `result.grep`. | | B5 | `b5-await-using-inference` | checker | 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é. | Inférer les runners supprime la liste à maintenir à la main de la règle oxlint. | | B2 | `b2-unknown-marker-runtime` | runtime | Le framework refuse à l’exécution un marqueur `$…` inconnu, ou un `$FIXTURES` sans dossier `specs` ancêtre (message guidant). | Un message runtime guide l’auteur quand la faute échappe au canal statique (argument non littéral). | | B6 | `b6-url-injection` | runtime | En mode `cli` avec `services`, le framework injecte `_URL` (CONSTANT_CASE camel-aware) plus les alias non ambigus `DATABASE_URL`/`REDIS_URL` ; `.env()` override (`null` retire). | Injecter les URLs évite le câblage manuel répété et son décalage (voir b6w). | ## C — Fichiers & dossiers | Code | Implementation | Channel | Convention | Rationale | | ---- | ------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | C1 | `c1-domain-structure` | statique | Un `*.test.ts` vit à la profondeur facet/domain, un `*.specification.ts` au root de la facette ; toute autre profondeur est une erreur. | Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement. | | C2 | `c2-http-only-requests` | statique | `requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur. | Une entrée de requête est un `.http` complet — homogénéiser le dossier interdit les formats ad hoc. | | C4 | `c4-contract-shape` | statique | Un fichier de `contracts/` respecte `..ts` (provider ∈ openai\|anthropic\|http), à plat, `export default defineContract(...)`, imports depuis le point d’entrée public. | Une forme figée rend les contrats découvrables et typables sans convention locale. | | C6 | `c6-tomatch-extension` | statique | L’argument de `toMatch` porte son extension (`'help.txt'`), sauf pour les snapshots d’arborescence (dossiers) ; un sujet fichier sans extension est une erreur. | L’extension fait partie du nom du fichier attendu — l’omettre casse la résolution `expected/`. | | C7 | `c7-seeds-sql-only` | statique | `seeds/` ne contient que des `*.sql` ; tout autre fichier est une erreur. | `.seed()` porte l’état des bases uniquement — pas de seed-handler ni de dispatch par préfixe. | | C8 | `c8-referenced-fixture-exists` | statique | Un littéral de `.request`/`.seed`/`.fixture`/`toMatch` doit exister sur disque sous sa racine conventionnelle ; un chemin absent est une erreur. | Attraper un typo statiquement évite un échec qui ne surviendrait qu’à l’exécution. | | C9 | `c9-dead-fixtures` | checker | 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). | Le miroir de C8 — une fixture que rien ne référence est du poids mort qui trompe le lecteur. | | C1 | `c1-asset-grouping` | process | 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 `.test.ts` frères. La règle statique ne vérifie que la profondeur. | Ce sont les assets qui tranchent le regroupement — un critère qu’aucun canal ne peut décider seul. | ## D — Assertions | Code | Implementation | Channel | Convention | Rationale | | ---- | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | D2 | `d2-await-io-matcher` | statique | Un matcher IO (`toMatchRows`/`toBeEmpty`/`toBeRunning`) doit être awaité ou retourné ; sinon l’assertion ne s’exécute jamais → erreur. | Une assertion IO non attendue passe silencieusement — le pire mode d’échec d’un test. | | D2 | `d2w-await-sync-matcher` | statique | `await` sur un matcher toujours synchrone (`toBe`/`toEqual`/`toContain`/`toHaveLength`) est redondant → warning. | Un await inutile masque le signal qui distingue les vrais matchers IO. | | D6 | `d6w-transform-token-equivalent` | statique | Un `transform` qui ne fait que réécrire vers des équivalents de tokens standard duplique la grammaire → warning. | `transform` est une échappatoire pour le bruit non couvert par les tokens — pas un doublon des tokens. | | D8 | `d8w-text-bypass` | statique | `expect(x.text).toContain/toMatch` court-circuite le sujet accesseur typé → warning. | Asserter sur `.text` jette la grammaire de tokens et la résolution `toMatch('fichier')` du sujet. | | D9 | `d9w-single-use-ref` | statique | 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. | Une ref ne se justifie que si elle asserte l’égalité sur au moins deux occurrences. | | D12 | `d12w-response-body-probe` | statique | 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). | 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). | | D13 | `d13w-unfrozen-negative-fixture` | statique | 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). | 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. | | D15 | `d15w-status-only-probe` | statique | 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). | 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). | | D4 | `d4-malformed-ref` | checker | Une ref malformée d’un kind connu (`{{iso8601#}}`, `{{uuid #id}}`) dans un fichier texte sous `expected/` est une erreur. | Une capture malformée échouerait silencieusement — la signaler la rend visible tôt. | | D4 | `d4-unknown-token` | checker | Tout `{{token}}` dans une fixture `expected/` appartient au vocabulaire figé ; un token inconnu est une erreur. | Une grammaire fermée partagée avec le matcher runtime empêche la dérive entre canaux. | | D4b | `d4b-http-first-line` | checker | La première ligne d’un `.http` de profondeur 1 suit sa grammaire : requête (`MÉTHODE /path`) sous `requests/`, statut (`HTTP/1.1 `) sous `expected/`. | La ligne d’ouverture distingue une requête d’une réponse — la contraindre attrape les fichiers mal placés. | | D10 | `d10w-tokens-in-requests` | checker | Un token connu dans un fichier sous `requests/` → warning : les requêtes sont des entrées, jamais matchées. | Un token dans une entrée ne sera ni validé ni substitué — c’est presque toujours une erreur. | | D7 | `d7-strict-intercepts` | runtime | 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. | Un réseau gardé rend les interactions externes exhaustives et intentionnelles. | | D14 | `d14-tomatch-fixture-name` | runtime | `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/)`. | 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. | | D11 | `d11-golden-file` | process | 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. | Jugement de revue — le canal statique ne distingue pas un grep légitime d’un grep paresseux. | | D13 | `d13-frozen-negative-fixture` | process | 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. | 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. | ## F — Imports & protection de la prod | Code | Implementation | Channel | Convention | Rationale | | ---- | ----------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | F1 | `f1-no-subpath-import` | statique | Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/` est une erreur, sauf le subpath tool-facing `@jterrazz/test/oxlint` (exempté partout). | Un point d’entrée unique garde l’API publique explicite et les subpaths internes invisibles. | | F2 | `f2-no-test-imports-in-prod` | statique | Un fichier de prod n’importe jamais `vitest`, `@jterrazz/test`, un `*.test.*`, un `*.fixtures.*` ni `mockOf`/`mockOfDate` (exception : `@jterrazz/test/oxlint`). | Empêcher les artefacts de test de fuir en prod protège le bundle applicatif du consommateur. | | F3 | `f3-specs-public-entry` | statique | 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/` 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/**`. | 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. | | F4 | `f4-no-test-to-test-import` | statique | Un `*.test.ts` n’importe jamais un autre `*.test.ts`. | Le partage entre tests passe par des `*.fixtures.ts`, pas par des imports test-à-test qui couplent les fichiers. | | F5 | `f5-fixtures-only-from-tests` | statique | Un `*.fixtures.ts` n’est importable que depuis des `*.test.ts`. | Cantonner les fixtures aux tests empêche la donnée de test de fuir dans le code de prod. | ## I — Architecture du code source | Code | Implementation | Channel | Convention | Rationale | | ---- | ------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | I1 | `i1-layer-boundaries` | statique | 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. | Des frontières strictes gardent `core/` pur et chaque intégration confinée à sa dépendance. | | I2 | `i2-sibling-test-naming` | statique | Le test de `.ts` est `.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur. | Des tests voisins (parité avec le `foo_test.go` de Go) gardent test et code ensemble et découvrables. | | I4 | `i4-no-vi-mock-in-src` | statique | Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset non-`.ts` depuis un `.test.ts` sont interdits. | Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec. | | I3 | `i3-intercept-compose` | runtime | `.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process). | Un child process ou un conteneur n’est pas interceptable par MSW — l’erreur oriente vers un projet node-only. | ## J — Hygiène | Code | Implementation | Channel | Convention | Rationale | | ---- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | J1 | `j1-no-only-skip` | statique | Aucun `.only` / `.skip` committé (`describe.only`, `test.only`, `test.skip`). | Un `.only`/`.skip` oublié désactive silencieusement une partie de la suite. | | J2 | `j2-no-sleep-in-specs` | statique | Aucun sleep arbitraire (`setTimeout`/`setInterval`/`Atomics.wait`) sous `specs/**` — la synchronisation passe par `waitFor`. | Un sleep fixe rend les tests lents et instables ; attendre une condition est déterministe. | | J3 | `j3-no-expectless-test` | statique | Un `test(...)` avec callback contient au moins un `expect(…)` ; `test.todo` (sans callback) est ignoré. | Un test sans assertion est mort ou muet — il passe toujours sans rien vérifier. | | J4 | `j4-unique-test-names` | statique | Deux tests d’un même fichier ne partagent pas un nom littéral (`.each` ignoré). | Le nom du test est son unique description — deux noms identiques rendent un échec ambigu. | | J5 | `j5-lowercase-title` | statique | 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. | Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait. | ## K — Rétro-propagation | Code | Implementation | Channel | Convention | Rationale | | ---- | ---------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | K1 | `k1-retro-propagation` | process | 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. | C’est la règle qui fait croître les trois autres canaux au lieu de les laisser pourrir. | ## W — Specs website | Code | Implementation | Channel | Convention | Rationale | | ---- | -------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | W1 | `w1-scenario-pure` | statique | Un scénario de visite est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné. | Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables. | | W2 | `w2w-user-facing-elements` | statique | Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement. | Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie. | Conventions with no oxlint rule are covered by another channel — the catalogue above names each one: the **checker step** (D4 / D4b / D10, below), the **cross-file checker passes** (C9, B5-by-inference, A7), and the **runtime / process** channels (D7, D11, K1). The **type channel** (C4 trigger members, D1 read-only accessors) carries no catalogue row — the type system enforces it without a rule. Rules marked _fs-checked_ probe the filesystem, anchored on files oxlint is already visiting (a feature's sibling `requests/`, `seeds/`, `expected/`, a module test's neighbour), with per-run caching — they never scan the tree blindly. ## The conventions checker (D4) Oxlint only parses JS/TS. The D4 token grammar also constrains **data fixtures** under `requests/**` and `expected/**`, so that channel ships as a separate step, bundled as `dist/checker.js` and exposed as the `jterrazz-test-check` bin: ```bash jterrazz-test-check specs # via the package bin node node_modules/@jterrazz/test/dist/checker.js specs # or the bundle directly ``` What it checks: - **D4** — every `{{token}}` in an `expected/` fixture belongs to the frozen vocabulary. It scans **all text files** under `expected/` (not just `.http`/`.json`/`.txt` — it decodes UTF-8 and skips binary), and flags **malformed captures** of a known kind (`{{iso8601#}}`, `{{uuid #id}}`). - **D4b** — depth-1 `requests/*.http` must start with a request line (`METHOD /path`); depth-1 `expected/*.http` with a status line (`HTTP/1.1 `). - **D10** (warning) — a `{{token}}` inside a `requests/` file: requests are inputs, never matched, so a token there is almost always a mistake. Advisory only — it does not fail the run. It shares `TOKEN_KINDS` with the runtime matcher (the two cannot drift), skips `fixtures/` trees (verbatim `.fixture()` cwd material — file state, not assertions), and ignores non-identifier braces (`{{.Server.Version}}`-style template output). Exit 1 on any error, 0 when only warnings remain. In this repo it is chained into `npm run lint`; `typescript check` is expected to invoke the same entry as its « conventions » step once it grows one. ## Cross-file checker passes The same binary also runs the analyses oxlint structurally cannot — those that need to read **two files at once** (a `*.specification.ts` record and the `specs/` tests importing it) or a **whole feature tree** at once. They live in the checker channel and follow its doctrine: **precision over recall** — a heuristic that cannot decide stays silent (under-reports) rather than block a legitimate shape. All are string/scan based (no TS parser in the light lint bundle). A `$FIXTURES` pool (`specs/fixtures`) is pruned from the per-feature walks (verbatim fixture material, including the on-purpose violation trees) and inspected only at its top level. - **C9 — dead fixtures & orphan dirs.** The mirror of C8: a fixture file no test literal references is dead weight, and a domain directory with conventional subdirs but no `*.test.ts` is an orphan. A referenced `expected//` (or `fixtures//`) tree counts entirely as used; a `$FIXTURES` pool entry unreferenced across all of `specs/**` is dead. A non-literal fixture argument (a template with an expression, a variable) makes the reference set incomplete, so that feature is downgraded to **warning**. - **B5 — await-using (inference).** The **primary** B5 channel. It reads the docker-aware runners straight from the `docker:` option of the imported spec file, then flags every `….exec()` result bound without `await using`. No hand-maintained runner list — unlike the oxlint rule, which stays for explicitly-configured runners. - **A7 — database property.** A spec's `services:` record fixes how many SQL databases exist; the importing tests must (≥ 2) or must not (== 1) pass `{ database }` to every `.seed()` / `.table()`. Non-literal records (a handle variable in `services`) and files importing more than one enforced runner are skipped (ambiguous). Doubled by the runtime channel (the framework throws). ## The generated catalogue (docs-as-code) The rule catalogue is **generated from the code**, not hand-maintained. Each rule carries its normative sentence as `meta.docs` (sourced from `src/lint/manifest.ts`), and the manifest assembles all four channels — statique (the oxlint rules), checker (the passes above), runtime (execution-time refusals), and process (review-borne rules like D11/K1). A generator (`src/lint/catalog.ts`, bundled as `dist/catalog.js`, chained into `npm run docs` **before** `typescript docs`) renders two committed projections deterministically: - the **full catalogue above**, spliced between GENERATED markers in this file; - **[`skills/jterrazz-test/references/rules.md`](../skills/jterrazz-test/references/rules.md)** — the same set, trimmed for agents. The narrative constitution lives in [09 — Conventions](09-conventions.md): principles, the enforcement channels, non-mechanizable criteria, and design rationales — no per-rule normative lines, so there is no duplication. A meta-test (`src/lint/plugin.test.ts`) guards the contract: **freshness** (re-running the generator reproduces both committed projections byte-for-byte) and **completeness** (every shipped rule carries `meta.docs`; every manifest entry maps to a plugin rule, a checker pass, or a documented runtime/process rule), plus the standing E2E inventory (every rule has a `specs/lint/**` spec + fixture pair). ## Doc typechecking (meta-test) A vitest meta-test (`src/lint/docs-typecheck.ts`) extracts the ` ```typescript ` blocks from `docs/*.md` that import the framework, rewrites `@jterrazz/test` to the repo source, and runs the real `tsc --noEmit` over them — so a sample drifting from the API (a removed `.spawn()`, a renamed accessor) fails CI as a type error. Precision over coverage: only self-contained framework blocks are checked; prose fragments leaning on ambient vitest globals, and blocks importing consumer app code (`createApp`, `../../src/app.js`), are skipped. ## Suppressions Standard oxlint disable comments work, and every suppression must carry its reason: ```typescript // oxlint-disable-next-line jterrazz/a1-specification-file -- negative constructor spec: the runner must fail to start ``` The framework's own repo keeps its sanctioned exemptions visible: config `overrides` for the constructor unit tests (`src/**/*.test.ts` may call `specification.*` — testing the constructors is their purpose) and for `src/vitest/**` (the runner-coupling layer imports `vitest` by design). The **cross-file checker passes** carry their own suppression comment (oxlint disable comments don't reach them — they are a separate binary): ```typescript // checker-disable-next-line a7 -- negative spec: the omitted database is the behaviour under test (runtime channel) expect(() => api.seed('one-user.sql')).toThrow(/* … */); ``` `// checker-disable-next-line [,] -- reason` suppresses the named passes (`a7`, `b5`, `c9`, or `*`) on the next non-blank line; `// checker-disable-line ` suppresses its own line. The text after `--` is stripped before the ids are matched, so it never affects suppression — it is required by house convention (every suppression explains itself), not by the checker. The idiomatic use is a negative spec that deliberately violates a convention to exercise the runtime channel. ## Build-before-lint The plugin is loaded from the **built bundle**. In this repo `jsPlugins: ['./dist/oxlint.js']` means `npm run build` must precede `npm run lint` (Node's TS type-stripping cannot resolve `.js` specifiers to `.ts` sources). Consumers load `@jterrazz/test/oxlint` from `node_modules` — no ordering constraint. ## Stability Oxlint's JS-plugin API is **alpha** (oxlint 1.74): the plugin declares the small structural slice it uses locally (`src/lint/types.ts`) instead of importing oxlint types, so upstream churn surfaces as a compile break in one file, not across the rules. Expect to re-validate the plugin on oxlint majors; the E2E specs (`specs/lint/**`, one violation/compliant fixture pair per rule) run the real binary and are the canary. ## Pitfalls - **Enabling `b5-await-using` without options.** The oxlint rule cannot know which of your runners are docker-aware; it stays inert until you list their identifier names in `runners`. The **checker's B5 pass is the primary channel** now — it infers those runners from the `docker:` option automatically, so B5 is enforced even when the oxlint rule is left unconfigured. Keep the rule for runners the inference can't reach (aliased re-exports). - **Suppressing instead of restructuring.** Most hits have a conventional home: an inline runner belongs in a `*.specification.ts`, shared test data in a `*.fixtures.ts`, a file-reading module test in `specs/`. Suppress only what is structurally impossible (negative constructor specs). - **Forgetting the checker step.** Oxlint passing does not validate `.http`/`.json`/`.txt` fixtures — wire `dist/checker.js` into your lint chain. - **Linting generated fixture pools.** Violation fixtures (like this repo's `specs/fixtures/lint-violations/**`) must be excluded via `ignorePatterns` — they violate on purpose. - **Stale plugin in the IDE after a rebuild (LSP staleness, not a real failure).** The oxlint language server loads `dist/oxlint.js` **into memory once** and holds it for the life of the server process. After you rebuild the plugin (`npm run build`), the editor keeps linting against the _old_ bundle: rules you just changed still fire (or fail to fire), and the in-editor squiggles disagree with a fresh CLI run. The command line is authoritative — if `npm run lint` passes, the code is clean. To resync the IDE, **restart the editor's oxlint server** (reload the window, or restart the oxlint/oxc language-server process); a plain file re-save is not enough. Suspect this whenever the IDE reports a plugin diagnostic that the CLI does not. - **CJS config silently drops the plugin (loudest pitfall).** The plugin is ESM-only. If your `oxlint.config` is CommonJS (or resolves the plugin through a CJS path), oxlint **prints a load warning and then exits 0** — the plugin is simply not registered, so **none of the `jterrazz/*` rules run** and your lint stays green while enforcing nothing. There is no error to fail CI. Use an ESM config (`oxlint.config.ts`/`.mjs`), and after wiring, sanity-check that a known violation is actually reported (e.g. a stray `test.only`). - **What the `./oxlint` `require` condition does — and does not — solve.** The `@jterrazz/test/oxlint` export ships both an `import` (`dist/oxlint.js`) and a `require` (`dist/oxlint.cjs`) condition. The `require` condition exists so a **shared preset or config module can pull the config fragment synchronously** — `const { testing } = require('@jterrazz/test/oxlint')` — instead of forcing top-level `await import(...)` (and thus a top-level-await, ESM-only preset). This is purely about how **your own preset code** reads `testing`/`recommendedRules`. It does **not** change the pitfall above: oxlint still loads the _plugin itself_ (the `jsPlugins: ['@jterrazz/test/oxlint']` entry) as ESM, and oxlint still requires _your_ `oxlint.config` to be ESM to register any JS plugin. The `require` condition makes sync wiring possible in a preset; it does not make a CommonJS oxlint config work. ## Related [09 — Conventions](09-conventions.md) · [06 — Tokens](06-tokens.md) · [01 — Getting started](01-getting-started.md)