DEV Community

Dakota Huang
Dakota Huang

Posted on

Refactor One Exit at a Time: The Exit-Path Ledger for Messy Functions

A messy function is not one refactor target. It is a set of exits. Each return, raise, break, continue, or yield is a contract point. Pin those points before you move code. Then make one small change that preserves every pinned exit.

Most legacy refactors fail at the same place. The team writes one broad golden test. That test covers the happy path. It leaves the error path, the skip path, and the early return unpinned. A patch then changes one of those exits. The broad test still passes. The regression ships.

This walkthrough uses a synthetic Python module. It is small enough to reproduce in a scratch repo. The method is the point, not the sample code.

The rule: one exit, one test

Start with an inventory, not a rewrite. You need a complete list of control-flow exits in the target function. Do not trust memory or IDE folding. Use a parser.

The script below lists exits for one named function. It skips nested functions and classes. It treats return, raise, yield, yield from, break, and continue as exits. Save it as exit_ledger.py.

import ast
import sys
from pathlib import Path

class ExitVisitor(ast.NodeVisitor):
    def __init__(self):
        self.exits = []

    def visit_FunctionDef(self, node):
        return

    visit_AsyncFunctionDef = visit_FunctionDef
    visit_ClassDef = visit_FunctionDef

    def visit_Return(self, node):
        self.exits.append((node.lineno, 'Return'))

    def visit_Raise(self, node):
        self.exits.append((node.lineno, 'Raise'))

    def visit_Yield(self, node):
        self.exits.append((node.lineno, 'Yield'))

    def visit_YieldFrom(self, node):
        self.exits.append((node.lineno, 'YieldFrom'))

    def visit_Break(self, node):
        self.exits.append((node.lineno, 'Break'))

    def visit_Continue(self, node):
        self.exits.append((node.lineno, 'Continue'))

def find_function(tree, name):
    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.name == name:
                return node
    raise SystemExit(f'function {name!r} not found')

path = Path(sys.argv[1])
name = sys.argv[2]
tree = ast.parse(path.read_text())
target = find_function(tree, name)
visitor = ExitVisitor()
for stmt in target.body:
    visitor.visit(stmt)
for i, (line, kind) in enumerate(sorted(visitor.exits), 1):
    print(f'E{i:02d} line {line} {kind}')
Enter fullscreen mode Exit fullscreen mode

Run it against the file and function name:

python exit_ledger.py legacy_settle.py settle
Enter fullscreen mode Exit fullscreen mode

The output is a ledger. It is not a test plan yet. It is the list of contracts you must cover.

Step 1: Build a small messy target

Use a disposable module named legacy_settle.py. The sample has six exits. It mixes validation, a loop skip, a VIP branch, and a status return.

def settle(order, now):
    if order.get('cancelled'):
        return {'status': 'cancelled', 'amount': 0}

    total = order['subtotal'] + order['tax']
    if total < 0:
        raise ValueError('negative total')

    if order.get('coupon') == 'EXPIRED':
        return {'status': 'review', 'amount': total}

    for item in order.get('items', []):
        if item['qty'] <= 0:
            continue
        total += item['price'] * item['qty']

    if order.get('vip'):
        total = max(0, total - 5)
        return {'status': 'vip', 'amount': total}

    return {'status': 'ok', 'amount': max(0, total)}
Enter fullscreen mode Exit fullscreen mode

The now argument is unused. That is intentional. Legacy code often carries dead parameters. Do not clean that up in the first change.

The ledger output should look like this:

Exit Kind Trigger Observable result
E01 Return cancelled order status cancelled, amount 0
E02 Raise negative total ValueError
E03 Return expired coupon status review
E04 Continue zero quantity item item skipped
E05 Return VIP order status vip, amount floor 0
E06 Return plain order status ok, amount floor 0

If your parser output has a different count, stop. Fix the inventory before writing tests.

Step 2: Write one characterization test per exit

Characterization tests record current behavior. They do not judge it. They are not a spec for the future. They are a tripwire for the next diff.

Create tests/test_settle_characterization.py:

import pytest
from legacy_settle import settle

NOW = '2026-11-08'

def test_E01_cancelled_order():
    assert settle({'cancelled': True}, NOW) == {
        'status': 'cancelled',
        'amount': 0,
    }

def test_E02_negative_total_raises():
    with pytest.raises(ValueError, match='negative total'):
        settle({'subtotal': -10, 'tax': 1}, NOW)

def test_E03_expired_coupon():
    assert settle({'subtotal': 10, 'tax': 0, 'coupon': 'EXPIRED'}, NOW) == {
        'status': 'review',
        'amount': 10,
    }

def test_E04_zero_qty_item_is_skipped():
    order = {
        'subtotal': 0,
        'tax': 0,
        'items': [
            {'qty': 0, 'price': 99},
            {'qty': 1, 'price': 7},
        ],
    }
    assert settle(order, NOW) == {'status': 'ok', 'amount': 7}

def test_E05_vip_amount_floors_at_zero():
    order = {'subtotal': 2, 'tax': 0, 'vip': True}
    assert settle(order, NOW) == {'status': 'vip', 'amount': 0}

def test_E06_plain_order():
    order = {'subtotal': 10, 'tax': 2}
    assert settle(order, NOW) == {'status': 'ok', 'amount': 12}
Enter fullscreen mode Exit fullscreen mode

Run the full table:

pytest -q tests/test_settle_characterization.py
Enter fullscreen mode Exit fullscreen mode

Then run each exit alone. This catches tests that only pass because another test set up state.

pytest -q tests/test_settle_characterization.py -k E04
Enter fullscreen mode Exit fullscreen mode

A test that fails alone is not a characterization test. It is a shared-state dependency. Fix that before refactoring.

Step 3: Use a model for draft coverage, not approval

Free model access is useful for one narrow task. Ask for candidate inputs per exit. Do not ask for a refactor plan.

A prompt that works:

Here is a Python function. List every return, raise, break, continue, and yield.
For each exit, give one input that reaches it.
Do not suggest code changes. Mark any exit you cannot reach.
Enter fullscreen mode Exit fullscreen mode

MonkeyCode's free model access can draft that table. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option is another place to run the harness. Verify current limits before you depend on it. Keep secrets out of the server copy.

The model output is a draft. The AST output is the checklist. If the model misses E04, the ledger wins. If the model invents an exit, delete it.

Step 4: Make the smallest safe change

Pick one exit body. Extract only that body. Do not extract the whole function. Do not rename parameters. Do not reorder branches.

Example: extract the VIP amount calculation.

def _vip_amount(total):
    return max(0, total - 5)

def settle(order, now):
    if order.get('cancelled'):
        return {'status': 'cancelled', 'amount': 0}

    total = order['subtotal'] + order['tax']
    if total < 0:
        raise ValueError('negative total')

    if order.get('coupon') == 'EXPIRED':
        return {'status': 'review', 'amount': total}

    for item in order.get('items', []):
        if item['qty'] <= 0:
            continue
        total += item['price'] * item['qty']

    if order.get('vip'):
        return {'status': 'vip', 'amount': _vip_amount(total)}

    return {'status': 'ok', 'amount': max(0, total)}
Enter fullscreen mode Exit fullscreen mode

Re-run the same table:

pytest -q tests/test_settle_characterization.py
Enter fullscreen mode Exit fullscreen mode

Check the diff size:

git diff --numstat
Enter fullscreen mode Exit fullscreen mode

For this change, one production file should show a small addition. The test file should not change. If the diff touches the loop, the coupon branch, and the VIP branch, revert. You exceeded the smallest safe change.

Step 5: Stop at one exit

The temptation is to keep going. Do not. Commit the helper and the pinned tests. Then run the ledger again. Extract the next exit in a separate commit.

This keeps the review small. It also keeps the failure local. If E05 breaks, you know which commit did it. If E06 breaks after an E05-only change, the helper leaked state or changed evaluation order.

Use a simple gate before each commit:

python exit_ledger.py legacy_settle.py settle
pytest -q tests/test_settle_characterization.py
git diff --numstat
Enter fullscreen mode Exit fullscreen mode

The first command checks that the exit list did not change. The second checks the pinned behavior. The third checks that the diff stayed small.

Limitations and who should not use this

This method is for behavior-preserving refactors. It is not for fixing undefined behavior. If the current output is wrong and you need a new contract, write new tests. Do not call them characterization tests.

Do not use this when the legacy runtime is unavailable. A pinned test you cannot run is documentation, not evidence. Do not use it for a full rewrite. A rewrite needs a different migration plan.

Do not use this if the function has side effects you cannot observe. Database writes, network calls, and global mutations need a harness that records those effects. The exit ledger alone will miss them.

Finally, do not let a model approve the ledger. Models are good at drafting inputs. They are bad at proving coverage. The parser, the tests, and the diff are your evidence.

The refactor is safe when every exit has a test. The change is small when the diff touches one exit body. Start there.

Top comments (0)