Aller au contenu
Portes de CI Publié le Par Guillaume Fossier

Ce que dit un PASS de semctx sur un diff

En bref

Une porte d'analyse d'impact, étape de CI qui lit le diff d'une modification, rend PASS, WARN ou BLOCK. Ce verdict est une lecture de ce diff par l'outil, pas une garantie que le changement est correct. Avec semctx 0.3.5 sur un dépôt de démonstration, un décalage d'une place qui viole un invariant déclaré (une règle que le code doit toujours respecter) a reçu WARN, puis PASS une fois deux commentaires du dépôt alignés, les quatre tests existants restant verts. C'est un mécanisme sur un cas construit, pas une fréquence dans du code réel.

semctx est un outil open source dont je suis l'auteur principal ; le Diagnostic Agents payant que je propose en fin d'article l'utilise. Il a été développé avec l'aide d'un assistant IA (Claude), comme le montrent les mentions Co-Authored-By de l'historique du dépôt hoklims/semctx. Il répond à une question étroite, posée dans son README : étant donné ce changement, qu'a-t-il mis en risque, et est-ce prouvé ? Il est jeune : première version publiée sur npm le 5 juillet 2026, soit 86 jours avant le 29 septembre 2026, date du relevé (registre npm). Ce n'est donc pas un avis extérieur.

Aucune mesure n'a été faite ici sur des dépôts réels : la cible est la fixture de l'outil, un petit dépôt de démonstration livré avec lui, et les changements soumis ont été écrits pour l'essai.

Conditions de l'essai

Outil
semctx 0.3.5 (sortie de semctx --version), lancé par son chemin complet
Date
29 septembre 2026, de 01 h 31 à 01 h 38 UTC
Cible
examples/sample-typescript-repo au tag v0.3.5, copié dans un dépôt Git jetable
Environnement
Windows 11, Node v22.23.2, Git 2.50.1.windows.1, Bun 1.4.2 ; tests lancés par bun test, le lanceur de Bun
Non exercé
tout ce qui n'est pas dans la ligne suivante, dont la configuration version 2, le hook de pre-commit, l'action GitHub sur un runner et les outils MCP pour agents
Exercé
setup, index, index-health, doctor, inspect symbol, verify diff (texte, JSON, --fail-on)

Le semctx nu de Git Bash lançait une 0.3.3 : toutes les sorties viennent du binaire 0.3.5, appelé par son chemin. La 0.3.6, dernière version sur npm au 29 septembre 2026 (publiée le 27 septembre à 22 h 01 UTC), n'a pas été exécutée : c'est pourtant vers elle que résout la commande bunx semctx@latest du README.

Le cas : un invariant déclaré, une place de trop

semctx verify diff est une telle porte : elle rend PASS, WARN (avertissement) ou BLOCK (violation bloquante) selon des règles configurables. Elle ne lance pas le code : elle rapproche les lignes changées d'un graphe du dépôt (fonctions, appels, tests reliés par leurs imports), construit par semctx index.

Un marqueur @invariant, commentaire balisé, déclare l'invariant ; l'outil ne l'infère pas. La fixture, un dépôt de réservation, déclare confirmed-never-exceeds-capacity : les places confirmées d'un créneau ne dépassent jamais sa capacité. Changement A, écrit pour l'essai : une ligne de confirmReservation tolère une place de trop.

Diff du changement A · src/domain/confirmation.ts
-  if (reservation.seats > remaining) {
+  if (reservation.seats > remaining + 1) {

Dans la sortie : impacted nodes compte les éléments du graphe atteints par le diff ; Recommended tests, des fichiers de test reliés par un import ; Unknowns, ce que l'analyse ne peut pas prouver ; Findings, les constats qui fondent le verdict. Entre crochets, le statut de l'invariant : [contradicted] (contesté), [inferred] (déclaré, sans test relié) ou [tested] (un test est relié).

semctx 0.3.5 · verify diff --base main · 01 h 32 UTC · code de sortie 0 · range remplacé par […]
Verdict: WARN
  range         : […]
  changed files : 1
  impacted nodes: 2

Impacted invariants
  ! Invariant: confirmed-never-exceeds-capacity [contradicted]

Recommended tests
  test/confirmation.test.ts

Contradictions touched (non-normative)
  ~ Invariant: confirmed-never-exceeds-capacity

Unknowns
  ? Impacted invariant is not test-backed: Invariant: confirmed-never-exceeds-capacity

Findings
  [WARN ] contradiction_unresolved: change touches 1 unresolved contradiction(s)

Verdict WARN, code de sortie 0. Les quatre tests existants passent (bun test), et pourtant la règle est violée : sur un créneau de capacité 10 qui compte déjà 8 places confirmées, un court script de l'essai, non reproduit ici, confirme une demande de 3 places.

Script de contre-exemple, sur main puis avec le changement A · 01 h 33 UTC
rejected: insufficient capacity for slot slot-1
confirmed seats = 11, capacity = 10, invariant holds = false

Ce que ce WARN dit vraiment

Il ne désigne pas la place de trop. La règle déclenchée est contradiction_unresolved : l'invariant porte le statut [contradicted] avant même le changement (inspect symbol, qui liste ce que l'outil sait d'une fonction, sur main). La fixture déclare en effet le même identifiant deux fois, avec deux énoncés :

Les deux déclarations, dans capacity.ts puis confirmation.ts (préfixe de commentaire retiré)
@invariant confirmed-never-exceeds-capacity: the sum of seats across CONFIRMED reservations for a slot must never exceed slot.capacity
@invariant confirmed-never-exceeds-capacity: confirming must never push confirmed seats above slot.capacity

L'outil marque contesté un identifiant à deux énoncés, sans choisir entre eux (markers.ts). Quatre contrôles vont dans ce sens, avec le même WARN à la ligne range près : un renommage de variable locale sans effet (A2) ; le décalage de A sur une base sans la note obsolète (docs/legacy-capacity-notes.md, A3) ; un renommage sur une base sans le marqueur @risk, qui déclare un risque connu (A4) ; le décalage de A sur une base sans la note ni le marqueur (A5). Il disparaît quand on aligne les deux énoncés (A6) : un commit ne change que ce commentaire, puis le même décalage que A.

Contrôle A6 · 01 h 35 UTC · code de sortie 0
Verdict: PASS
  range         : […]
  changed files : 1
  impacted nodes: 2

Impacted invariants
  ! the sum of seats across CONFIRMED reservations for a slot must never exceed slot.capacity [tested]

Recommended tests
  test/confirmation.test.ts

Findings
  none

OK no blocking violations

PASS, Findings vide, invariant [tested] : un fichier de test est relié à la fonction par un import, et l'outil ne l'exécute pas. Les quatre tests passent et le script donne encore 11 places confirmées pour une capacité de 10 (01 h 35 UTC).

Le BLOCK ne dit pas non plus « défaut trouvé »

Changement B : une nouvelle fonction, confirmedSeatsAfterCancellation, qui renvoie les places confirmées d'un créneau une fois la réservation annulée retirée de la liste. Elle porte son propre @invariant et aucun test n'y est relié.

Changement B · 01 h 35 UTC · code de sortie 3
Verdict: BLOCK
  range         : […]
  changed files : 1
  impacted nodes: 2

Impacted invariants
  ! after a cancellation, the seats of that reservation no longer count as confirmed [inferred]

Recommended tests
  none

Unknowns
  ? Impacted invariant is not test-backed: after a cancellation, the seats of that reservation no longer count as confirmed
  ? "confirmedSeatsAfterCancellation" is constrained by [cancelled-reservation-frees-its-seats] but has no test — behaviour under change is unverified.

Findings
  [BLOCK] invariant_touched_without_test: invariant-constrained code changed without a covering test: confirmedSeatsAfterCancellation

Aucun défaut n'est connu dans cette fonction. Un test à un cas (B2 : réservations de 4 et 3 places, annulation de la seconde, résultat attendu 4) passe, avec 5 tests verts en tout ; un seul cas ne prouve pas la correction. Le BLOCK dit « invariant déclaré, aucun test relié », pas « défaut » : avec ce test et un index à jour, B2 reçoit PASS et l'invariant passe à [tested]. Sans relancer semctx index après le commit, un second BLOCK s'ajoute, index_binding_stale : il vient de l'index, pas du code.

Ce que WARN et BLOCK font au code de sortie

“PASS → 0; WARN → 0 (unless --fail-on warn); BLOCK → non-zero (unless --fail-on none).”

docs/reference/cli.md, « verify diff »

Relevé ici : PASS sort en 0 ; WARN en 0 par défaut, en 3 avec --fail-on warn ; BLOCK en 3 par défaut, en 0 avec --fail-on none (verdict inchangé, message d'erreur toujours affiché sur stderr). La documentation ne promet que « non-zero » : 3 est une valeur observée. Par défaut, la commande sort en 0 sur un WARN : un pas de CI qui l'appelle tel quel n'échoue pas (action GitHub non exercée).

Ce que ces verdicts permettent de dire

On peut dire

« Avec semctx 0.3.5, sur ce dépôt de démonstration, le 29 septembre 2026, le diff de A6 touche confirmReservation, liée à l'invariant confirmed-never-exceeds-capacity et au test test/confirmation.test.ts. Verdict : PASS, sans constat. »

On ne peut pas dire

  • que PASS signifie que l'invariant est respecté (A6) ;
  • que WARN signale la place de trop (A2, un renommage, reçoit la même sortie) ;
  • que BLOCK signale un défaut (B ne contient aucun défaut connu) ;
  • que le test recommandé protège l'invariant (quatre tests verts en A et en A6).

Sur A6, trois exécutions de verify diff --format json (01 h 38 UTC) donnent un rapport identique octet pour octet (même empreinte SHA-256). Mais A6 se répète à l'identique, violation comprise.

Ce que la porte ne voit pas

Limites publiées par l'outil, citées en anglais telles quelles, avec ce que l'essai en a montré. Le registre du site classe d'ailleurs « Le comportement à l'exécution » en « Non établi ».

  • Elle ne lance ni le code ni les tests.

    “semctx is a static impact analyzer: it reasons about the diff against the graph without building or running the code.”

    README, « Current delivery status ». Les tests recommandés sont reliés par leurs imports, « not executed or measured coverage » (« What it does »).

  • Elle ne voit pas la concurrence.

    “Concurrency/runtime properties are surfaced as unknowns, not statically proven — by design.”

    README, « Known limitations ». La fixture déclare un risque de concurrence (@risk) et inspect symbol le liste :

    inspect symbol confirmReservation · 01 h 34 UTC · extrait
    Related claims (by authority)
      […]
      (risk/inferred) capacity is read then written without a guard, so two concurrent confirmations can both pass the check and overbook the slot
      (invariant/contradicted) Invariant: confirmed-never-exceeds-capacity
      […]

    Le README décrit aussi, à « What it does », les unknowns comme « what static analysis cannot prove (e.g. a concurrency race), stated plainly ». Or aucune des cinq sorties de verify diff (texte et JSON, changements A, A2 et A6) ne mentionne ce risque. Écart constaté, non qualifié dans cet essai.

  • Elle ne juge pas ce qu'elle n'analyse pas.

    “The default version-1 configuration preserves the historical TypeScript-family analysis path: TypeScript receives semantic extraction, Markdown is classified as documentation, and SQL as migrations.”

    README, « Language coverage and analysis health ». La fixture est en version 1, où le README ne liste pas Python. Le changement C2, un script Python qui confirme toutes les réservations en attente, reçoit PASS, 0 nœud impacté. index-health, la commande qui rend compte de la couverture de l'analyse, affiche partial (14/18 selected, 4 excluded) (3 exclus sur main avant tout changement) et sort en 2, code que la documentation attribue à la couverture partielle. La documentation décrit, en version 2 (non exercée ici), un BLOCK analysis_scope_incomplete (cli.md). semctx setup prévient dès le départ (stderr, 01 h 31 UTC) :

    “WARN setup succeeded, but incomplete analysis cannot justify negative conclusions; run 'semctx index-health --json' for the exact gates and reasons.”

  • Elle ne signale pas une fonction sans marqueur ni test relié.

    Dans cet essai, le changement C, une fonction TypeScript exportée sans marqueur qui confirme sans contrôle de capacité (scripts/force-confirm.ts, hors du include src/**/*.ts de la configuration, sans appel à une fonction du dépôt ; l'outil l'analyse tout de même), reçoit PASS, Findings vide (01 h 36 UTC, code de sortie 0). README, « Semantic markers (opt-in) » :

    “Markers are what unlock the strict-tier invariant/contract BLOCK rules”

    Le README classe en WARN un contrat exporté (« public interfaces/types », « What it does ») modifié sans test relié (« Severity tiers ») : cas non exercé dans cet essai.

Lire la règle, pas seulement le verdict

Ici, le même décalage d'une place reçoit WARN puis PASS, et le WARN vient d'une règle (contradiction_unresolved) sans rapport avec la place de trop. Lisez la règle déclenchée dans Findings, pas seulement le verdict, et fixez à l'avance si un WARN doit faire échouer la CI (par défaut, la commande sort en 0). Le README va vers un contrôle qui exécute le comportement :

“Use semctx to select the scope; use a runtime check to confirm behaviour.”

Vos agents de code passent déjà par une porte en CI ?

Cinq jours, au forfait. Le Diagnostic Agents mesure votre revue, audite votre setup d'agents et pose une première porte dans votre CI. Vous repartez avec un verdict : ce qui tient, ce qui casse, ce qui vaut d'être industrialisé.

Voir le Diagnostic Agents