DEV Community

Dakota Huang
Dakota Huang

Posted on

Freeze the Call Trace Before You Split a God File

A messy module is not a rewrite target. Treat it as a recording target first. Capture call order under a checked-in fixture set.

Splitting a god file without a trace is a guess. Helpers move and call order drifts. Callers still compile and still misbehave.

This workflow records an event trace first. Then it permits one leaf change. The gate is an identical trace on the same fixtures.

The failure mode

God files accumulate helpers with implicit order. Later extracts shuffle that order. Tests that assert only return values miss the shuffle.

A return-equal refactor can still break logging. It can skip a cache write. It can invert two mutations.

Those bugs show up as event-order drift. They do not show up as type errors. Compile-clean diffs are not behavior-clean diffs.

What to freeze

Freeze four fields per event, nothing more.

  1. Event kind: enter, exit, or exception.
  2. Qualified name of the Python function.
  3. A stable argument sketch, never raw secrets.
  4. A coarse result sketch: type name or exception class.

Do not freeze wall-clock times. Do not freeze memory addresses. Those fields are noise, not behavior.

Artifact: a local trace recorder

The recorder below is a proposed harness. It is not a measured production run. Drop it beside the messy module under test.

# trace_freeze.py
"""Proposed characterization harness. Unexecuted in this article."""
from __future__ import annotations

import hashlib
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Callable, Iterator

TRACE_PATH = Path("characterization.trace")
MODULE_PREFIX = "messy_inventory"


def _sketch(value: Any) -> str:
    text = repr(value)
    if len(text) > 80:
        digest = hashlib.sha256(text.encode()).hexdigest()[:12]
        return f"{type(value).__name__}#sha256:{digest}"
    safe = text.replace("|", "/")
    return f"{type(value).__name__}:{safe}"


def _qualname(frame) -> str:
    module = frame.f_globals.get("__name__", "")
    name = frame.f_code.co_name
    return f"{module}.{name}"


class CallTrace:
    def __init__(self, path: Path, prefix: str) -> None:
        self.path = path
        self.prefix = prefix
        self._fh = None

    def _write(self, kind: str, qn: str, payload: str) -> None:
        self._fh.write(f"{kind}|{qn}|{payload}\n")

    def __call__(self, frame, event, arg):
        if event not in {"call", "return", "exception"}:
            return self
        qn = _qualname(frame)
        if not qn.startswith(self.prefix):
            return self
        if event == "call":
            names = frame.f_code.co_varnames[: frame.f_code.co_argcount]
            args = ",".join(
                f"{n}={_sketch(frame.f_locals.get(n))}" for n in names
            )
            self._write("enter", qn, args)
        elif event == "return":
            self._write("exit", qn, _sketch(arg))
        else:
            exc = arg[0].__name__ if arg and arg[0] else "Exception"
            self._write("exc", qn, exc)
        return self

    def install(self) -> None:
        self.path.write_text("", encoding="utf-8")
        self._fh = self.path.open("a", encoding="utf-8")
        sys.setprofile(self)

    def remove(self) -> None:
        sys.setprofile(None)
        if self._fh:
            self._fh.close()
            self._fh = None


@contextmanager
def freeze_calls(path: Path = TRACE_PATH) -> Iterator[None]:
    tracer = CallTrace(path, MODULE_PREFIX)
    tracer.install()
    try:
        yield
    finally:
        tracer.remove()


def replay_fixtures(run: Callable[[], None], path: Path) -> None:
    with freeze_calls(path):
        run()
Enter fullscreen mode Exit fullscreen mode

That harness writes one pipe-delimited row per event. Rows are ordered on purpose. Order is the contract under test.

Artifact: a trace diff gate

After any edit, replay the same fixtures. Diff the new file against the frozen file. Treat mismatch as a failed gate, not a note.

# trace_diff.py
"""Proposed diff. Nonzero exit means revert the leaf."""
from pathlib import Path
import sys


def load_lines(path: Path) -> list[str]:
    return path.read_text(encoding="utf-8").splitlines()


def main(old: str, new: str) -> int:
    left = load_lines(Path(old))
    right = load_lines(Path(new))
    if left == right:
        print("TRACE_OK", len(left), "events")
        return 0
    limit = max(len(left), len(right))
    for i in range(limit):
        a = left[i] if i < len(left) else "<missing>"
        b = right[i] if i < len(right) else "<missing>"
        if a != b:
            print(f"TRACE_DRIFT at event {i}")
            print("old:", a)
            print("new:", b)
            return 1
    return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode

Run the three commands as one gate. Do not skip the diff.

python -m messy_inventory.fixtures --trace before.trace
# apply one leaf change only
python -m messy_inventory.fixtures --trace after.trace
python trace_diff.py before.trace after.trace
Enter fullscreen mode Exit fullscreen mode

Numbered workflow

Follow the steps in order. Skip none of them.

1. Pick one god file

Choose the file with mixed I/O and rules. Do not pick a whole package. One file keeps the module prefix small.

2. List public entry points

Write the names down before any extract. Entry points are the only legal fixture drivers. Private helpers are not drivers.

3. Build a fixture corpus

Drive every entry point with checked-in inputs. Cover success, empty input, and one failure. Label this corpus proposed until it runs in your tree.

4. Record the baseline trace

python -m messy_inventory.fixtures --trace baseline.trace
wc -l baseline.trace
git add baseline.trace messy_inventory/fixtures.py trace_freeze.py
git commit -m "freeze call trace for inventory god file"
Enter fullscreen mode Exit fullscreen mode

Commit the trace with the fixtures. Uncommitted oracles do not gate anything. Reviewers cannot replay a local scratch file.

5. Rank leaves, not lines

A leaf is a function with no further in-module calls. Rank leaves by trace frequency next. High-frequency leaves are poor first targets.

Low-frequency leaves fail in isolation. That isolation is the point. You want a revert that is cheap.

6. Change one leaf

Move one leaf to a new module. Keep the name and the signature. Do not inline callers in the same commit.

7. Replay and diff

python -m messy_inventory.fixtures --trace after.trace
python trace_diff.py baseline.trace after.trace
Enter fullscreen mode Exit fullscreen mode

TRACE_OK means the extract preserved order. TRACE_DRIFT means revert the leaf. Do not negotiate with the drift.

8. Only then consider a helper model

A coding model can draft the leaf move. It cannot invent the baseline. Generate the patch against the frozen file.

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

MonkeyCode offers free model access and a free server option. Use that pair only after the baseline exists. Paste the leaf, the callers, and the first drifted row.

Do not paste secrets from argument sketches. If the local gate already fails, skip the model. A drifted trace is a revert signal, not a prompt.

Decision table

Observation Action Stop condition
Trace files identical Keep the leaf extract None
Drift on enter order Revert extract Do not swap callers
Drift on exc class Revert extract Do not swallow errors
Drift only in sketches of new objects Inspect, then maybe relax sketch Never ignore order
New events appear Revert unless logging was the change Logging is behavior
Events disappear Revert Missing calls are regressions

Use the table as a gate script. Do not debate the rows during review. Encode the rows in the diff command.

A proposed messy module

The module below is illustrative. It is not from a production inventory. Treat it as a teaching double.

# messy_inventory.py
"""Proposed god file. Do not treat as measured production code."""

from __future__ import annotations

_CACHE: dict[str, int] = {}
_AUDIT: list[str] = []


def _audit(msg: str) -> None:
    _AUDIT.append(msg)


def _cache_get(sku: str) -> int | None:
    return _CACHE.get(sku)


def _cache_put(sku: str, qty: int) -> None:
    _CACHE[sku] = qty


def _load_row(sku: str) -> int:
    _audit(f"load:{sku}")
    if sku == "MISSING":
        raise KeyError(sku)
    return 4


def restock(sku: str, delta: int) -> int:
    hit = _cache_get(sku)
    if hit is None:
        hit = _load_row(sku)
        _cache_put(sku, hit)
    total = hit + delta
    _cache_put(sku, total)
    _audit(f"restock:{sku}:{total}")
    return total
Enter fullscreen mode Exit fullscreen mode

The implicit order is the product. Cache before load. Audit after mutation. An extract that loads first will still return total.

The trace will not match. That mismatch is the whole method. Return equality is not the gate.

Smallest safe change

The smallest safe change for this file is _audit. It is a leaf. It does not call other in-module names.

Moving _load_row is not smallest. _load_row writes audit events and raises. That is a bundle, not a leaf.

# inventory_audit.py
def audit(bucket: list[str], msg: str) -> None:
    bucket.append(msg)
Enter fullscreen mode Exit fullscreen mode

Keep _audit as a one-line wrapper first. Replay the trace after that wrapper. Then delete the wrapper in a later change.

Two commits beat one clever commit. The trace must pass after each. Stacked extracts hide which leaf drifted.

Fixture driver

# messy_inventory/fixtures.py
"""Proposed fixture driver."""
from messy_inventory import restock, _CACHE, _AUDIT


def run() -> None:
    _CACHE.clear()
    _AUDIT.clear()
    restock("ABC", 1)
    restock("ABC", 2)
    try:
        restock("MISSING", 1)
    except KeyError:
        pass


if __name__ == "__main__":
    run()
Enter fullscreen mode Exit fullscreen mode

Three calls are enough to start. The second call hits the cache path. The third call records the KeyError row.

Add more fixtures when a new leaf needs coverage. Do not add fixtures after seeing a generated patch. That inverts the gate.

CI shape

Keep the gate boring in CI. Replay, then diff, then fail the job.

python -m messy_inventory.fixtures --trace ci.trace
python trace_diff.py baseline.trace ci.trace
Enter fullscreen mode Exit fullscreen mode

Store baseline.trace next to the fixtures. Do not regenerate the baseline in CI. Regeneration turns the oracle into a mirror.

Redaction rules

Argument sketches can leak secrets. Hash long values before write. Redact tokens by name, not by length.

Keep traces out of public gists. Rotate any fixture that embeds credentials. Treat the trace file as test data with teeth.

If a sketch still shows a live key, delete the file. Rebuild fixtures with stubs. Then record again.

Limitations

sys.setprofile sees Python calls only. C extensions stay invisible. Decorators rewrite qualified names. Threads interleave events.

Traces are corpus-bound. A green trace on three calls is not full coverage. It is a local freeze of observed order.

The harness does not prove performance. It does not prove concurrency. It does not prove API stability for other languages.

Import side effects can poison the first record. Clear module globals in the fixture driver. Record after that reset, never before.

Who should not use this

Do not use this on a greenfield module. Write intent tests first there. Do not use this when contract tests already pin the file.

Do not use this for security-sensitive request paths. Traces capture argument sketches. That is a data-handling choice with cost.

Do not use a model to invent fixtures. Fixtures must come from known callers. Generated callers hide real order and fake safety.

What the method does not claim

This article does not report runtime numbers. It does not rank coding models. It does not claim a universal refactor bot.

The claim is narrower than a rewrite. Event order is a cheap oracle for one file. Identical traces license one leaf move.

Drift licenses a revert. That pair is the whole protocol. Everything else is optional tooling.

Close

Commit the call trace before any extract. Move one leaf and require TRACE_OK. Repeat until the god file looks boring.

Top comments (0)