Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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ù :

  1. Chaque requirement est typé (compilateur refuse les typos)
  2. Chaque test déclare quel requirement il vérifie (via décorateur typé)
  3. Un scanner prouve que le test appelle effectivement du code lié au requirement
  4. Le coverage confirme que ce code est bien exécuté
  5. 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
}

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');
  }
}

@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 :

  1. Parse chaque *.test.ts et *.spec.ts
  2. Trouve les classes @FeatureTest(F) et les méthodes @Verifies<F>('ac')
  3. Walk le body de chaque méthode @Verifies et collecte tous les identifiants référencés en position valeur (pas les types, pas les noms de déclaration, pas les .name de PropertyAccess)
  4. Résout chaque identifiant via la import map du fichier → chemin relatif repo
  5. Suit transitivement les helpers locaux (function setup() { ... }) et les helpers de classe (this.makeFixture()) via un visited set (cycle-safe)
  6. Filtre aux fichiers dans src/** ou scripts/** (le code testé, pas les deps externes)
  7. 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');
}

Un scanner CallExpression-only voit setup() (local, pas bindable) et expect() (vitest, filtré). Il rate createSpaNavMachine.

Notre scanner :

  1. Collecte les helpers top-level (function setup() { ... }) dans une map name → body
  2. Collecte les helpers de classe (this.makeFixture()) dans une map name → body
  3. Quand un identifiant référencé match un helper, walk le body du helper récursivement
  4. 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

Trois colonnes :

  • ACs — pourcentage d'ACs ayant au moins un @Verifies test (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 @Verifies test
  • Le coverage global tombe sous 80%

Le threshold vitest enforce séparément :

  • src/lib/**/*.ts : 98% statements, 95% branches, 98% functions, 99% lines
  • scripts/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 / FAIL

Aucun maillon n'est déclaratif-seulement. Chaque lien est vérifié :

  • Feature → AC : TypeScript type system (keyof F)
  • AC → Test : @Verifies decorator (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();

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 refactor
  • scripts/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 base
  • src/lib/external.ts — les ports hexagonaux
  • test/unit/CLAUDE.md — conventions de test
  • vitest.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.

⬇ Download