Do not extract a helper until the runtime call graph is pinned. Static reading of a god script overstates the live path. Dead functions often sit beside the hot writers.
This workflow records call edges for one CLI path. It then extracts only one recorded leaf. A leaf is a user function that never appears as a caller.
Stdout pins miss this class of break. Returncode pins miss it too. Control flow can change while output bytes stay stable.
Why the call graph is the contract
A messy repo usually has one entry module. That module parses argv, walks files, and writes output. Unused helpers share the same file as live code.
A rename can look local in review. The live path still binds those names. Missing one quiet callee breaks a branch you never ran.
File-path keys also fail after a real extract. The moved function still has the same name. Name keys survive the module move. Name collisions merge counts, so record that risk.
What this pin covers
Pin three observables on one frozen argv. Process returncode. Call-edge counts. User function names.
Ignore standard library frames in the tracer. Ignore the harness file itself. Normalize keys to function names, not absolute paths.
Import-time calls will appear in the graph. That is useful, not noise. A later package move often breaks those first.
Example god script
The listing below is an example, not production history. It mixes parse, line counts, and a JSON writer.
# report_god.py — example only
from __future__ import annotations
import argparse
import json
from pathlib import Path
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--out", required=True)
return parser.parse_args(argv)
def list_py(root: Path) -> list[Path]:
return sorted(root.glob("*.py"))
def count_lines(path: Path) -> int:
text = path.read_text(encoding="utf-8")
if not text:
return 0
return text.count("\n") + (0 if text.endswith("\n") else 1)
def write_report(out: Path, rows: list[dict]) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
def unused_helper() -> None:
raise RuntimeError("dead code")
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
rows = []
for path in list_py(Path(args.root)):
rows.append({"file": path.name, "lines": count_lines(path)})
write_report(Path(args.out), rows)
return 0
if __name__ == "__main__":
raise SystemExit(main())
unused_helper never appears on the happy path. Moving it still looks clean in diff. The edge list shows it is not live.
Artifact: edge recorder
The harness uses sys.settrace. It records Python function calls only. C extensions do not appear in the file.
Treat the JSON as a characterization snapshot. It is not a profiler report. It is not a performance budget.
# pin_callgraph.py — example harness
from __future__ import annotations
import argparse
import json
import runpy
import sys
from collections import Counter
from pathlib import Path
HARNESS_NAME = Path(__file__).name
STDLIB_HINTS = ("lib/python", "Lib/", "site-packages")
def is_user_frame(filename: str) -> bool:
if not filename.endswith(".py"):
return False
if Path(filename).name == HARNESS_NAME:
return False
lowered = filename.replace("\\", "/")
return not any(hint in lowered for hint in STDLIB_HINTS)
def make_tracer(edges: Counter[str], funcs: Counter[str]):
def tracer(frame, event, arg):
if event != "call":
return tracer
if not is_user_frame(frame.f_code.co_filename):
return tracer
callee = frame.f_code.co_name
funcs[callee] += 1
parent = frame.f_back
caller = "<root>"
if parent is not None and is_user_frame(parent.f_code.co_filename):
caller = parent.f_code.co_name
elif parent is not None:
caller = "<nonuser>"
edges[f"{caller}->{callee}"] += 1
return tracer
return tracer
def snapshot(rc: int, edges: Counter[str], funcs: Counter[str]) -> dict:
leaves = sorted(
name
for name in funcs
if not any(edge.startswith(f"{name}->") for edge in edges)
)
return {
"returncode": rc,
"functions": dict(sorted(funcs.items())),
"edges": dict(sorted(edges.items())),
"leaves": leaves,
}
def compare(expected: dict, actual: dict) -> list[str]:
errors: list[str] = []
if expected.get("returncode") != actual.get("returncode"):
errors.append(
f"returncode {actual.get('returncode')} != {expected.get('returncode')}"
)
exp_edges = expected.get("edges") or {}
got_edges = actual.get("edges") or {}
if exp_edges != got_edges:
errors.append("edges mismatch")
for key in sorted(set(exp_edges) | set(got_edges)):
if exp_edges.get(key) != got_edges.get(key):
errors.append(
f" {key}: expected {exp_edges.get(key)} got {got_edges.get(key)}"
)
exp_funcs = expected.get("functions") or {}
got_funcs = actual.get("functions") or {}
if exp_funcs != got_funcs:
errors.append("functions mismatch")
for key in sorted(set(exp_funcs) | set(got_funcs)):
if exp_funcs.get(key) != got_funcs.get(key):
errors.append(
f" {key}: expected {exp_funcs.get(key)} got {got_funcs.get(key)}"
)
return errors
def run_target(target: str, argv: list[str]) -> dict:
edges: Counter[str] = Counter()
funcs: Counter[str] = Counter()
sys.settrace(make_tracer(edges, funcs))
sys.argv = [target, *argv]
rc = 0
try:
runpy.run_path(target, run_name="__main__")
except SystemExit as exc:
code = exc.code
if code is None:
rc = 0
elif isinstance(code, int):
rc = code
else:
rc = 1
finally:
sys.settrace(None)
return snapshot(rc, edges, funcs)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--golden", default="callgraph.golden.json")
parser.add_argument("--record", action="store_true")
parser.add_argument("target")
parser.add_argument("target_argv", nargs=argparse.REMAINDER)
args = parser.parse_args()
argv = args.target_argv
if argv[:1] == ["--"]:
argv = argv[1:]
payload = run_target(args.target, argv)
golden = Path(args.golden)
if args.record:
golden.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"recorded {golden}")
print("leaves: " + ", ".join(payload["leaves"]))
return 0
expected = json.loads(golden.read_text(encoding="utf-8"))
errors = compare(expected, payload)
if errors:
print("\n".join(errors))
return 1
print("call graph pin held")
print("leaves: " + ", ".join(payload["leaves"]))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Expected leaves on the happy path include write_report and count_lines. main is not a leaf. unused_helper is absent from the snapshot.
Numbered workflow
- Create a disposable fixture directory with two small
.pyfiles. - Freeze one argv string and keep that string in the golden job.
- Record edges with
--recordbefore any extract. - Read the
leavesarray and pick one writer or pure helper. - Move that single function into a new module and import it.
- Re-run the harness in check mode against the same argv.
- Stop if edges drift. Do not stack a second extract.
Fixture setup for the example:
mkdir -p /tmp/callgraph-demo
printf 'x = 1\n' > /tmp/callgraph-demo/a.py
printf 'y = 2\n' > /tmp/callgraph-demo/b.py
Record command:
python pin_callgraph.py --record --golden callgraph.golden.json -- \
report_god.py --root /tmp/callgraph-demo --out /tmp/callgraph-demo/out.json
Check command after the extract:
python pin_callgraph.py --golden callgraph.golden.json -- \
report_god.py --root /tmp/callgraph-demo --out /tmp/callgraph-demo/out.json
A passing check prints call graph pin held. A failed check prints the drifted edges. Read those lines before touching a second function.
Smallest safe change
The smallest safe change is one leaf move. Keep the original main as the caller. Do not rename the leaf on the same commit.
# writers.py — example extract only
from pathlib import Path
import json
def write_report(out: Path, rows: list[dict]) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(rows, indent=2) + "\n", encoding="utf-8")
In report_god.py, import write_report and delete the old body. Do not move count_lines in that commit. One leaf keeps the edge main->write_report stable.
If check mode fails, revert the move. The golden file is the contract. The diff is not the contract.
Decision table for the next leaf
| Recorded leaf | Extract now | Wait |
|---|---|---|
write_report |
Isolated side effect, one caller | Output path still inline in main
|
count_lines |
Pure function, easy unit test | Lower risk than the writer |
list_py |
Still bound to filesystem layout | Glob pattern may change |
parse_args |
Argparse object graph is sticky | Wait until writers are gone |
unused_helper |
Not in the live graph | Do not move dead code first |
Prefer a leaf with one caller and a side effect. Pure leaves are cheaper, but they hide fewer bugs. Dead names are a delete, not an extract.
After the pin exists
A model can propose the next leaf only after the golden file exists. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode offers free model access and a free server option. Feed the recorded leaves array and the edge map. Ask for one sink function, not a full rewrite.
Do not ask any model to invent the golden graph. The harness owns that file. The model only ranks leaves that the tracer already saw.
Run the check command after each suggested patch. Discard the patch if edges drift. Keep the server out of the recording path.
Limitations
sys.settrace is slow on large trees. Do not leave it enabled in production. Use it on one frozen CLI path.
C extensions do not emit Python call events. Heavy NumPy or database drivers look like leaves. They are not proven leaves.
Threads and processes need other tools. This harness traces the main thread only. Multiprocessing clones will not update the Counter.
Decorators wrap names and add extra edges. Async functions can show wrapper instead of the source name. Name collisions merge two functions into one count.
Import-time work is included. That is intended for messy packages. It is the wrong pin for a long-lived server boot.
Who should not use this
Skip this workflow if modules are already isolated. You already have unit tests around the writer. A call-graph pin adds cost without a new contract.
Skip it for threaded servers and GUI loops. Skip it for extension-heavy numeric kernels. Skip it if the plan is a wide rename.
Do not replace tests with a model suggestion. Do not record one graph and then change argv. Do not extract two leaves in one commit.
Close
Pin edges first. Extract one leaf second. Re-check the same argv before any further split.
The live call graph is the contract for a messy CLI. Visual cleanup is not. Dead helpers are a delete after the pin, not a move.
Top comments (0)