DEV Community

Dakota Huang
Dakota Huang

Posted on

Fan-In Is the Refactor Safety Signal You're Ignoring

Fan-In Is the Refactor Safety Signal You're Ignoring

Most legacy refactors start in the wrong place. Teams write characterization tests for the easiest functions first. That order is backwards.

The safest refactor starts at the seam with the most callers. Fan-in tells you which seam that is. Fan-in is the number of call sites that reference a function. High fan-in means high blast radius. High fan-in plus high complexity means high risk.

This article gives you a small AST script that ranks every function in your repo. You write characterization tests in ranked order. You then make the smallest safe change.

Why random test order fails

A characterization test locks in current behavior. It does not judge whether that behavior is good. Its only job is to catch accidental change.

If you test a leaf function first, you protect a small behavior. The real refactor risk sits in the hub functions. Those are the functions everyone calls. Leaf-first testing feels productive. It rarely prevents the regression that matters.

Characterization tests are cheap to write and expensive to maintain. Every one you write becomes a permanent guard. So place them where they earn their keep.

I have seen suites that never caught a bug. They protected the wrong layer. The ranking below fixes the order, not the test quality.

The artifact: seam_rank.py

The script uses only Python's ast module. No dependencies. No execution of your code. It walks every .py file and computes four numbers per function:

  • fan_in: how many call sites reference the function
  • fan_out: how many calls the function makes
  • complexity: branch count (if, for, while, try, boolean ops)
  • lines: function size

Then it scores each function. The score is a heuristic, not a benchmark.

#!/usr/bin/env python3
# seam_rank.py — rank functions by refactor risk using only the AST.
import ast
import sys
from collections import defaultdict
from pathlib import Path


def analyze(path):
    tree = ast.parse(path.read_text(), filename=str(path))
    calls = defaultdict(int)
    definitions = {}

    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            name = None
            if isinstance(node.func, ast.Name):
                name = node.func.id
            elif isinstance(node.func, ast.Attribute):
                name = node.func.attr
            if name:
                calls[name] += 1

        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            definitions[node.name] = (node, path)

    return calls, definitions


def complexity(node):
    score = 1
    for child in ast.walk(node):
        if isinstance(child, (ast.If, ast.For, ast.While, ast.Try,
                              ast.With, ast.BoolOp, ast.ExceptHandler)):
            score += 1
    return score


def main(root):
    calls = defaultdict(int)
    defs = {}

    for path in Path(root).rglob('*.py'):
        try:
            c, d = analyze(path)
        except SyntaxError:
            continue
        for name, count in c.items():
            calls[name] += count
        defs.update(d)

    rows = []
    for name, (node, path) in defs.items():
        fan_in = calls.get(name, 0)
        fan_out = sum(1 for child in ast.walk(node)
                      if isinstance(child, ast.Call))
        lines = (node.end_lineno or node.lineno) - node.lineno + 1
        cx = complexity(node)
        score = fan_in * 2 + cx + (1 if fan_out > 5 else 0)
        rows.append((score, fan_in, fan_out, lines, cx, name, path))

    rows.sort(reverse=True)
    header = '{:>5} {:>6} {:>7} {:>5} {:>3}  function:file'.format(
        'score', 'fan_in', 'fan_out', 'lines', 'cx')
    print(header)
    for score, fan_in, fan_out, lines, cx, name, path in rows:
        print('{:5d} {:6d} {:7d} {:5d} {:3d}  {}:{}'.format(
            score, fan_in, fan_out, lines, cx, name, path))


if __name__ == '__main__':
    main(sys.argv[1] if len(sys.argv) > 1 else '.')
Enter fullscreen mode Exit fullscreen mode

Run it from the repo root:

python seam_rank.py src/
Enter fullscreen mode Exit fullscreen mode

Sample output (illustrative, not from a real repo):

score fan_in fan_out lines cx  function:file
   24      7       9    42  9  apply_discount:src/pricing.py
   17      5       3    18  7  compute_tax:src/pricing.py
   11      4       2     9  3  format_row:src/report.py
    2      0       1     4  2  helper:src/util.py
Enter fullscreen mode Exit fullscreen mode

Reading the score

The score weights fan-in double. One caller is one potential regression. Two callers are two. Complexity adds one point per branch. A branch is a decision that can flip. The fan_out bonus marks functions that reach deep into the system.

The formula is deliberately simple. You can tune the weights to your repo. The point is the ordering, not the absolute number.

The decision table

Classify each function into a tier.

Tier Condition Action
A fan_in >= 3 and cx >= 5 Write characterization tests first
B fan_in 1-2 or cx 3-4 Write one probe, then refactor
C fan_in == 0 and cx <= 2 Refactor directly

Tier A is your blast radius. These functions guard the behavior that callers depend on. Tier C is invisible. Nobody calls it, and it has almost no branches. Refactor it without ceremony.

A worked example

Say apply_discount scores first. It has seven callers and nine branches. One caller is a web endpoint. One is a batch job. One is an invoice printer.

Write one characterization test per caller path. Feed it the current inputs. Record the current outputs. The test now guards seven code paths at once.

Then refactor. Extract the discount matrix into a separate module. Re-run the suite. If the suite passes, behavior is identical. If it fails, the failure names the exact caller that changed.

The workflow

  1. Run seam_rank.py and read the top ten rows.
  2. Write characterization tests for Tier A functions only. Assert current behavior, not desired behavior.
  3. Draft the probes mechanically. The signatures are known. The assertions come from real runs. A free model can turn this into test skeletons fast. MonkeyCode's free model access covers this step without a paid tier. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option can run the whole loop — analysis, tests, refactor — without standing up paid infrastructure.
  4. Mutation-check the probes. Dead probes pass silently and protect nothing.
  5. Refactor the smallest safe change. One rename, one extraction, one move. Re-run the suite after every change. Keep a diff budget: no change should touch more than one function.

Verify with the suite after each step. The command is boring on purpose:

python -m pytest tests/ -q
Enter fullscreen mode Exit fullscreen mode

Why this order works

The suite is a tripwire, not a safety net. A tripwire only works when it sits in the path of the change. Ranking puts the tripwire where the change actually lands.

Fan-in is the signal because callers are the risk. A function with seven callers can break seven code paths at once. A function with zero callers can break nothing.

Small changes stay safe when you know the blast radius. The rank gives you that number before you edit.

Limitations

The script is static. It does not see dynamic dispatch, decorators, or __getattr__ imports. It counts call sites by name. Same-named functions in different files merge into one row. It is a triage tool, not a proof.

Do not use this approach on a well-tested repo. The ranking exists to fight unknown behavior. It adds ceremony to code you already understand.

Who should use it: teams facing a messy repo, no test suite, and a deadline. Run the script. Test the top seams. Refactor in small steps.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

Your approach to identifying high-risk refactoring targets through fan-in analysis is a game changer for maintaining legacy code. Using an AST script to automate this ranking not only streamlines the process but also ensures that we focus on the most critical areas first, which can significantly reduce regression risks. One improvement I’d suggest is incorporating a way to visualize the results, perhaps integrating with tools like Graphviz, to help teams better understand the complexity of their codebase. If you’re looking for help with enhancing this script or any related project, I’d be glad to discuss a paid collaboration! How do you envision teams implementing this into their current workflows?