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())
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
- Run the scan and paste the top ten rows into the issue.
- Pick exactly one row from the "best seam candidate" band. Not two.
- Write the smallest behavior lock you can justify. A caller-count assertion is enough.
- Extract one unit: one function, one parameter object, one module boundary.
- Run the scan again. Fan-in for the extracted name should drop, not move sideways.
- 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
SKIPbefore you trust the output. - Nested functions are counted once per enclosing scope. Treat the
loccolumn 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)