跳到正文
DEV Community· Dakota Huang·· 2 小時前AI 評分57

混亂程式碼庫重構指南:先為跨模組可呼叫函數評分,再設定變更影響範圍預算

Score Every Seam Before You Move One: A Blast-Radius Budget for Messy Repos

AI 導讀

文章提出以約 90 行標準庫 Python 腳本盤點跨模組可呼叫函數、按風險評分,再設定 CI 重構門檻的方法。評分考慮函數是否公開、有否副作用、參數數量及行數;分數達 7 的函數在一般整理中禁止修改,分數達 4 則須先加入固定現有行為的 pin 測試。作者提醒,靜態 AST 掃描會漏掉動態分派等情況,清單亦須與結構性修改同步更新。

正文

Measure each seam before you touch it. Then spend a fixed change budget per commit.

That is the method. It turns "smallest safe change" into a number a CI job can reject.

Most refactor advice stops at "take small steps". Small is judged by feel, so it drifts. A forty-line diff in a leaf helper is safe. A one-line diff in a shared entry point is not. Diff size alone cannot separate the two cases.

The gate below weights every change by the seam it touches. Seams with public names, side effects, and wide argument lists get a near-zero budget. Leaf helpers get room. The artifact is about ninety lines of standard-library Python, and it runs in CI.

1. Inventory the seams with ast

A seam is any function that another module can call. You cannot budget what you have not listed. Extract the list mechanically instead of from memory.

The script below walks every .py file and records four facts per function: public or private, argument count, line count, and which side-effecting calls it makes. It is deliberately crude. A parser is easier to trust than a hand-written inventory that rots in a wiki.

# tools/seam_inventory.py
import ast, json
from pathlib import Path

EFFECT_CALLS = {
    "open", "write_text", "write_bytes", "unlink", "rename",
    "urlopen", "get", "post", "put", "execute", "commit", "publish", "send",
}

WEIGHTS = {"public": 3, "effect": 2, "arity": 1, "size": 2}


def callee_name(call: ast.Call):
    fn = call.func
    if isinstance(fn, ast.Name):
        return fn.id
    if isinstance(fn, ast.Attribute):
        return fn.attr
    return None


def scan(path: Path):
    tree = ast.parse(path.read_text(encoding="utf-8"))
    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        effects = sorted({
            n for sub in ast.walk(node)
            if isinstance(sub, ast.Call)
            for n in [callee_name(sub)] if n in EFFECT_CALLS
        })
        line_count = (node.end_lineno or node.lineno) - node.lineno + 1
        yield {
            "file": path.as_posix(),
            "name": node.name,
            "line": node.lineno,
            "public": not node.name.startswith("_"),
            "arity": len(node.args.args),
            "loc": line_count,
            "effects": effects,
        }


def score(seam):
    return (
        (WEIGHTS["public"] if seam["public"] else 0)
        + min(len(seam["effects"]), 3) * WEIGHTS["effect"]
        + (WEIGHTS["arity"] if seam["arity"] > 4 else 0)
        + (WEIGHTS["size"] if seam["loc"] > 40 else 0)
    )


if __name__ == "__main__":
    seams = [s for p in Path("src").rglob("*.py") for s in scan(p)]
    for s in seams:
        s["score"] = score(s)
    seams.sort(key=lambda s: -s["score"])
    Path("seams.json").write_text(json.dumps({"seams": seams}, indent=2))
    print(f"{len(seams)} seams, {sum(s['score'] >= 7 for s in seams)} locked")

Run it once and commit seams.json. That file becomes reviewable evidence, not a private hunch.

python tools/seam_inventory.py
python -c "import json;d=json.load(open('seams.json'));print(d['seams'][:5])"

2. Read the score as a permission slip

The weights are arbitrary at first. Their job is ranking, not truth. Calibrate them against the three or four functions your team already fears.

Signal Weight Why it raises risk
Public name +3 Unbounded caller set; you cannot see all call sites
Side effect call +2 each, max 3 Failures escape the process boundary
More than four arguments +1 Argument order bugs survive review
Over forty lines +2 Harder to characterize with one pin

A score of seven or above means locked. Locked seams do not move during routine cleanup, full stop. Lower scores are negotiable, and that is where the work happens.

3. Write the budget as a file

Budgets that live in a reviewer's head do not survive a busy Friday. Put them in version control next to the code they constrain.

# refactor-budget.toml
max_files = 4
max_net_lines = 120
locked_score = 7
require_pin_for = 4

Three numbers do most of the work. max_files stops "while I was here" edits. max_net_lines caps the readable surface. locked_score is the hard wall from the table.

4. Gate the pull request

The gate compares git diff --numstat against the budget and the seam inventory. Any locked seam in the diff is a hard block, regardless of how small the hunk is.

# tools/refactor_gate.py
import json, subprocess, sys, tomllib
from pathlib import Path

budget = tomllib.loads(Path("refactor-budget.toml").read_text())
seams = json.loads(Path("seams.json").read_text())["seams"]


def diff_rows(base="origin/main"):
    raw = subprocess.run(
        ["git", "diff", "--numstat", f"{base}...HEAD"],
        capture_output=True, text=True, check=True,
    ).stdout
    rows = []
    for line in raw.splitlines():
        add, delete, path = line.split("\t", 2)
        rows.append((path, int(add or 0), int(delete or 0)))
    return rows


def main():
    rows = diff_rows()
    touched = {p for p, _, _ in rows}
    errors = []

    if len(rows) > budget["max_files"]:
        errors.append(f"files touched: {len(rows)} > {budget['max_files']}")

    net = sum(a - d for _, a, d in rows)
    if net > budget["max_net_lines"]:
        errors.append(f"net lines: {net} > {budget['max_net_lines']}")

    for seam in seams:
        if seam["file"] not in touched:
            continue
        if seam["score"] >= budget["locked_score"]:
            errors.append(f"locked seam changed: {seam['name']} in {seam['file']}")
        if seam["score"] >= budget["require_pin_for"]:
            pin = Path("tests/pins") / f"{seam['name']}.py"
            if not pin.exists():
                errors.append(f"no pin for seam: {seam['name']}")

    for err in errors:
        print("BLOCK:", err)
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

tomllib ships with Python 3.11 and later. On older interpreters, swap in a small TOML parser or a JSON budget file. The gate stays a single exit code either way.

5. Pin before you move

Every seam at score four or above needs one characterization pin before its file is edited. The pin asserts current behavior, including the behavior you consider wrong. Here is the whole idea in six lines.

# tests/pins/build_rows.py
import json
from pathlib import Path
from billing.invoice import build_rows


def test_matches_golden():
    golden = json.loads(Path("tests/golden/rows.json").read_text())
    assert build_rows({"id": 7, "items": [{"sku": "a", "qty": 2}]}) == golden

One rule keeps goldens honest. Regenerate them only in a commit that touches zero source files. A commit with source and golden changes together is a behavior change wearing a test's clothes.

6. Order the work in three passes

Ordering matters more than diff size. Sort the inventory once, then spend the budget in passes.

  1. Pass A: helpers scoring zero to three. No pins required, four files maximum.
  2. Pass B: seams scoring four to six. One pin per touched seam, same file cap.
  3. Pass C: locked seams only, one per pull request, with a waiver and a reviewer outside the change.

Never bundle two locked seams in one pull request. If both break, you cannot attribute the breakage to either.

Where free model access and the free server fit

MonkeyCode offers free model access and a free server option. Both are useful at the edges of this workflow, not its center.

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

I used the free model access to draft the initial EFFECT_CALLS list and to argue about the weight table. Both drafts were wrong in useful ways, and both were edited by hand against the repository. The inventory job runs on the free server option so it does not compete with local build jobs. No claims about limits, speed, or permanence follow from that, because none were verified here.

Keep the model out of the gate itself. A scoring gate must be deterministic and offline.

Limitations, and who should skip this

Static ast scanning misses dynamic dispatch, getattr, plugin registries, and decorators that rewrite signatures. Treat the inventory as a lower bound. Nested functions are scanned as separate seams, which inflates counts in closure-heavy code.

The weights rank seams; they do not measure correctness. A repo with no working test runner gets nothing from step five. Hotfixes should not wait on this gate, so allow an explicit waiver comment with a named expiry date and a required follow-up issue.

Skip the whole approach if you are prototyping. Skip it too if your team cannot keep seams.json regenerated in the same commit as structural changes. A stale inventory produces confident, wrong blocks.

Start with one package

Pick the package with the most open bug reports. Run the inventory there and compare the locked list against the functions your team already argues about. If the two lists overlap by more than half, the score is calibrated enough to gate a pull request.

來源:DEV Community · dev.to