Vibe Coder #5.1: Technical Debt and Spaghetti Code – AI's High-Interest Credit Card
Producing code got cheap; consolidating it did not. The interest on technical debt is charged not by the calendar but every time you touch a complex file. A plain PHP debt meter, and a hook that stops new debt in four coding agents.
This post is also available in Turkish →
In the first chapter of this series we signed an IOU: "As we will get into in Chapter 5.1, forgetting the syntax is allowed; forgetting the architecture is how a project meets its end." Seven months on, the bill is due. The previous chapter also closed by sliding an invoice across the table: you can make the slices so small that each one is right on its own and the sum is wrong. Ten small commits, none of them bad, and the architecture has drifted somewhere nobody decided. This chapter gives that drift its name: AI technical debt.
The metaphor is a credit card. Your coding agent handed you a contactless card with no visible limit, and spending has never been easier. The statement, however, looks exactly the way it always did. We look at where the debt comes from, where the interest actually accrues, and what a "card limit" for the agent looks like in four tools. By the end you will have a debt meter that runs against your own repository.
What is technical debt, and who coined it?#
The term has a birth certificate. In his WyCash experience report at OOPSLA in 1992, Ward Cunningham wrote: "Shipping first time code is like going into debt." He defines the interest right after: every minute spent on not-quite-right code counts as interest on that debt.
The part most quotes leave out is that Cunningham was not against borrowing. In the same paragraph he says the early shortcut pays off, as long as it is repaid promptly with a rewrite. The danger is not the debt, it is never paying it back. Let it pile up and it can bring an entire engineering organisation to a standstill.
Martin Fowler added a quadrant in 2009: debt can be deliberate or inadvertent, prudent or reckless. In his words, the useful distinction isn't between debt or non-debt, but between prudent and reckless debt.
Hard truth: with AI, the quadrant that fills up fastest is reckless and inadvertent. With deliberate debt, at least someone says "we'll fix this later" and writes it down. The agent says "done". Nobody knows they borrowed, so nobody writes anything down. The charge went through; the notification never arrived.
How did AI raise the card limit?#
The largest dataset we have on this is GitClear's 2025 code quality report. It covers 211 million changed lines written between January 2020 and December 2024. Two of its numbers map straight onto the card:
- Less code is being moved. Moving a function into a shared place is the footprint of refactoring, which is to say, of paying debt down. Moved lines fell from 25% of changed lines in 2021 to under 10% in 2024.
- More code is being copied. Copy/pasted lines rose from 8.3% to 12.3% over the same period. According to the report, copy/paste overtook moved code for the first time in its data.
Google's DORA team points the same way. Announcing the 2024 report (Nathen Harvey and Derek DeBellis, 22 October 2024), they wrote that as AI adoption increased, it was accompanied by an estimated 1.5% decrease in delivery throughput and an estimated 7.2% reduction in delivery stability.
Technical detail: both studies measure correlation, not cause; GitClear ties the trend to the spread of AI assistants. The data is also from 2024, and we have not yet seen it repeated with 2026's agents. The direction is consistent, though: producing code got cheap, consolidating it did not.
Spaghetti code changed shape too. It used to mean if-else blocks nested five deep, and in the Spaghetti 2.0 post (in Turkish) we looked at the architecture-sized version. AI spaghetti is sneakier: near-copies sitting side by side, similar but not quite identical. The agent writes a new function instead of finding and extending the existing one, because writing a new one is cheaper for the agent. Not for you.
Where does the interest accrue?#
Code debt differs from a credit card in one important way: interest is not charged by the calendar, it is charged every time you swipe. A complex file you never open and never change costs you nothing. A complex file you dig into every week charges you on every visit. That is where Cunningham's "every minute spent on not-quite-right code" gets spent.
The person who turned this into a practice is Adam Tornhill. In Your Code as a Crime Scene (second edition, February 2024) he uses version control history to find hotspots and prioritise technical debt. A rough version of the formula:
- Principal: how complex the file is, and how much duplication it carries.
- Interest rate: how often the file is touched, meaning its commit count.
- What you pay: the two multiplied.
That product changes the answer to "which debt do I pay first?". Not the most complex file, but the most complex file that also changes the most.
Reading the statement: a debt meter#
To stop leaving this to gut feeling, I wrote a small script. No framework, no Composer dependencies, plain PHP 8.3 and git. For every PHP file it counts complexity per function (1 + decision points), commits in the last N days, and six-line windows that also appear somewhere else. It has a second mode as well, which we will wire into the agent shortly.
<?php
declare(strict_types=1);
/**
* Debt meter: counts complexity (principal), change frequency (interest) and
* duplicated blocks for every PHP file; multiplies complexity by change to rank hotspots.
* Report: php debt-meter.php [--days=180] [--top=10]
* Hook: php debt-meter.php --ceiling=10 (exits 2 when a change grows the debt)
* Run it from the repository root.
*/
$options = getopt('', ['days::', 'top::', 'ceiling::']);
$days = (int) ($options['days'] ?? 180);
$top = (int) ($options['top'] ?? 10);
function git(string $command): array
{
exec('git ' . $command . ' 2>/dev/null', $output, $code);
if ($code !== 0) {
fwrite(STDERR, "git $command failed (code $code). Are you at the repository root?\n");
exit(1);
}
return $output;
}
/** Complexity per function: 1 + number of decision points. */
function complexity(string $code): array
{
$decisions = [T_IF, T_ELSEIF, T_FOR, T_FOREACH, T_WHILE, T_CASE, T_CATCH,
T_BOOLEAN_AND, T_BOOLEAN_OR, T_LOGICAL_AND, T_LOGICAL_OR, T_COALESCE];
$result = [];
$name = null;
$depth = 0;
$bodyDepth = null;
$tokens = token_get_all($code);
foreach ($tokens as $i => $t) {
if (is_array($t) && $t[0] === T_FUNCTION) {
if ($bodyDepth !== null) {
continue; // a nested closure counts towards the outer function
}
for ($j = $i + 1; isset($tokens[$j]); $j++) {
if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) {
$name = $tokens[$j][1];
break;
}
if ($tokens[$j] === '(') {
$name = '{closure}';
break;
}
}
continue;
}
if ($t === '{' || (is_array($t) && in_array($t[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true))) {
$depth++;
if ($name !== null && $bodyDepth === null) {
$bodyDepth = $depth;
$result[$name] = ($result[$name] ?? 0) + 1;
}
continue;
}
if ($t === ';' && $bodyDepth === null) {
$name = null; // method without a body (abstract/interface)
continue;
}
if ($t === '}') {
if ($bodyDepth !== null && $depth === $bodyDepth) {
$name = null;
$bodyDepth = null;
}
$depth--;
continue;
}
if ($bodyDepth !== null && ($t === '?' || (is_array($t) && in_array($t[0], $decisions, true)))) {
$result[$name]++;
}
}
return $result;
}
/** Lines with comments and inline HTML removed, so licence headers don't count as copies. */
function codeOnly(string $path): array
{
$code = '';
foreach (token_get_all(file_get_contents($path)) as $t) {
if (is_array($t) && in_array($t[0], [T_COMMENT, T_DOC_COMMENT, T_INLINE_HTML], true)) {
$code .= str_repeat("\n", substr_count($t[1], "\n"));
continue;
}
$code .= is_array($t) ? $t[1] : $t;
}
return explode("\n", $code);
}
// Hook mode: forgives old debt, stops new debt. Looks only at changed files and
// returns 2 if a function over the ceiling is new or more complex than in HEAD.
if (isset($options['ceiling'])) {
$ceiling = (int) $options['ceiling'];
$changed = array_unique(array_merge(
git("diff --name-only HEAD -- '*.php'"),
git("ls-files --others --exclude-standard -- '*.php'")
));
$violations = [];
foreach ($changed as $path) {
if (!is_file($path)) {
continue; // a deleted file only reduces debt
}
exec('git show ' . escapeshellarg("HEAD:$path") . ' 2>/dev/null', $old, $code);
$before = $code === 0 ? complexity(implode("\n", $old)) : [];
unset($old);
foreach (complexity(file_get_contents($path)) as $name => $value) {
if ($value > $ceiling && $value > ($before[$name] ?? 0)) {
$violations[] = sprintf('%s %s(): %d (before %s, ceiling %d)', $path, $name, $value, $before[$name] ?? 'none', $ceiling);
}
}
}
if ($violations !== []) {
fwrite(STDERR, "Debt ceiling exceeded. Split these functions before you continue:\n " . implode("\n ", $violations) . "\n");
exit(2);
}
exit(0);
}
$files = git("ls-files '*.php'");
if ($files === []) {
fwrite(STDERR, "No PHP files in this repository.\n");
exit(1);
}
// Interest: number of commits that touched the file in the last N days.
$changes = array_count_values(array_filter(
git("log --since='$days days ago' --format= --name-only -- '*.php'")
));
// Duplication: does a meaningful 6-line window, without comments or whitespace, appear more than once?
$windows = [];
foreach ($files as $path) {
$clean = array_values(array_filter(
array_map(fn (string $s): string => preg_replace('/\s+/', '', $s), codeOnly($path)),
fn (string $s): bool => strlen($s) > 3 && !in_array($s, ['{', '}', '});', '];'], true)
));
for ($i = 0; $i + 6 <= count($clean); $i++) {
$windows[md5(implode("\n", array_slice($clean, $i, 6)))][] = $path;
}
}
$duplicates = [];
foreach ($windows as $places) {
if (count($places) > 1) {
foreach ($places as $path) {
$duplicates[$path] = ($duplicates[$path] ?? 0) + 1;
}
}
}
$report = [];
foreach ($files as $path) {
$functions = complexity(file_get_contents($path));
$total = array_sum($functions);
$report[$path] = [
'commits' => $changes[$path] ?? 0,
'total' => $total,
'max' => $functions === [] ? 0 : max($functions),
'copies' => $duplicates[$path] ?? 0,
'score' => ($changes[$path] ?? 0) * $total,
];
}
uasort($report, fn (array $a, array $b): int => [$b['score'], $b['total']] <=> [$a['score'], $a['total']]);
printf("Last %d days, %d PHP files\n", $days, count($files));
printf("%-7s %-8s %-9s %-6s %s\n", 'commits', 'complex', 'max fn', 'dupes', 'file');
foreach (array_slice($report, 0, $top, true) as $path => $r) {
printf("%-7d %-8d %-9d %-6d %s\n", $r['commits'], $r['total'], $r['max'], $r['copies'], $path);
}
$interestFree = count(array_filter($report, fn (array $r): bool => $r['commits'] === 0 && $r['total'] > 0));
printf("Complex files untouched in the last %d days: %d (debt, but no interest)\n", $days, $interestFree);
exit(0);
I ran it against two real repositories (25 October 2026, PHP 8.3.6 CLI).
The first is the demo building of the Urban Renewal series, altinkaynak. Ten PHP files. src/Altinkaynak.php tops the list with a total complexity of 26 and carries 21 duplicated windows; those are the three copied methods we found in Urban Renewal #1. A single function in src/Response/Response.php, decorateResponse(), scores 14 on its own. But the commit column reads zero on every row for the last 180 days; the repository's entire history is 20 commits on 6 September 2020. The script's last line: "Complex files untouched in the last 180 days: 6 (debt, but no interest)."
That also explains why the Urban Renewal series started by reading, not writing. The debt has been sitting there for six years without charging anyone a cent. The day we start touching those files, the interest starts too.
The second is a living library: PHPMailer (LGPL-2.1, latest release tag v7.1.1 on 18 May 2026; lead maintainer Marcus Bointon). 202 commits in the last 365 days, 131 PHP files.
$ php debt-meter.php --days=365 --top=3
Last 365 days, 131 PHP files
commits complex max fn dupes file
31 718 38 98 src/PHPMailer.php
11 177 33 12 src/SMTP.php
7 34 6 0 src/POP3.php
Complex files untouched in the last 365 days: 12 (debt, but no interest)
src/PHPMailer.php is 5,603 lines and 129 functions; the most complex is preSend() at 38. In src/SMTP.php, authenticate() scores 33. Reality check: this table is not a quality verdict on PHPMailer. It is no surprise that the central class of a library that has been around for years is both its most complex and its most frequently changed file. All the table says is this: if you had one hour of refactoring time in this repository, you would know where it goes. The twelve complex files nobody touched in a year can wait.
Technical detail: the numbers are approximate. Complexity is a token count (if, loops, case, catch, &&, ||, ?? and ?); arrow functions (fn) are not counted as separate functions and match arms are not counted. Duplication looks for identical six-line windows after stripping comments and inline HTML, so it will not catch the agent's "almost identical" copies. The score is a raw product with no normalisation across files. For finer measurement there are tools like PHPStan and PHPMD, or CodeScene, which Tornhill founded and where he is CTO. This script is for reading the statement, not for doing the accounts.
A card limit for the agent: hooks in four tools#
In chapter 4.2 we drew a line: a rules file asks, a hook enforces. Writing "don't make functions more complex" in AGENTS.md is a wish. Measuring the agent after every edit turns the wish into a rule.
That is what the script's --ceiling mode is for. It deliberately forgives old debt: it only looks at changed files, and it exits with 2 only when a function is over the ceiling and either new or more complex than it is in HEAD. An agent touching PHPMailer's preSend() to fix a typo is not stopped; an agent adding three more branches to it is. You don't have to pay off the existing balance today. You just stop taking out new cards.
I tried four scenarios on a throwaway copy of altinkaynak (--ceiling=10): a clean tree returned 0; a whitespace-only change in Response.php returned 0 (the debt is 14, but it didn't grow); adding two conditions to decorateResponse() returned 2 with this message:
Debt ceiling exceeded. Split these functions before you continue:
src/Response/Response.php decorateResponse(): 16 (before 14, ceiling 10)
A new file with a complexity of 11 also returned 2 ("before none"). Outside a git repository it returns 1.
Now let's wire it to the agents. All four tools have hooks, but the way the agent sees the result differs:
- Claude Code: a
PostToolUsehook in.claude/settings.jsonwith the matcher"Edit|Write". According to the docs this hook cannot block, since the edit has already happened, but if it exits with 2 its stderr is shown to Claude. The agent reads the message and goes back to split the function. - Codex:
.codex/hooks.jsonor a[hooks]table inconfig.toml. The hooks docs sayPostToolUsealso covers the file-editingapply_patchtool, and on exit 2 the stderr goes back to the model as feedback. Hooks are on by default, but project-local hooks only load when the project's.codex/layer is trusted. - Cursor:
.cursor/hooks.json("version": 1). There is a trap here: the docs describe theafterFileEdithook for things like formatters, and it returns nothing to the agent. To get through to the agent you need thestophook: itsfollowup_messageis submitted as the next user message, with a default limit of five loops (loop_limit). - Aider:
--test-cmdwith--auto-testruns the command after every AI edit; according to the docs Aider tries to fix the errors when it exits non-zero.
The Claude Code setting looks like this:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "cd \"$CLAUDE_PROJECT_DIR\" && php debt-meter.php --ceiling=10"
}
]
}
]
}
}
On the Aider side it is one line:
aider --test-cmd "php debt-meter.php --ceiling=10" --auto-test
Hard truth: I did not run any of these hooks inside the agents for this post; the script and its four scenarios are real, the agents' reactions to the hook are not tested. There is also a sneakier limit: an agent that hits the ceiling will find the cheapest way to lower the number. It will chop a forty-line function into four meaningless pieces and slide under the ceiling. The number drops, the readability stays the same. The hook tells you "debt is growing here"; whether it was actually paid down is still a human call.
When does it help, and when doesn't it?#
It helps in a codebase that grows fast with an agent, to see where the debt is piling up; when you have limited refactoring time and need to decide where it goes; and when you need to back up "we should spend a week cleaning this module" with a table. The hook mode pays off most on jobs where you leave the agent unattended.
It doesn't help in a young repository with no history: the interest column stays empty and, as with altinkaynak, everything looks "untouched". A number is not a verdict on its own either: a complex money calculation may be exactly as complex as it needs to be. And a ceiling invites the agent to optimise for the number instead of the code.
Measuring debt is not paying it. The first step in paying it is being able to touch indebted code without changing what it does, and that takes tests first. The next chapter, Vibe Coder #5.2 on AI-TDD, looks at the limits of writing the tests yourself and letting the agent write the code. How debt gets paid down step by step in an old building is what we follow in the Urban Renewal series, on altinkaynak. The full series and the list of published chapters live on the Vibe Coder's Handbook page (in Turkish).
AI gave you a card with no visible limit. Reading the statement is still your job. Stay with technology.
Comments
0 comments
Leave a comment