DEV Community

Dakota Huang
Dakota Huang

Posted on

Your Refactor Copies a List. The Caller Still Holds the Old One.

In-place mutation is the quietest part of a module's contract. Most characterization tests assert what a function returns. Almost none assert that the caller's list changed underneath. Replace rows.sort() with rows = sorted(rows) and the suite stays green. Production still reads the old list, in the old order.

This is the seam that survives a copy-versus-mutate refactor. Freeze it before you touch the body.

1. The break is aliasing, not logic

A caller passes a list. The function appends to it and returns None. The caller never reads the return value; it reads its own variable later. That is a real contract, and it lives outside your assertions.

Now a refactor returns a fresh list. Return-value tests pass. The caller keeps its stale object. Nothing raises. No error is logged.

The damage is silent because the signature did not change. So the gate must record mutations, not just outputs.

2. Build a mutation ledger

The probe below wraps a function. It deep-snapshots every argument before the call. It diffs each argument after the call and appends one JSON line per invocation.

# mutation_probe.py
import copy, functools, json


def _snapshot(value):
    try:
        return copy.deepcopy(value)
    except Exception:
        return repr(value)


def _diff(before, after, path="$"):
    out = []
    if type(before) is not type(after):
        return [{"path": path, "change": "type",
                 "before": type(before).__name__, "after": type(after).__name__}]
    if isinstance(before, dict):
        for key in sorted(set(before) | set(after), key=str):
            if key not in before:
                out.append({"path": f"{path}[{key!r}]", "change": "added"})
            elif key not in after:
                out.append({"path": f"{path}[{key!r}]", "change": "removed"})
            else:
                out.extend(_diff(before[key], after[key], f"{path}[{key!r}]"))
        return out
    if isinstance(before, list):
        if len(before) != len(after):
            out.append({"path": path, "change": "length",
                        "before": len(before), "after": len(after)})
        for i, (b, a) in enumerate(zip(before, after)):
            out.extend(_diff(b, a, f"{path}[{i}]"))
        return out
    if before != after:
        out.append({"path": path, "change": "value", "before": before, "after": after})
    return out


def freeze_mutations(fn, ledger_path):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        before = {f"arg{i}": _snapshot(v) for i, v in enumerate(args)}
        before.update({f"kw:{k}": _snapshot(v) for k, v in kwargs.items()})
        result = fn(*args, **kwargs)
        after = {f"arg{i}": _snapshot(v) for i, v in enumerate(args)}
        after.update({f"kw:{k}": _snapshot(v) for k, v in kwargs.items()})
        record = {"function": fn.__qualname__, "mutations": []}
        for name in before:
            record["mutations"].extend(_diff(before[name], after[name], name))
        with open(ledger_path, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(record, sort_keys=True, default=repr) + "\n")
        return result
    return wrapper
Enter fullscreen mode Exit fullscreen mode

Two notes before you run it. Snapshots cost memory, so target narrow functions. And default=repr keeps unserializable payloads from killing the run.

3. Capture against real call shapes

Tests alone give you a weak baseline. They exercise the arguments someone remembered to write.

  1. List candidate functions with grep -rn "\.append(\|\.update(\|\.sort(\|\.pop(" src/.
  2. Wrap the top candidates with freeze_mutations(fn, "ledger-before.jsonl").
  3. Drive them with recorded payload fixtures, not synthetic ones.
  4. Run the suite twice and require identical ledgers, sorted by (function, path, change).
  5. Commit ledger-before.jsonl as the baseline artifact.

Step 4 matters. A ledger that differs between runs is noise, not a contract. Fix the noise before you trust the diff.

4. Turn the ledger into a gate

The gate is a set comparison. It does not care about ordering across lines. It cares about which mutation paths exist.

# mutation_gate.py
import json, sys


def keys(path):
    seen = set()
    with open(path, encoding="utf-8") as fh:
        for line in fh:
            rec = json.loads(line)
            for m in rec["mutations"]:
                seen.add((rec["function"], m["path"], m["change"]))
    return seen


baseline, current = keys(sys.argv[1]), keys(sys.argv[2])
lost, new = baseline - current, current - baseline
for tag, rows in (("LOST", lost), ("NEW", new)):
    for fn_name, path, change in sorted(rows):
        print(f"{tag}\t{fn_name}\t{path}\t{change}")
sys.exit(1 if lost or new else 0)
Enter fullscreen mode Exit fullscreen mode

A LOST line is the dangerous one. A mutation that used to happen no longer happens. A NEW line is a contract you just invented.

5. Make the smallest safe change

One function per commit. Never two.

  1. Refactor a single function to return a new object.
  2. Re-run the probe into ledger-after.jsonl.
  3. Run the gate against the baseline.
  4. If LOST is non-empty, revert the copy and keep in-place mutation.
  5. If only NEW appears, delete it and re-check.

The revert in step 4 is not a failure. It is the gate doing its job.

6. Decide per mutation, not per module

Observed mutation Caller relies on it? Action
Appends to a passed-in list Yes, verified by reader code Keep; document the contract in a docstring
Sorts a passed-in list Unclear Keep; add a test that asserts the caller's list order
Mutates a config dict No Convert to copy; expect LOST and accept it explicitly
Mutates an argument, returns same object Yes Convert only if you also update every reader
Mutates across a thread boundary Unknown Stop; the probe cannot see the race

Run that table before you run the refactor. It converts a judgement call into a recorded decision.

7. Where a disposable box helps

Capture runs dirty the environment. They write ledgers, load fixtures, and sometimes touch a scratch database. Running them on your working checkout is friction you do not need.

MonkeyCode's free server option covers that part: one throwaway box for the capture run. Its free model access covers the drafting step, where a raw ledger becomes readable assertion names instead of JSON noise. Both are operator-supplied availability claims; check current limits yourself before planning around them.

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

Keep the gate in your own CI. Generated assertion names must be read against the ledger line by line. A model will happily invent a path that never mutated.

8. Limitations, and who should skip this

  • List indices shift. A path like arg0[2].id moves when the list resizes. Normalize indices out before you compare across branches.
  • Opaque objects leak. Deep copies of database handles, sockets, or C extensions often fail, and repr fallback hides the mutation.
  • Threads and processes are invisible. The probe sees one call, one thread, one moment.
  • Side effects outside arguments are out of scope. Writes, network calls, and file handles need a separate gate.
  • Deep snapshot is slow. Do not wrap hot paths, and do not wrap loops that run thousands of times per request.

Skip this approach if your code already treats data as immutable. Skip it if the object graph is too large to copy. Skip it if the mutation you fear lives in another process.

The rule

Asserting return values proves half the contract. Freeze what the function did to the arguments, or the quietest break ships.

Top comments (0)