Een kwaliteitscontrole heeft één taak: nee zeggen als de code slechter is geworden. De meeste kapotte controles die ik ben tegengekomen, waren dat vermogen ongemerkt kwijtgeraakt en bleven gewoon groen melden. De scherpste formulering staat in Knossos, de architectuurscanner die ik bouw, in een commentaar in zijn eigen kwaliteitsscript: "a gate that cannot fail is worse than no gate because it reads as if it checked."
Deze post beschrijft de opzet die ik gebruik, laag voor laag: wat waar hoort, wat elke laag kost, en de paar regels die ervoor zorgen dat elke controle kan blijven falen. Hij brengt de acht eerdere posts uit deze reeks samen in één project. Het bewijs komt uit drie repository's: Knossos, de repository van deze blog en een Python- en Vue-app die ik nog niet heb uitgebracht.
De opzet in één oogopslag
Eén regel bepaalt waar een controle hoort: in de lichtste laag die hem nog elke keer afdwingt. Handhaving kost wrijving en brengt onderdelen mee die kapot kunnen. Een conventie waarvoor "meestal gevolgd" genoeg is, kan in een prompt. Een regel waarbij de eerste overtreding al te veel is, heeft een hook of een gate nodig.
| Laag | Draait | Duurt | Hoort hier |
|---|---|---|---|
| Context | elke sessie | niets | CLAUDE.md, gebiedsregels en ADR's: het waarom |
| Agent-hooks | bij elke tool call of einde beurt | milliseconden | wat je wilt vangen terwijl de agent nog bezig is |
| Pre-commit | bij elke commit | seconden | de snelle baan: formatting, lint, types, secrets |
| Pre-push | bij elke push | minuten | de volle baan: tests, coverage-drempels, duplicatie, dode code |
| CI | elke push, pull request en nacht | minuten | dezelfde banen, plus end-to-end tests en de gate tegen een baseline |
| Ingepland | wekelijks | een uur of meer | mutation testing, dependency-audits, opruimen |
Laag voor laag
Context: het waarom, nooit de garantie
Of de agent CLAUDE.md volgt, is een kwestie van kans. Een ADR in de repository van deze blog zegt het zonder omwegen: "Most of the time is not every time, and on a long task 'most of the time' compounds the wrong way." Gebruik context voor de redenering achter een regel, en zet alles wat elke keer moet kloppen in een lagere laag van de tabel. Dat is de opbouw van handhaving uit een eerdere post, en die houdt nog steeds stand.
Agent-hooks: vangen terwijl de agent nog bezig is
Een hook gaat af op wat de agent zelf doet, dus een fout komt direct terug in de sessie en de agent herstelt hem ter plekke. Drie hooks dekken het meeste wat je zo vroeg wilt vangen:
- Bescherm de harness (PreToolUse op Edit en Write). Blokkeer schrijven naar
.enven build-output, en dwing een permissievraag af bij tests en bij elk configbestand dat de repo bewaakt. De reden, uit dezelfde ADR: "the cheapest way to turn a failing suite green is to lower the bar." Zet je maar één hook in een project, maak het dan deze. - Controleer wat er net is geschreven (PostToolUse). Bij deze blog controleert hij de absolute regels van de huisstijl, met regelnummer en oplossing erbij. Op de dag dat die hook af was, vond hij in alle 196 contentbestanden drie overtredingen die elke review hadden overleefd.
- Maak de beurt klaar voor review (Stop). Draai lint en de typechecker als er broncode is gewijzigd, en geef een fout terug zodat de beurt doorloopt in plaats van te eindigen op werk dat een reviewer moet afmaken. In een sessie waarin alleen content is aangeraakt, is hij in 8 ms klaar.
Een hook kan ook adviseren in plaats van weigeren. De hooks van Knossos weigeren nooit iets: bij de start van een sessie geven ze de agent een overzicht van de architectuur, en bij het lezen of bewerken van een bestand voegen ze een korte notitie toe over wat ervan afhangt, welke grensregels erop van toepassing zijn en welke tests het raken. Een dependency-graph levert bewijs, en bewijs hoort op tafel te liggen terwijl de agent beslist. Wat Knossos verder doet, lees je op de toolpagina.
Git hooks: één entry point, twee profielen
Een agent-hook dekt alleen die agent, in die client, met die permissie-instellingen. Code weiger je een laag lager, waar elke commit langskomt, wie of wat hem ook maakte. Daarom zit het weigeren bij Knossos en de ongepubliceerde app in git en CI, en heeft die app helemaal geen Claude Code-hooks.
Stuur elke controle door één entry point. Knossos heeft één script, tools/quality, met banen voor statische controles, tests, de Rust-worker, coverage, releasecontroles en de architectuurgate. Pre-commit roept het snelle profiel aan, pre-push het volle. De ongepubliceerde app doet hetzelfde met een commit-laag en een push-laag in de pre-commit-config.
Wat er in die banen draait, is weinig spannend: strikte types (mypy in strict mode, PHPStan), grenzen aan complexiteit en functielengte, duplicatiedetectie, detectie van dode code, secret scanning, coverage-drempels per bestand, een budget voor de bundlegrootte en dependency-audits.
CI: hetzelfde script, plus wat lokaal te traag is
CI draait dezelfde banen, in Knossos als matrix, in de ongepubliceerde app via één herbruikbare verify-workflow. Met één entry point betekenen "lokaal geslaagd" en "in CI geslaagd" precies hetzelfde. In Knossos hernoemde ik de oude composer check zelfs naar check:quick, met de reden in de commit: "it is not the authoritative gate".
Daar komen de controles bij waarop niemand op een laptop wil wachten: end-to-end tests, een scan van de container-image en de gate tegen een baseline. De gate van Knossos scant zowel de baseline-commit als de wijziging, en legt het resultaat naast de budgetten in knossos.json: nul nieuwe cycles, nul grensovertredingen.
Wekelijks: de dure waarheden
Sommige controles zijn te duur voor elke push, en juist die vertellen je of de rest iets waard is. Draai mutation testing volgens een vast schema. Het is de enige laag die vraagt of een test een bug zou opmerken. In Knossos vond die laag een aftrekking van een timeout-budget die je in een optelling kon veranderen terwijl de hele suite groen bleef. Twee subprocessen kregen dan samen tot twee keer de tijd waar de caller om vroeg.
Elke controle moet kunnen falen
Alles hierboven is waardeloos als een controle groen kan worden zonder te controleren. Elk van deze gevallen kwam voor in een van de drie repository's, en bij elk hoort een oplossing die je vandaag kunt toepassen.
- Geen baseline. Een gate die met een baseline-commit vergelijkt, vergelijkt zonder history een boom met zichzelf en slaagt. Laat hem dan weigeren te draaien. De gate van Knossos doet dat, en liet op de dag dat hij werd aangesloten precies daarop de release van v0.14.0 falen: "The guard did its job; the wiring did not exist."
- Een hook die nooit afgaat. De hooks van deze blog waren gecommit zonder exec-bit, en "a write to
dist/sailed through with no error anywhere." De unit test slaagde die dag gewoon, omdat hij JSON via een pipe aan het script gaf in plaats van de hook echt te laten afgaan. Roep hooks aan alsbash <script>, en test ze door ze te laten afgaan. - Een getal dat niemand leest. De mutation-job van de ongepubliceerde app telde de overlevende mutanten, drukte dat getal af en faalde alleen bij een infrastructuurfout, dus een target kon van 0 naar 40 overlevenden gaan zonder dat er iets rood werd. Berekent een controle een getal, dan moet een drempel er iets mee doen.
- Een lege scan. Een controle die een map doorloopt en vaststelt dat de lijst met overtreders leeg is, slaagt net zo goed als de scan niets vindt. Controleren dat een allowlist leeg blijft is een goed patroon, zolang de controle aantoont dat de scan bestanden heeft gezien. In de ongepubliceerde app matchte een grep niet meer na een wijziging elders, de lus draaide nul keer en de controle slaagde. Alleen een test van precies die controle ving het op.
- Een warning die met 0 eindigt. In Knossos lieten PHPUnit-warnings de run niet falen, dus een mutant die een vroege test een warning liet geven, stopte de run met exit 0 en telde als overlever. In één bestand waren negen van de tien gemelde overlevers zulke spookmutanten.
failOnWarningstaat nu aan, en een test houdt hem aan. - Een controle die de fout vastzet. Een consistentie-audit las een controle op tokenscope als een controle op rol. Toen ik de fix had doorgevoerd, kon het beperkte token dat je krijgt tijdens het instellen van 2FA het wachtwoord van het account wijzigen. De integratietests vingen het op terwijl de unit suite groen bleef. Erger nog: de controle die tegelijk met de wijziging was toegevoegd, legde het nieuwe gedrag vast en had de regressie zo voorgoed vastgezet. Geef daarom elke nieuwe controle een positieve test: een geplante overtreding waarvan je hebt gezien dat hij wordt geweigerd.
En geen enkele controle mag altijd falen
Het spiegelbeeld richt evenveel schade aan. Toen ik een Knossos-config aan de ongepubliceerde app toevoegde, nam ik alleen de grensregels op die vandaag op nul staan. Eén regel met een goedgekeurde lijst uitzonderingen zou bij elke run tien overtredingen geven, en "a gate that always fails is a gate nobody reads." Regels die je nog niet haalt, horen in een drempel met een baseline die alleen strenger wordt.
Hetzelfde geldt voor een gate die rood wordt van goed nieuws. De mutation-ratchet van de ongepubliceerde app faalde eerst in beide richtingen, en stond zo vier runs achter elkaar op rood terwijl die runs alleen verbeteringen maten. Nu laat hij een verbetering door en opent hij een pull request die de baseline aanscherpt. Een rode run betekent één ding: er is iets slechter geworden.
Scherp een ratchet alleen aan op een volledige run
Een ratchet is zo eerlijk als de meting erachter. Op 22 juli ging de mutation-drempel van Knossos van 53 naar 76, op basis van testbatches die per bestand waren gemeten. Een dag later stond hij weer op "the measured 53", en nu staat hij op 63, gemeten met één run over de hele boom die bijna zes uur duurde. De regel die nu in de config staat: een drempel komt nooit hoger dan wat een volledige run heeft gemeten.
Drift gaat ook de andere kant op. De coverage-drempels van de frontend in de ongepubliceerde app bewogen nooit, maar de meting zakte ernaartoe tot branch coverage nog maar zes branch-arcs boven de drempel zat, terwijl elke gate groen bleef. In de woorden van de config: "The floors never moved; the measurement came down to meet them." Twee drempels omlaag zetten om weer speling te krijgen, was de eerlijke oplossing.
Zo bouw je dit in je eigen project
- Bescherm eerst de harness. Een PreToolUse-hook die vraagt voordat er iets aan tests, coverage-config of lint-config wordt veranderd. Al het andere steunt daarop.
- Eén entry point, drie aanroepers. Pre-commit, pre-push en CI draaien hetzelfde script met een ander profiel.
- Geef elke controle een positieve test. Een geplante overtreding waarvan je hebt gezien dat hij wordt geweigerd, en bij een hook een echte trigger in plaats van JSON door een pipe.
- Een berekend getal moet ergens over beslissen. Drukt een job het af en eindigt hij met 0, gooi de job dan weg of koppel er een drempel aan.
- Zet in een gate alleen wat vandaag op nul staat. De rest gaat in een ratchet die alleen strenger wordt.
- Scherp aan op basis van volledige runs. Nooit op een steekproef, nooit op één bestand.
- Rood betekent slechter. Goed nieuws opent een pull request.
- Advies waar de agent werkt, weigering waar elke commit langskomt.
Knossos had ooit een budget dat tussen 2 augustus en 10 september opliep van 68 naar 158, bij een limiet van 110, en "no run said so". Aan de limiet lag het niet. Er ontbrak een run die erop kon falen, en toen die er was, liet hij dezelfde dag nog een release falen. Wat code goed houdt, zijn vooral controles zoals die: saai, specifiek, en in staat om nee te zeggen.