Finding Technical Debt: Parse for Candidates, Then Judge Them
A 300-line function nobody has touched in three years is not urgent debt. Ranking by complexity alone points teams squarely at the code they should leave alone.
Two halves of a problem neither tool solves alone
Static analysis finds structural facts — a function of 400 lines, a cyclomatic complexity of 38, a module imported by 60 others, a dependency three major versions behind. It finds them exhaustively and cheaply, and it has no opinion about which matter.
A model has opinions and no exhaustiveness. Point it at a codebase and it comments on whatever it happened to read.
The combination is the useful thing: parse for candidates, then judge the candidates. Neither ordering works in reverse — a model asked to find technical debt across a large repository produces an arbitrary sample.
Stage one: measure without judgement
import ast, pathlib, subprocess, json
def churn(path: str, since="12 months ago") -> int:
out = subprocess.run(
["git", "log", "--since", since, "--oneline", "--", path],
capture_output=True, text=True).stdout
return len(out.strip().splitlines())
def complexity(node) -> int:
branches = (ast.If, ast.For, ast.While, ast.ExceptHandler,
ast.With, ast.Assert, ast.BoolOp)
return 1 + sum(isinstance(n, branches) for n in ast.walk(node))
rows = []
for p in pathlib.Path("src").rglob("*.py"):
tree = ast.parse(p.read_text(encoding="utf-8"))
file_churn = churn(str(p))
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
lines = (node.end_lineno or node.lineno) - node.lineno
cx = complexity(node)
if lines < 40 and cx < 10:
continue # not a candidate
rows.append({
"file": str(p), "func": node.name, "line": node.lineno,
"lines": lines, "complexity": cx, "churn": file_churn,
# complexity alone ranks stable code you never touch
"priority": cx * max(file_churn, 1),
})
rows.sort(key=lambda r: -r["priority"])
print(json.dumps(rows[:40], indent=1))
The priority line is the idea worth taking. A 300-line function
nobody has edited in three years is not urgent debt — it is stable code
that happens to be ugly. A 90-line function with high complexity that changed
twelve times this year is where bugs come from and where the next change will
hurt.
Complexity tells you how hard something is to change. Churn tells you how often you have to. Debt is the product, and ranking by complexity alone consistently points teams at code they should leave alone.
Stage two: judgement, one candidate at a time
Feed the top candidates in individually, with a schema that forces a conclusion rather than an essay.
Here is one function flagged by static analysis:
file, line, length, complexity, times changed in 12 months.
Read it and the tests covering it. Answer only:
1. Is this genuinely a problem, or is the complexity essential?
Some domains are irreducibly branchy. Say so if that is the case.
2. If it is a problem, what KIND: too many responsibilities,
missing abstraction, defensive code for a case that cannot
happen, dead branches, or duplicated logic?
3. What is the smallest first step? Not a rewrite — one extraction.
4. What would break? Name the callers and the tests.
Do not change any code.
Question one is the one that earns the model's place. Static analysis cannot distinguish accidental complexity from a state machine that genuinely has fourteen states. A tax calculation with forty branches is not debt; it is tax law. Roughly a third of high-complexity candidates come back as essential, and that filtering is most of the value.
Correlate with where bugs actually land
The strongest signal is not in the code at all. It is in your issue tracker.
# Files touched by commits that reference a bug ticket
git log --since="18 months ago" --name-only --pretty=format:"%s" \
| awk '/^(fix|bug)/{flag=1;next} /^$/{flag=0} flag' \
| sort | uniq -c | sort -rn | head -20
A module appearing in thirty bug-fix commits is telling you something no complexity metric will. Cross-reference that list with the static analysis ranking, and the overlap is your actual debt register — empirically expensive code rather than theoretically messy code.
Output that someone can act on
A report of forty problems gets read once and archived. Three with an owner and a first step get done.
# Debt register — generated 2026-06-02
## 1. `OrderService.process` — src/orders/service.py:88
Complexity 41 · 212 lines · 18 changes in 12 months · 9 bug commits
Kind: too many responsibilities. Validation, pricing, inventory
reservation and notification in one method.
First step: extract `_reserve_inventory` (lines 140–171). It has one
input and one output and no other coupling.
Breaks: nothing. Two callers, both in the same module. Covered by
`test_order_processing.py::test_reserves_stock`.
Owner: @orders-team Estimate: half a day
## 2. ...
Every field there is designed to remove an excuse. The first step is named and small. The blast radius is known. The tests that cover it are named. There is an owner and an estimate. That is the difference between a register and a list of complaints.
Debt the parser cannot see
Structural metrics miss whole categories, and it is worth naming them so nobody mistakes the register for a complete picture.
| Debt | Why metrics miss it | What finds it |
|---|---|---|
| Wrong abstraction | Clean, short, well-factored — and wrong | People who have fought it |
| Missing tests | Coverage tools, not complexity | Diff coverage on changed lines |
| Dependency risk | Not in your code at all | Audit tooling, EOL dates |
| Operational debt | Runbooks and alerts, not source | Incident retrospectives |
| Knowledge concentration | The code is fine; one person understands it | git log author distribution |
The last row is measurable and rarely measured. A module where 90% of commits come from one author is a risk that no complexity score reflects, and it is a one-line query.
for d in src/*/; do
n=$(git log --since="2 years ago" --pretty=%an -- "$d" | sort -u | wc -l)
printf '%-30s %s distinct authors
' "$d" "$n"
done | sort -k2 -n
Running it as a habit
Quarterly is about right. More often and nothing has changed; less often and the register is a surprise nobody planned for.
- Re-run the scan and diff against last quarter. A function whose priority score doubled is the interesting entry, more than one that has been high forever.
- Track whether last quarter's top three were addressed. If not, the register is not being used and the process should stop rather than continue theatrically.
- Watch for new entries near the top. Something that appeared from nowhere is usually a module under active development that is accreting complexity as it goes — the cheapest possible moment to intervene.
Parse for candidates, then judge them — a model asked to find debt across a repository samples arbitrarily. Rank by complexity multiplied by churn, because stable ugly code is not urgent. Let the model rule out essential complexity, cross-reference with modules that actually produce bugs, and output three entries with an owner and a named first step rather than forty observations.