A moved function can be byte-identical and still behave differently. The AST proves the text did not change. It does not prove the names still resolve to the same objects.
That gap is where messy-repo refactors quietly break. You move one helper into a new module, the diff looks clean, and a caller starts returning different output a week later.
This post gives three checks you can run in one sitting: a behavior pin, an AST equality proof, and a free-name resolver diff. All three use only the Python standard library and git.
Why a pure move changes behavior
A function body does not execute in isolation. It resolves names against the module namespace it was compiled in.
Four mechanisms cause most of the damage:
- Module globals. The name now resolves in the new module, which may import a different symbol.
-
__name__,__file__,__package__. Loggers,mainguards, and path lookups read these. -
Relative imports. A copy-pasted
from . import xpoints somewhere else after the move. - Default arguments and decorators. These are evaluated at import time, in the new module's order.
None of the four shows up in a text diff of the function body.
Step 1: Pin the observable behavior first
Pick the narrowest entry point that exercises the function. A CLI subcommand or a batch job is ideal, because its output is a string you can hash.
If your laptop's locale, timezone, or temp paths leak into that string, capture on a machine that is not your laptop. That is the one place where MonkeyCode's free server option is genuinely useful here: it gives you a neutral Linux box for baseline capture, so the recorded digest is not tied to your local shell state.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The pin script below runs a command with a fixed environment and stores digests, not raw text. Digests keep the baseline small and keep customer data out of your repo.
# pin_cli.py -- stdlib only, Python 3.9+
import hashlib, json, os, re, subprocess, sys
NORMALIZERS = [
(r"\b\d+\.\d+\s*s\b", "<dur>"), # 1.24 s
(r"/tmp/[A-Za-z0-9_.-]+", "<tmp>"), # temp paths
(r"\b[0-9a-f]{7,40}\b", "<sha>"), # commit ids
(r"\bpid=\d+\b", "pid=<pid>"), # process ids
]
def normalize(text: str) -> str:
for pat, repl in NORMALIZERS:
text = re.sub(pat, repl, text)
return text
def digest(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
def run(cmd: list[str]) -> dict:
env = {"PATH": os.environ.get("PATH", ""), "LC_ALL": "C.UTF-8", "TZ": "UTC"}
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120)
return {
"code": proc.returncode,
"stdout": digest(normalize(proc.stdout)),
"stderr": digest(normalize(proc.stderr)),
}
if __name__ == "__main__":
mode, path, cmd = sys.argv[1], sys.argv[2], sys.argv[4:]
result = run(cmd)
if mode == "--record":
json.dump(result, open(path, "w"), indent=2, sort_keys=True)
print("recorded", result)
else:
expected = json.load(open(path))
print("expected", expected)
print("actual ", result)
sys.exit(0 if expected == result else 1)
Record on a clean checkout. Check again after the move.
python pin_cli.py --record baseline.json -- python -m mycli report --input sample.csv
python pin_cli.py --check baseline.json -- python -m mycli report --input sample.csv
Prove the normalizers can still fail
A normalizer that erases everything will pass forever. Test its sensitivity before you trust it.
Change one user-visible string in the source. Run the check. It must exit non-zero.
sed -i 's/rows processed/rows handled/' mycli/report.py
python pin_cli.py --check baseline.json -- python -m mycli report --input sample.csv
echo $? # must be 1
git checkout -- mycli/report.py
If the exit code is 0, your normalizers are too aggressive. Tighten them and record again.
Step 2: Prove the body did not change
Now verify the move was actually a move. Compare the function's AST dump from HEAD against the working tree.
# move_check.py -- stdlib only
import ast, hashlib, subprocess, sys
def from_git(ref: str, path: str) -> ast.Module:
src = subprocess.run(["git", "show", f"{ref}:{path}"],
capture_output=True, text=True, check=True).stdout
return ast.parse(src)
def from_disk(path: str) -> ast.Module:
return ast.parse(open(path, encoding="utf-8").read())
def find(tree: ast.Module, name: str):
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
return node
raise SystemExit(f"{name} not found")
def ast_hash(fn) -> str:
dump = ast.dump(fn, include_attributes=False)
return hashlib.sha256(dump.encode()).hexdigest()[:16]
Run it with the old path and the new path. Equal hashes mean the body text is unchanged. Different hashes mean you edited while moving. Split the commit before you continue.
Step 3: Diff the free names
The AST hash says nothing about resolution. Extract every loaded name the function does not bind locally, then resolve each one in both modules.
def free_names(fn) -> set[str]:
bound = {a.arg for a in fn.args.posonlyargs + fn.args.args + fn.args.kwonlyargs}
for extra in (fn.args.vararg, fn.args.kwarg):
if extra:
bound.add(extra.arg)
for node in ast.walk(fn):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
bound.add(node.id)
if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node is not fn:
bound.add(node.name)
return {n.id for n in ast.walk(fn)
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)} - bound
def describe(module, names: set[str]) -> dict[str, str]:
out = {}
for name in sorted(names):
value = getattr(module, name, None)
out[name] = f"{type(value).__name__}:{value!r}"[:120]
return out
Compare the two dictionaries. Any entry that differs is a behavior risk the AST check cannot see. A changed logger name, a different Path base, a different constant: all of them show up here.
Label the output as a report, not a verdict. The resolver is coarse for comprehension targets and closures. Read it, then decide.
What to do with each result
| Finding | Risk | Smallest safe change |
|---|---|---|
| AST hash differs | High | Revert the edit, move again with no other changes |
| Free name changed type or value | High | Pass the symbol in as an argument, or re-import it explicitly |
Free name is __file__ or __name__
|
Medium | Pass the needed value in as a parameter |
CLI digest changed only in stderr
|
Medium | Check logger names and warning text |
| All three checks pass | Low | Proceed to the next function |
The rule stays simple. Move first. Change nothing else. Edit behavior only after all three checks are green.
A light use for a free model
Fitting normalizers is dull, pattern-matching work. Capture two runs of the same command, then ask a model to propose regex candidates for the lines that differ between runs.
MonkeyCode's free model access fits that step without much ceremony. Treat the output as a draft. Every proposed pattern still has to pass the sensitivity check from Step 1 before it enters NORMALIZERS.
The model never decides whether the refactor is safe. The three checks do.
Limitations
This workflow assumes deterministic, text-shaped output. It fits CLI tools, report generators, and batch jobs.
It does not fit services with concurrency, network calls, or timing-sensitive output. It also says nothing about performance: a move can keep every digest identical and still double your import time.
And it only covers the functions you name. A shared module that both sides import can change behavior for callers you never listed.
Skip this approach if your output is already covered by contract tests, or if the move touches fewer than about five call sites. The overhead will not pay for itself.
Start with one function
Pick the function that scares you most. Record the baseline. Hash the AST. Diff the free names.
Three small scripts, one clean commit. If you want to try the capture step on a neutral box, the free server option in MonkeyCode is enough for a single baseline run; keep the pin script in your repo so the check stays yours.
Top comments (0)