Pitch
Dans un projet TypeScript (SSG custom), on a construit un système qui prouve à chaque commit que chaque exigence métier est implémentée, testée, et couverte — sans aucune déclaration manuelle. Le tout repose sur 3 piliers : un DSL de requirements en TypeScript, des décorateurs de tests qui lient chaque assertion à une exigence, et un scanner AST qui infère automatiquement le graphe de traçabilité. Le coverage vitest v8 ferme la boucle en vérifiant que le code traversé est bien celui revendiqué.
Le problème qu'on résout
Le cycle classique requirements → code → tests a un trou : qui vérifie que les tests testent bien ce qu'ils prétendent tester ? Et qui vérifie que chaque requirement a au moins un test ?
Les approches habituelles :
- Matrices de traçabilité manuelles (Excel, Jira links) → dérivent, personne ne les maintient
- Tags dans les tests (
@requirement REQ-123) → déclaratifs, pas vérifiés, pas typés - Coverage seul → dit que le code est exécuté, pas qu'il vérifie le bon requirement
On veut un système où :
- Chaque requirement est typé (compilateur refuse les typos)
- Chaque test déclare quel requirement il vérifie (via décorateur typé)
- Un scanner prouve que le test appelle effectivement du code lié au requirement
- Le coverage confirme que ce code est bien exécuté
- Un quality gate bloque si une exigence n'est pas couverte
Couche 1 — Requirements DSL (TypeScript natif)
// requirements/features/navigation.ts
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.High;
/** Clicking a TOC item loads the page with a fade transition. */
abstract tocClickLoadsPage(): ACResult;
/** Browser back button restores previous page. */
abstract backButtonRestores(): ACResult;
// ... 6 autres ACs
}// requirements/features/navigation.ts
export abstract class NavigationFeature extends Feature {
readonly id = 'NAV';
readonly title = 'SPA Navigation + Deep Links';
readonly priority = Priority.High;
/** Clicking a TOC item loads the page with a fade transition. */
abstract tocClickLoadsPage(): ACResult;
/** Browser back button restores previous page. */
abstract backButtonRestores(): ACResult;
// ... 6 autres ACs
}Chaque Feature est une classe abstraite. Chaque AC (Acceptance Criterion) est une méthode abstraite. Le compilateur TypeScript garantit :
- L'AC existe (sinon
@Verifies<F>('typo')ne compile pas —keyof F) - La feature est exportée et enregistrée
Pas de YAML, pas de JSON, pas de Jira. Le requirement EST du TypeScript.
Couche 2 — Décorateurs de test
// test/unit/spa-nav-state.test.ts
@FeatureTest(NavigationFeature)
class SpaNavTests {
@Verifies<NavigationFeature>('tocClickLoadsPage')
'navigating to a page transitions to loading state'() {
const machine = createSpaNavMachine(fakeDeps);
machine.navigate('/blog/foo.html');
expect(machine.getState().phase).toBe('loading');
}
}// test/unit/spa-nav-state.test.ts
@FeatureTest(NavigationFeature)
class SpaNavTests {
@Verifies<NavigationFeature>('tocClickLoadsPage')
'navigating to a page transitions to loading state'() {
const machine = createSpaNavMachine(fakeDeps);
machine.navigate('/blog/foo.html');
expect(machine.getState().phase).toBe('loading');
}
}@FeatureTest(F) — lie la classe de test à une Feature. Auto-enregistre les méthodes avec vitest (pas de describe/it manuels).
@Verifies<F>('acName') — lie une méthode de test à un AC précis. Le type keyof F garantit au compile-time que l'AC existe.
@Exclude() — marque un helper interne (setup, teardown) pour que le scanner l'ignore.
Le runtime registry collecte tous les { feature, ac, testClass, testMethod } à l'exécution. Le scanner AST les lit statiquement.
Couche 3 — Scanner AST (la pièce maîtresse)
scripts/lib/test-bindings-scanner.ts — un walker TypeScript qui :
- Parse chaque
*.test.tset*.spec.ts - Trouve les classes
@FeatureTest(F)et les méthodes@Verifies<F>('ac') - Walk le body de chaque méthode
@Verifieset collecte tous les identifiants référencés en position valeur (pas les types, pas les noms de déclaration, pas les.namede PropertyAccess) - Résout chaque identifiant via la import map du fichier → chemin relatif repo
- Suit transitivement les helpers locaux (
function setup() { ... }) et les helpers de classe (this.makeFixture()) via un visited set (cycle-safe) - Filtre aux fichiers dans
src/**ouscripts/**(le code testé, pas les deps externes) - Agrège en un
BindingsManifest:{ featureId → { acName → [{ file, symbol }] } }
Résultat : pour chaque AC de chaque Feature, on sait quels symboles src le test appelle réellement. Pas ce qu'un humain a déclaré — ce que l'AST prouve.
Le walker transitif
Le cas clé qui rendait les scanners naïfs inutiles :
// Le test appelle setup(), pas createMachine() directement
function setup() {
return createSpaNavMachine(fakeDeps); // ← symbole importé
}
@Verifies<NavigationFeature>('tocClickLoadsPage')
'navigates correctly'() {
const m = setup(); // ← appel local, pas un import
m.navigate('/page');
expect(m.getState().phase).toBe('loading');
}// Le test appelle setup(), pas createMachine() directement
function setup() {
return createSpaNavMachine(fakeDeps); // ← symbole importé
}
@Verifies<NavigationFeature>('tocClickLoadsPage')
'navigates correctly'() {
const m = setup(); // ← appel local, pas un import
m.navigate('/page');
expect(m.getState().phase).toBe('loading');
}Un scanner CallExpression-only voit setup() (local, pas bindable) et expect() (vitest, filtré). Il rate createSpaNavMachine.
Notre scanner :
- Collecte les helpers top-level (
function setup() { ... }) dans une mapname → body - Collecte les helpers de classe (
this.makeFixture()) dans une mapname → body - Quand un identifiant référencé match un helper, walk le body du helper récursivement
- Visited set empêche les boucles infinies
Résultat : 485 méthodes "vides" (premier scanner naïf) → 11 (scanner transitif). Les 11 restantes sont des tests d'artefacts (CSS regex, HTML inspection) qui ne traversent aucun symbole TypeScript — légitimes.
Couche 4 — Compliance Report
scripts/compliance-report.ts consomme le manifeste inféré et le coverage vitest v8 pour produire un rapport :
✓ NAV SPA Navigation + Deep Links 8/8 ACs (100%) src 100% (1 file) impl 4/8 TU + 4/8 E2E
✓ THEME Theme Switching 5/5 ACs (100%) src 100% (1 file) impl 5/5 TU
✓ VIS Visual Regression 4/4 ACs (100%) src 100% (1 file) impl 1/4 TU + 3/4 E2E
Features: 97 active
Acceptance criteria: 829/829 ACs covered (100%)
Total tests linked to ACs: 2812 (2757 unit + 55 e2e)
Runtime coverage warnings: 0
Unbound features: 0
Orphan source files: 0
Quality gate: PASS✓ NAV SPA Navigation + Deep Links 8/8 ACs (100%) src 100% (1 file) impl 4/8 TU + 4/8 E2E
✓ THEME Theme Switching 5/5 ACs (100%) src 100% (1 file) impl 5/5 TU
✓ VIS Visual Regression 4/4 ACs (100%) src 100% (1 file) impl 1/4 TU + 3/4 E2E
Features: 97 active
Acceptance criteria: 829/829 ACs covered (100%)
Total tests linked to ACs: 2812 (2757 unit + 55 e2e)
Runtime coverage warnings: 0
Unbound features: 0
Orphan source files: 0
Quality gate: PASSTrois colonnes :
- ACs — pourcentage d'ACs ayant au moins un
@Verifiestest (vert = 100%) - src — min du coverage v8 lignes% sur les fichiers revendiqués par la feature (vert = 100%)
- impl — split TU / E2E : combien d'ACs sont liés à des symboles src (TU, vert) vs seulement vérifiés par Playwright (E2E, jaune)
Couche 5 — Quality Gate
Le quality gate FAIL si :
- Un AC critical n'est pas couvert par au moins un
@Verifiestest - Le coverage global tombe sous 80%
Le threshold vitest enforce séparément :
src/lib/**/*.ts: 98% statements, 95% branches, 98% functions, 99% linesscripts/lib/compliance-core.ts: 99% lines
Le cycle fermé
Feature (TypeScript)
│
│ abstract methods = ACs
▼
@Verifies<Feature>('ac') sur méthode de test
│
│ le test appelle des symboles src
▼
Scanner AST walk le body + helpers transitifs
│
│ résout les imports → fichiers src
▼
BindingsManifest: feature → ac → [file, symbol]
│
│ croisé avec coverage vitest v8
▼
Compliance Report: chaque AC est
(a) vérifié par un test ✓
(b) le test appelle du code src ✓
(c) ce code est couvert par v8 ✓
│
▼
Quality Gate: PASS / FAILFeature (TypeScript)
│
│ abstract methods = ACs
▼
@Verifies<Feature>('ac') sur méthode de test
│
│ le test appelle des symboles src
▼
Scanner AST walk le body + helpers transitifs
│
│ résout les imports → fichiers src
▼
BindingsManifest: feature → ac → [file, symbol]
│
│ croisé avec coverage vitest v8
▼
Compliance Report: chaque AC est
(a) vérifié par un test ✓
(b) le test appelle du code src ✓
(c) ce code est couvert par v8 ✓
│
▼
Quality Gate: PASS / FAILAucun maillon n'est déclaratif-seulement. Chaque lien est vérifié :
- Feature → AC : TypeScript type system (
keyof F) - AC → Test :
@Verifiesdecorator (scanné statiquement) - Test → Code : AST call graph (résolution d'imports transitive)
- Code → Exécution : vitest v8 line coverage
Hexagonal + SOLID : pourquoi c'est nécessaire
Le coverage ne vaut rien si le code testé a des dépendances sur le monde réel (fs, DOM, network). Un test qui importe createMachine() mais que createMachine() appelle fs.readFileSync() en interne → le test n'est plus unitaire.
Solution : architecture hexagonale rigoureuse.
// src/lib/external.ts — tous les ports
export interface FileSystem {
readFile(path: string, encoding: 'utf8'): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
exists(path: string): Promise<boolean>;
// ...
}
export interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
error(msg: string, ...args: unknown[]): void;
}
// scripts/lib/validate-md-links-core.ts — core pur
export function createLinkValidator(deps: { fs: FileSystem; logger: Logger }) {
// ... zéro import de 'fs' ou 'console'
}
// scripts/validate-md-links.ts — thin shell (~30 lignes)
import { promises as fsp } from 'fs';
const realFs: FileSystem = {
readFile: (p, enc) => fsp.readFile(p, enc),
// ...
};
createLinkValidator({ fs: realFs, logger: console }).run();// src/lib/external.ts — tous les ports
export interface FileSystem {
readFile(path: string, encoding: 'utf8'): Promise<string>;
writeFile(path: string, content: string): Promise<void>;
exists(path: string): Promise<boolean>;
// ...
}
export interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
error(msg: string, ...args: unknown[]): void;
}
// scripts/lib/validate-md-links-core.ts — core pur
export function createLinkValidator(deps: { fs: FileSystem; logger: Logger }) {
// ... zéro import de 'fs' ou 'console'
}
// scripts/validate-md-links.ts — thin shell (~30 lignes)
import { promises as fsp } from 'fs';
const realFs: FileSystem = {
readFile: (p, enc) => fsp.readFile(p, enc),
// ...
};
createLinkValidator({ fs: realFs, logger: console }).run();Le coverage instrumenté par vitest ne couvre QUE les cores (scripts/lib/**, src/lib/**). Les shells sont exclus. Résultat : 99.75% de coverage sur du code pur, pas des wrappers IO.
Chiffres
| Métrique | Avant | Après |
|---|---|---|
| Features | 93 | 97 |
| ACs | 794 | 829 |
| Tests (unit + e2e) | 2191 | 2812 (2757 unit + 55 e2e) |
| Sync IO violations | 232 | 0 |
| Bindings files manuels | 93 | 0 |
| sourceFiles déclarés | 93 features | 0 (supprimé) |
| Coverage lignes | ~43% | 99.75% |
| Orphan source files | 53 | 0 |
| Quality gate | PASS | PASS |
| Empty @Verifies methods | 523 | 11 |
| Runtime coverage warnings | 17 | 0 |
| Unbound features | 4 | 0 |
1. "Le scanner ne voit pas les helpers"
Problème : 523 méthodes @Verifies "vides" parce que le scanner ne suivait que les CallExpression directes. Solution : walker transitif + class helpers + bare identifier references. 523 → 11.
2. "Le coverage ne s'écrit pas"
Problème : vitest v8 ne flush pas le coverage report quand stdout est saturé. Cause : build-js.ts imprimait ✓ src/foo.ts → js/foo.js pour chaque entry compilée pendant les tests. Solution : injecter un Logger port → tests passent un silent logger → plus de flood → coverage flush.
3. "Les bindings manuels sur-déclarent"
Problème : un humain écrit rightClickOpensPalette: createPaletteMachine dans un .bindings.ts. Le test @Verifies('rightClickOpensPalette') appelle en réalité routeThemeButtonClick(), pas createPaletteMachine(). Le binding manuel est conceptuellement vrai mais factuellement faux — le test ne vérifie PAS la palette machine. Solution : supprimer tous les bindings manuels. L'AST est plus honnête.
4. "Les tests E2E ne bindent à rien"
Problème : un test Playwright @Verifies<NavigationFeature>('tocClickLoadsPage') fait page.goto('/') + page.click('.toc-item'). Aucun symbole src dans le call graph. Solution : distinguer TU (symbole résolu) et E2E (test existe mais pas de binding symbole) dans le rapport. impl 4/8 TU + 4/8 E2E — honnête, pas un échec.
5. "Qui teste le testeur ?"
Le scanner AST lui-même est une feature (TEST-BINDINGS-INF) avec 23 ACs et 52 tests. Le compliance report est une feature (REQ-TRACK) avec 14 ACs. Le système se teste lui-même via ses propres décorateurs. Pas de méta-infini : à un moment le TypeScript compiler est le juge final (si @Verifies<F>('typo') ne compile pas, c'est fini).
Ce que le système ne fait PAS (yet)
- Mutation testing — le coverage prouve que le code est exécuté, pas que les assertions sont pertinentes. Un test
expect(true).toBe(true)passe le coverage. Stryker serait le prochain étage. - Completeness des ACs — le système vérifie que chaque AC déclaré est testé, mais pas que les ACs déclarés couvrent tout le comportement. C'est un jugement humain.
- Cross-feature dependencies — le scanner traite chaque feature indépendamment. Un changement dans THEME peut casser NAV si les deux partagent un state machine, mais le système ne le sait pas. L'event topology scanner (
scripts/scan-event-topology.ts) couvre partiellement ce cas.
Fichiers de référence pour le prochain Claude
HANDOFF-BIG-BANG.md— résumé technique de la session de refactorscripts/lib/test-bindings-scanner.ts— le scanner AST (500 lignes, commenté)scripts/lib/compliance-report-core.ts— le rapport de conformitérequirements/decorators.ts— les 3 décorateurs (@FeatureTest, @Verifies, @Exclude)requirements/base.ts— la classe Feature de basesrc/lib/external.ts— les ports hexagonauxtest/unit/CLAUDE.md— conventions de testvitest.config.js— coverage include/exclude + thresholds
Tone de l'article
Continental, technique, première personne. Pas un tuto — un retour d'expérience sur un choix architectural et ses conséquences. Montrer le problème (le trou dans le cycle), la solution (le scanner AST), les frictions (les 5 points ci-dessus), et le résultat (le tableau de chiffres). Citer le code réel, pas des exemples jouets.