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.
- 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é).
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.
rejected: insufficient capacity for slot slot-1
confirmed seats = 11, capacity = 10, invariant holds = falseCe 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 :
@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.capacityL'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.
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 violationsPASS, 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é.
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: confirmedSeatsAfterCancellationAucun 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).”
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) etinspect symbolle 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
unknownscomme « what static analysis cannot prove (e.g. a concurrency race), stated plainly ». Or aucune des cinq sorties deverify 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, affichepartial (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 BLOCKanalysis_scope_incomplete(cli.md).semctx setuppré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 duincludesrc/**/*.tsde la configuration, sans appel à une fonction du dépôt ; l'outil l'analyse tout de même), reçoit PASS,Findingsvide (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.”