DEV Community

Dakota Huang
Dakota Huang

Posted on

Score the Tangle First: Pick Your Refactor Seam by Churn and Fan-In

The refactor decision you skip

Most messy-repo refactors fail at target selection. The scariest-looking function gets picked by feel. Three days later the pull request touches forty files.

Fan-in and churn decide how expensive a change really is. Neither is visible by reading the code. Both are measurable in minutes.

Conclusion first: rank candidate functions by churn × fan-in before writing a single test. Extract the highest-scoring seam, not the ugliest function.

1. Build the blast-radius scan

The script below is stdlib-only, makes no network calls, and never edits your files. It answers one question: which functions are both heavily referenced and heavily rewritten?

#!/usr/bin/env python3
"""blast_radius.py - rank Python functions by refactor risk. Stdlib only.

Usage:
    python blast_radius.py . --since="180 days ago" --top=15
"""
import argparse, ast, collections, math, pathlib, re, subprocess, sys

SKIP = {".git", ".venv", "venv", "node_modules", "build", "dist", "__pycache__"}


def python_files(root: pathlib.Path):
    for p in root.rglob("*.py"):
        if any(part in SKIP for part in p.parts):
            continue
        yield p


def read(p: pathlib.Path):
    try:
        return p.read_text(encoding="utf-8")
    except (UnicodeDecodeError, OSError):
        return None


def churn(root: pathlib.Path, since: str):
    """Lines added + deleted per file, from git log --numstat."""
    out = subprocess.run(
        ["git", "log", f"--since={since}", "--numstat", "--pretty=tformat:"],
        cwd=root, capture_output=True, text=True,
    )
    counts = collections.Counter()
    for line in out.stdout.splitlines():
        parts = line.split("\t")
        if len(parts) != 3:
            continue
        added, deleted, path = parts
        if not added.isdigit() or not deleted.isdigit():
            continue  # binary blobs and rename edge cases
        counts[path] += int(added) + int(deleted)
    return counts


def reference_counts(root: pathlib.Path):
    """Name/attribute occurrences repo-wide. A crude fan-in proxy."""
    refs = collections.Counter()
    for path in python_files(root):
        src = read(path)
        if src is None:
            continue
        try:
            tree = ast.parse(src)
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if isinstance(node, ast.Name):
                refs[node.id] += 1
            elif isinstance(node, ast.Attribute):
                refs[node.attr] += 1
    return refs


def test_corpus(root: pathlib.Path):
    chunks = []
    for path in python_files(root):
        if "test" in path.name or "tests" in path.parts:
            src = read(path)
            if src:
                chunks.append(src)
    return "\n".join(chunks)


def definitions(root: pathlib.Path):
    for path in python_files(root):
        if "test" in path.name:
            continue
        src = read(path)
        if src is None:
            continue
        try:
            tree = ast.parse(src)
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                yield path, node


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("root", nargs="?", default=".")
    ap.add_argument("--since", default="180 days ago")
    ap.add_argument("--top", type=int, default=15)
    args = ap.parse_args()

    root = pathlib.Path(args.root).resolve()
    churn_by_file = churn(root, args.since)
    refs = reference_counts(root)
    tests = test_corpus(root)

    rows = []
    for path, node in definitions(root):
        name = node.name
        if name.startswith("_"):
            continue
        rel = str(path.relative_to(root))
        line = (node.end_lineno or node.lineno) - node.lineno + 1
        fanin = max(refs[name] - 1, 0)          # drop the definition itself
        file_churn = churn_by_file.get(rel, 0)
        tested = re.search(rf"\b{re.escape(name)}\b", tests) is not None
        score = (math.log1p(file_churn) * math.log1p(fanin)
                 * (1.0 if tested else 2.0) * math.log1p(line))
        rows.append((round(score, 2), name, rel, node.lineno,
                     fanin, file_churn, line, tested))

    rows.sort(reverse=True)
    header = (f"{'score':>6}  {'function':<28} {'file':<34} {'line':>5} "
              f"{'fanin':>5} {'churn':>5} {'loc':>4} {'tested':>6}")
    print(header)
    for r in rows[:args.top]:
        print(f"{r[0]:>6}  {r[1]:<28} {r[2]:<34} {r[3]:>5} "
              f"{r[4]:>5} {r[5]:>5} {r[6]:>4} {str(r[7]):>6}")


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

The building blocks are documented in Python's ast module and git log --numstat. Nothing here is exotic.

2. Read the table, not the code

The score is a ranking, not a verdict. Feed it into a triage rule you decide once, in advance.

Rank band Fan-in Churn (180d) Mentioned in tests Move
Top 3 ≥ 8 ≥ 200 no Treat as load-bearing. Lock behavior before extracting anything.
Top 8 ≥ 5 50–200 yes Best seam candidate. One extraction, one commit.
Any ≤ 2 < 50 any Low risk, low payoff. Defer.
Any 0 any no Likely dead code. Delete it instead of refactoring it.

Row one of that table is the trap. High fan-in plus zero test references means a silent regression has nowhere to show up.

3. The smallest safe change, in order

  1. Run the scan and paste the top ten rows into the issue.
  2. Pick exactly one row from the "best seam candidate" band. Not two.
  3. Write the smallest behavior lock you can justify. A caller-count assertion is enough.
  4. Extract one unit: one function, one parameter object, one module boundary.
  5. Run the scan again. Fan-in for the extracted name should drop, not move sideways.
  6. Stop. Commit. Open the next pull request tomorrow.

Step 5 is the measurement most teams skip. If fan-in stays flat after an extraction, the boundary leaked.

4. Where free model access and a free server fit

A language model is good at producing the mechanical diff once the seam is chosen. It is bad at choosing the seam. Churn and fan-in are historical facts, not stylistic judgments, so keep that decision in the script.

The sane split looks like this. Use the model to draft the extraction diff for the one function you already selected, then diff that draft against your behavior lock. Use the free server option to run blast_radius.py on a schedule, so the ranking is a standing artifact instead of a one-off.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode states that it offers free model access and a free server option; confirm current limits on the project page before planning around them. Treat the allowance as an operator-supplied claim, not a verified benchmark. Do not assume it stays constant forever.

Two honest constraints. First, if your repository cannot leave your network, none of this applies. Second, a model-drafted diff still needs a reviewer who understands the seam. The script narrows attention; it does not replace judgment.

5. Limits, and who should skip this

  • Fan-in here counts AST name and attribute references. Dynamic dispatch, getattr, decorators, and dependency-injection containers all distort it.
  • The churn window is a rolling 180 days by default. A fresh repo or one giant import commit will produce nonsense.
  • Generated code and vendored directories inflate the numbers. Extend SKIP before you trust the output.
  • Nested functions are counted once per enclosing scope. Treat the loc column as approximate.
  • Nested test helpers can mark a name as "tested" when nothing asserts on it.

Skip this approach entirely if the codebase is under roughly five thousand lines. Reading it directly is faster. Skip it too if the repo has no runnable test command yet, because the behavior lock in step 3 has nothing to attach to. Fix the test runner first.

Try it on one repository

The scan takes one command and changes no files. Run it, then look at your top three rows and ask whether that matches what you would have picked by hand. If the answer is no, the ranking just saved you a week.

If you want a starting point for the model side of the split, the free tier is enough to draft a single extraction diff and compare it against your lock.

Top comments (0)