Workflow Architecture

Finding Technical Debt: Parse for Candidates, Then Judge Them

David Guzenburg/ / 8 min read

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.

technical debtASTmetricscode review

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.

Churn is the multiplier

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.

DebtWhy metrics miss itWhat finds it
Wrong abstractionClean, short, well-factored — and wrongPeople who have fought it
Missing testsCoverage tools, not complexityDiff coverage on changed lines
Dependency riskNot in your code at allAudit tooling, EOL dates
Operational debtRunbooks and alerts, not sourceIncident retrospectives
Knowledge concentrationThe code is fine; one person understands itgit 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.

Takeaway

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.

Keep reading
Workflow Architecture

Measuring AI Impact: Ask the Questions That Have Answers

Why 'are agents worth it' is not empirically tractable, the narrow comparisons that are, reusing delivery metrics that predate the tooling, and the three ways this measurement goes wrong.

Workflow Architecture

Preventing Architectural Drift in a Codebase Agents Contribute To

Why higher change volume accelerates drift, the five forms it takes, making canonical utilities findable, and a quarterly structural snapshot that catches what rules miss.

Workflow Architecture

Structural Refactoring: Have the Agent Write the Transformation, Not the Edits

Why a tree-sitter query plus a deterministic apply script beats an agent editing four hundred files, the four-stage workflow, and the syntactic limits to verify around.

Workflow Architecture

CI Gating for Agent-Generated Pull Requests

Why agents pass the traditional gate more easily than humans, seven checks worth adding, diff coverage over total coverage, and why model-graded review should never be the gate.

← Java to Kotlin: The Converter Does Syntax, the Agent Does Semantics  ·  Measuring AI Impact: Ask the Questions That Have Answers →

All workflow architecture articles  ·  Every article