Technische schuld van AI-code opruimen: de opruimronde die niemand inplant
7m leestijd

Technische schuld van AI-code opruimen: de opruimronde die niemand inplant

Churn, duplicatie en de lavalaag zijn diagnoses. Dit is de behandeling: technische schuld van AI-code opruimen in een vaste opruimronde, en welk deel van dat werk de agent zelf kan doen.

Gisteren schreef ik over een codebase met twintig manieren om geld te formatteren. De logische vervolgvraag: wat is daarmee gebeurd? Negentien zijn weg. Net als de datumformatters, die ik na een dozijn niet meer telde, en de rest van de inconsistenties. Alles opruimen kostte één dag.

Die dag is het interessante deel. Niet het verwijderen zelf, dat was simpel. Het feit dat die dag bestond. Het opruimen van AI-code heeft in de meeste planningen een vreemde status: iedereen vindt het nodig, niemand plant het in, en dus staat het eeuwig in de sprint die begint zodra het rustiger wordt.

Het wordt niet rustiger.

Waarom het opruimen vanzelf nooit gebeurt

Technische schuld werd vroeger afgelost als bijeffect. Je kwam terug in een bestand dat je zelf had geschreven, voelde de wrijving, en ruimde op terwijl je er toch was. Al werkend refactoren werkte omdat degene die de rommel maakte, steeds terugkeerde naar de plaats delict.

Gegenereerde code breekt die cirkel twee keer. De agent die de code schreef komt nooit meer terug: elke sessie begint koud, zonder geheugen en zonder schaamte. En zelf heb je er nooit echt in gezeten, dus je krijgt er geen jeuk van zoals van je eigen rommel.

De cijfers laten zien dat dit structureel is, en het is hetzelfde bewijs dat achter de churn die je nu al in git log kunt meten zit. GitClear analyseerde 211 miljoen gewijzigde regels: verplaatste code, de vingerafdruk van refactoren, zakte van ongeveer een kwart van alle wijzigingen in 2021 naar minder dan tien procent in 2024, terwijl gekopieerde code er voor het eerst bovenuit kwam. Agents voegen toe. Ze consolideren niet. En niets in de werkwijze houdt dat tegen.

Dan blijft er één optie over: consolidatie wordt een geplande activiteit, net als backups. Een vast blok, met een naam, dat eindigt in een gemergde diff.

Zo ziet mijn opruimronde eruit

Een klein, vast blok. Een halve dag, elke week, dezelfde dag. Klein en regelmatig wint van het heroïsche opruimkwartaal, want dat kwartaal overleeft geen enkele roadmap, en tegen de tijd dat het had moeten beginnen is de lava al hard. Een wekelijkse halve dag pakt de schuld terwijl die nog warm genoeg is om te verplaatsen. Een geërfde codebase verdient een grotere eerste ronde, bij mij kostte die een hele dag, maar daarna houdt het wekelijkse blok het bij.

Breng eerst in kaart, fix nog niets. Het eerste halfuur levert een shortlist op, verder niets. Een clone detector zoals jscpd voor de structurele bijna-duplicaten. Greppen op werkwoord voor de versnipperde helpers. git log voor de bestanden die elke twee weken stilletjes herschreven worden. De uitkomst is "geldformattering, datumlogica, drie concurrerende API-clients", op volgorde van pijn. Weersta de neiging om tijdens de inventarisatie alvast iets te fixen. Die inventarisatie houdt de ronde zuiver.

Ruim op waar je gaat bouwen. Raakt het werk van volgende week de prijslogica, dan consolideert de ronde van deze week het geld. Opruimen levert pas iets op als iemand over de vrijgemaakte grond loopt, dus maak de grond vrij waar het werk langskomt.

Eén klus per ronde. Kies één gedupliceerde verantwoordelijkheid. Wijs één implementatie aan als de standaard, migreer elke aanroep daarnaartoe, verwijder de rest. De opbrengst is wat je weggooit: een goede opruimronde heeft een netto negatieve diff, en "hoeveel regels zijn er nog over" is een betere score voor de ronde dan "hoeveel regels zijn er aangepast".

Zet het vast voordat je stopt. De laatste twintig minuten zijn voor het verankeren: een regel in CLAUDE.md die de ene juiste helper aanwijst, een lintregel die de oude imports verbiedt, een hook als je die hebt. Sla je dit over, dan is het dweilen met de kraan open. Volgende maand schrijft de agent vrolijk formatter eenentwintig.

Wat ik de agent zelf laat opruimen

Het gereedschap dat de rommel maakte, blijkt goed te zijn in dweilen. Dat is minder ironisch dan het klinkt. Consolidatie is de zeldzame taak met precies de vorm waar agents mee overweg kunnen: het doel is bekend, het gedrag ligt vast, en het werk is mechanisch.

  • De inventarisatie. De bijna-duplicaten vinden, elke aanroep op een rij zetten, en de vergelijkingstabel bouwen: welke van de twintig rondt netjes af, welke kapt gewoon af, welke heeft het eurosymbool hardgecodeerd. Dat is uren saai leeswerk dat de agent in minuten doet, en het hoeft alleen te kloppen op punten die ik kan controleren.
  • Characterization tests. Voordat er iets beweegt schrijft de agent tests die vastleggen wat elke variant nu doet, inclusief het gedrag dat overduidelijk een bug is. Eerst vastleggen, daarna beslissen. Zonder dat vangnet is consolidatie gewoon churn met betere bedoelingen.
  • De migratie. Zodra ik heb gekozen welke versie blijft, is tweehonderd aanroepen herschrijven precies het werk waar ik een agent voor wil inzetten: volledig gespecificeerd, per aanroep controleerbaar, en dodelijk saai.

Wat ik zelf blijf doen

  • Kiezen welke versie blijft. Vier formatters rondden op vier manieren af. Dat zijn drie bugs en één bedoeling, en niets in de code vertelt je welke van de vier de bedoeling is. De agent kiest het gedrag dat het vaakst voorkomt. Het juiste gedrag staat in de factuurmodule, op één plek. Die keuze vraagt domeinkennis, en domeinkennis is precies wat de agent per definitie mist.
  • "Dode" code verwijderen. De agent kan niet weten wat dood is. Hij ziet de reflection-aanroep niet, de runtime dispatch niet, de ene cronjob die de module twee keer per jaar importeert. Hij verwijdert met droge ogen omzet. Verwijderbeslissingen blijven mensenwerk.
  • De grenzen. Twee functies die op elkaar lijken zijn niet altijd één functie. Beoordelen of een gelijkenis een abstractie is of toeval, is ontwerpwerk, en ontwerpwerk op basis van patroonherkenning is precies hoe de rommel ontstond.

De verdeling past in één zin: de agent doet de migratie, ik doe de selectie.

Giet de ronde in een skill

Die verdeling is aan te leren, en ik had er genoeg van om die elke week opnieuw uit te typen, dus die staat inmiddels in een Claude Code skill. De stap die ertoe doet is de harde stop: de skill verbiedt de agent om zelf te kiezen welke versie blijft, waarmee dat menselijke keuzemoment verschuift van discipline naar tooling. De skill legt bewust ook geen tooling vast, zodat hetzelfde bestand werkt op een TypeScript-monorepo en op een geërfde PHP-codebase. En omdat bevindingen aannemelijk én fout kunnen zijn, wordt de inventarisatie gecontroleerd voordat ik hem zie: een verse subagent, die niet weet hoe de lijst tot stand kwam, probeert elke vermeende near-clone onderuit te halen. Dubbelgangers die stiekem een andere taak hebben, sneuvelen daar in plaats van in mijn review. Dit is het echte bestand, in .claude/skills/consolidation-pass/SKILL.md:

markdown
---
name: consolidation-pass
description: Use when running a scheduled cleanup pass on duplicated
  helpers or near-clones in any codebase. Surveys ONE duplicated
  responsibility, has a fresh subagent verify every finding, pins
  behaviour with tests, then STOPS for a human to pick the surviving
  implementation before migrating anything.
---

# Consolidation pass

Run ONE pass, for ONE duplicated responsibility (money formatting, date
handling, an API client, retry logic). Never widen the scope mid-pass.
The pass must work in any language and any repo: discover the project's
own tooling first, never assume a specific tool.

## Step 0: Discover the project

Establish, from the repo itself:

- The language(s) and package manager (lockfiles, manifests).
- How tests run (CI config, Makefile, package scripts). Every later
  step uses THIS command, never a guessed one.
- Which duplication tooling fits the ecosystem (`jscpd`, PMD CPD,
  `pylint`'s duplicate-code check, a language-specific linter). If
  nothing is available or installable, fall back to structural grep.
- The project's linter and how it is configured (needed for lock-in).

## Step 1: Survey (read-only)

- Run the clone detector over the source directories.
- Grep by verb, not by name: the naming patterns of the responsibility
  under review (`format*`, `to*`, `display*`, `parse*`, `*Client`) plus
  the primitives underneath (number/date formatting calls, date-library
  imports, HTTP client constructors).
- Check import spread: how many different libraries or hand-rolled
  versions serve the same job.
- Output ONE table: variant name, file, call-site count, behavioural
  differences (rounding, timezone, locale, error handling, defaults).
- Do not fix, rename, or "quickly improve" anything in this step.

## Step 2: Verify the findings

- Spawn a fresh subagent per claimed clone group, blind to how the
  survey built the list, and have it try to REFUTE the finding: do
  these variants really do the same job, or do they only look alike?
- The subagent re-reads every variant and its call sites cold, and
  checks the claimed behavioural differences and call-site counts.
- Drop refuted findings. Mark doubtful ones as doubtful in the table.
- Only verified findings reach the human in step 4.

## Step 3: Pin behaviour

- Write characterization tests for EVERY verified variant, in the
  project's own test framework and conventions.
- Capture what each variant does today, including behaviour that looks
  like a bug. Pin first, decide later.
- Run the full suite with the project's own test command. It must be
  green before step 4.

## Step 4: STOP for the human

- Present the table and the behavioural differences.
- Ask which implementation survives, and which pinned behaviours are
  bugs to fix versus intent to keep.
- Never choose a survivor yourself: the most common behaviour is not
  necessarily the correct one.

## Step 5: Migrate

- Move the survivor to the project's canonical location for shared
  code. Follow the existing structure; ask if there is none.
- Rewrite every call site to the survivor, in small commits, tests
  green after each one.
- Where a pinned behaviour was declared a bug in step 4, fix it in the
  survivor only, in its own commit, updating the test deliberately.

## Step 6: Delete

- Remove the losing variants and their now-redundant tests.
- The pass fails if the final diff is not net negative.

## Step 7: Lock in

- Add the survivor to the project's agent memory (CLAUDE.md or
  equivalent): "there is exactly one X: `<path>`. Extend it, never
  write a new one."
- Ban the old imports with a lint rule, using the project's own linter.
- Log the responsibilities you found but did not touch as candidates
  for the next pass.

## Hard rules

- One responsibility per pass.
- Read-only until the step 4 question is answered.
- Never delete code the characterization tests do not cover.
- Use the project's own test command, linter, and conventions. Never
  introduce new tooling without asking.

Aan het begin van het wekelijkse blok start ik hem met /consolidation-pass, gericht op één verantwoordelijkheid, en de ronde verloopt elke week hetzelfde, in mijn eigen repos en in geërfde codebases.

Zo zie je dat het werkt

Drie signalen, allemaal gratis. Het aantal clones uit de inventarisatie daalt van ronde tot ronde. Het percentage nieuwe code dat na twee weken nog bestaat stijgt. En het totale aantal regels begint langzaam te dalen terwijl er gewoon features de deur uit gaan, en dat is zo ongeveer het gezondste wat een codebase met een agent erin kan doen.

Er is een stiller vierde signaal: de agent zelf wordt beter. Hij vindt geen vijf formatters meer om uit te kiezen, importeert de verkeerde niet meer, heeft minder pogingen nodig per wijziging. Geconsolideerde code is voor de volgende sessie makkelijker te bewerken, dus elke ronde maakt de gegenereerde code van de volgende sprint een beetje minder fout.

En sneuvelt het vaste blok telkens omdat featuredruk altijd wint, dan heb je ook iets geleerd. Dat is een procesprobleem in een AI-jasje, en geen tooling lost dat voor je op.

De twintig formatters waren het symptoom van een werkwijze zonder consolidatiestap, en elke codebase met een agent erin kweekt op dit moment zijn eigen twintig. Met de opruimronde zet je die stap terug in de werkwijze.

Weer één plek om gelijk te hebben. Twintig formatters terug naar één, de datums terug naar één, in één dag, gewoon in de agenda.

De ronde van volgende week begint met een lege lijst. De opdracht is hem leeg te houden.