DEV Community

Dakota Huang
Dakota Huang

Posted on

Pin the Runtime Call Graph Before One Leaf Extract

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())
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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

  1. Create a disposable fixture directory with two small .py files.
  2. Freeze one argv string and keep that string in the golden job.
  3. Record edges with --record before any extract.
  4. Read the leaves array and pick one writer or pure helper.
  5. Move that single function into a new module and import it.
  6. Re-run the harness in check mode against the same argv.
  7. 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
Enter fullscreen mode Exit fullscreen mode

Record command:

python pin_callgraph.py --record --golden callgraph.golden.json -- \
  report_god.py --root /tmp/callgraph-demo --out /tmp/callgraph-demo/out.json
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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)