mechanical-refactor-verify

Make mechanical refactoring (file splits, function moves, module extractions, renames) machine-checkable instead of eyeballed. Reproduce a relocation commit byte-for-byte from faithful primitives, and split an extraction into a verifiable prepare + move + postpare. Use when doing or reviewing such changes.

Install
npx skills add 'https://github.com/sgl-project/sglang/tree/main/.claude/skills/mechanical-refactor-verify'
Download bundle ↓
main · a9fb1c3Scanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

guide-split.md

guide-split.mdBrowse 41 files
View on GitHub
← Back to SKILL.md

Split a mechanical refactor

  • Two levels of splitting, one chapter each: §1 splits the PR/branch into small classified pieces (the chain contract); §2 splits one piece into prepare + move + postpare so its move is provable.

1. Split the PR into small verifiable pieces

1.1 The chain contract — what a compliant branch satisfies

When asked to make (or fix) a refactor branch so it "satisfies this skill", ALL of the following must hold over base..branch; run the chain verifier (guide-verify-proof.md §1) to check the machine-checkable part in one command.

  1. Every commit is classified, in the required subject format:

    <group-id>(<commit-id>,<kind>): <message>

    with <kind> exactly mechanical_provable or non_mechanical_provable, and <group-id> / <commit-id> kebab-case (contiguous same-<group-id> commits form one future PR). The verifier machine-checks the standalone-word rule (spec-reproduction-cli.md §2.1); the full format is required on top of it so the chain can be grouped into PRs.

  2. Classification is correct — mechanical work is labeled mechanical. Every operation expressible as the whitelisted relocations (an extract-function, a bulk move, a file split, an import repoint, …) is its own mechanical_provable commit. Hiding provable content inside a non_mechanical_provable commit — dodging the verifier — is forbidden (§2.2 maximality); catching it is the reviewer's duty (guide-verify-proof.md §1). How to split so this holds: §2.

  3. Every mechanical_provable commit has a proof that PASSes. Produce the proofs with the generator (guide-construct-proof.md §1); the chain verifier re-runs every one of them against the proof folder.

  4. Every non_mechanical_provable commit is correctness-reviewed by eyes. Its diff must be confirmed to do exactly what its message claims: no lost logic, no hidden bug, no unintended behavior change riding along (guide-verify-proof.md §1). When the commit claims to be behavior-preserving that means checking equivalence — but a chain need not be a pure refactor, and a commit that intentionally changes behavior is reviewed for the correctness of that change instead. The machine never certifies these — that is exactly why they must stay minimal (item 2).

1.2 Commit naming and classification

  • The subject format is exactly §1.1 item 1 — no reserved phase suffixes are required. The <commit-id> is free (naming it after the phase, e.g. foo-prepare / foo-move, is fine but optional).
  • The phases map onto the classification word directly: move commits declare mechanical_provable; prepare, postpare, and standalone semantic commits declare non_mechanical_provable.
  • The generator's range command selects the provable commits by the word itself: --match '(?<!_)mechanical_provable' (the lookbehind keeps non_mechanical_provable from matching).

2. Split one piece into prepare + move + postpare

2.1 Why split

  • A "move a method/function" change is really two operations with different correctness criteria:
OperationWhat it doesHow you check it
Semantic reshapemethod → free function or method; self.X → a parameter, or self retyped to the target class; signature / typing changebehavior unchanged: lint + tests pass
Physical movecut from the source, paste into the target, fix importsthe moved body is byte-identical, line for line; the only other changes are move artifacts
  • Put both in one commit and the criteria contaminate each other:
    • one hunk then holds the reshape and an indentation shift and a cross-file relocation;
    • neither a human nor a tool can mechanically confirm "the body that landed is the body that left" — you must re-read the logic.

2.2 The rule — up to three commits, in this order

  • prepare (optional) — a minimal in-place reshape the relocation needs (de-self a method, retype self). Human-reviewed, so: small, no cross-file def relocation, no body relocation — the code stays where it is.
  • move — the pure relocation; carries the bulk; certified by the reproduce proof (guide-construct-proof.md; property: spec-reproduction-utils.md).
  • postpare (optional) — a minimal tail fixup the move cannot do mechanically (a module path inside a string literal, a doc reference). Human-reviewed.

Hard lines ("prep" below = the prepare phase):

  • Both ends are optional, minimal, and covered by tests; neither ever relocates a def across files or moves a body.

  • The move-artifact whitelist is what a relocation forcesnot a licence to fold reshape work into the move. Anything outside the artifacts in the move's diff = the reshape leaked; push it back into prep.

  • A large semantic refactor is not a phase. Consolidating bookkeeping, deduplicating logic, restructuring control flow, redesigning an API → its own commit, reviewed for equivalence (tests or a written argument). Never smuggled into prep as a "small reshape".

  • Provable content never hides in a non-provable commit. The dual of the previous two rules: a commit declared non_mechanical_provable must be the minimal residue the relocation primitives cannot express. Any part reproducible as whitelisted relocations (spec-reproduction-utils.md §2.1) — a def moved across files, a scattered extract, an import repoint riding along — is split into its own mechanical_provable commit with a proof, never folded into a semantic commit where the verifier cannot see it. Declaring provable work non-provable to dodge the verifier violates the chain property (spec-reproduction-cli.md §2.1); the reviewer is instructed to hunt for exactly this (guide-verify-proof.md §1).

  • "Semantic" is not banned from prepare — oversized or hidden semantics are. prepare's own edits are meaning-carrying (de-self, retype-self, co-locating bookkeeping); "minimal" caps their size, it does not forbid semantics. The two bans are narrower: (1) no semantic change inside the move commit — the move is a pure relocation; and (2) don't pass a large reshape off as a trivial "small reshape" to dodge the equivalence review. A large but honestly-labeled, equivalence-reviewed reshape placed before the move is legitimate — that is exactly what "its own commit" means, and it may serve as the prepare.

  • The prep's shape depends on the destination: a module-level function (§2.3) or a class (§2.4).

  • The move is the same idea in both: a pure relocation, body byte-identical.

2.3 Case 1: method → free function

2.3.1 Commit 1 — prep: de-self in place (no relocation)

Reshape the method in its original file and position so it no longer needs self. The body stays put:

  • self.X (read) → pass X in as a parameter.

  • self.X = v (write) → return v; the caller assigns. (Or pass an explicit mutable object.)

  • self.other_method(...) → prep that method in the same commit, or inject it as a Callable argument.

  • Once self is gone → mark @staticmethod; the body does not move.

  • Call site: self.foo(args)TheClass.foo(args).

  • Seed the destination's module-level scaffolding here too, if the target module lacks what the moved body needs — a logger = logging.getLogger(__name__), a module-level constant the body reads (_is_hip = is_hip()), and the import each requires. This is destination groundwork (like a class skeleton, §2.7.4), not the body: adding it in prep keeps the move a pure cut+paste. Folding it into the move instead bundles a non-relocation edit and breaks the byte proof (the move would both paste the body and author a new logger, which the whitelist does not forgive). A move into a new module is the exception — there the whole header, logger included, is authored in the move itself (§2.5).

  • The decorator and the qualifier are the only artifacts the move will carry — exactly what the whitelist (spec-reproduction-utils.md §2.1) forgives.

Check: lint + tests pass; the diff is the body reshape, the call-site qualifier, and any destination scaffolding seeded above; nothing moved.

2.3.2 Commit 2 — move: relocate to the module

  • Cut the @staticmethod block; paste into the target module.
  • Drop @staticmethod, dedent to module level — body unchanged, line for line.
  • Source file: import the moved symbol; drop now-unused imports.
  • Call site: TheClass.foo(args)foo(args) (args untouched).

Check: mechanical_refactor_proof_generator.py <commit> reports PASS. Cross-check: git show <commit> --color-moved=dimmed-zebra --color-moved-ws=allow-indentation-change marks the whole block as moved.

2.4 Case 2: method → method on a class

  • For pulling several methods and the fields they touch into a new (or existing) class.
  • Prep does not de-self — it builds the class and retypes self, body untouched.

2.4.1 Commit 1 — prep: build the class, retype self

  1. Create the target class with the fields the moved methods touch (a frozen dataclass is simplest; drop frozen only if they mutate).

  2. Wire an instance into the call path — composition (self.component = Target(...) in the source ctor), construction at the call site, or temporarily both.

  3. Retype each moved method as a @staticmethod whose parameter is still named self but typed as the target class — body unchanged:

    class Source:
        component: Target
    
        @staticmethod
        def foo(self: Target) -> None:
            ...  # body still reads self.field_a / self.field_b
  4. Caller: self.foo(...)Source.foo(self.component, ...).

Why keep the name self:

  • it is an ordinary parameter name, so every self.X resolves against the target class statically and at runtime (the argument is a target-class instance);
  • renaming it would rewrite every self.X and destroy the "body unchanged across both commits" invariant.

Boundaries:

  • Prep stays minimal. Signature redesign, helper extraction, parameter objects, mutate→return, renames, method splits, dead-branch removal → later non-mechanical commits, never prep.

  • Runtime-mutable state → inject a Callable getter (still prep). State that changes every step (counters, the current batch, running stats): inject Callable[[], T] into the target ctor; rewrite self.Xself.get_X(). Do not thread it per call and do not reach back into the source object — per-call kwargs make every call site noisy, the API non-self-contained, and the threading a caller chore.

    class Target:
        def __init__(self, *, static_field, get_running_state: "Callable[[], State]"):
            self.static_field = static_field
            self.get_running_state = get_running_state
    
        @staticmethod
        def check(self: "Target") -> None:
            running = self.get_running_state()   # was self.running_state
            ...
    # source ctor
    self.component = Target(
        static_field=...,
        get_running_state=lambda: self.running_state,
    )

Check: lint + tests pass; body unchanged; types check (self: Target matches the instance the caller passes).

2.4.2 Commit 2 — move: relocate into the class

  • Cut foo into the target class; drop @staticmethod — body unchanged, line for line.
  • Header: def foo(self: Target)def foo(self) (type redundant inside the class).
  • Caller: Source.foo(self.component, ...)self.component.foo(...) — the receiver moves out of the argument list (replayed by lower_call_sites).

Check: mechanical_refactor_proof_generator.py <commit> reports PASS. The split paid off: prep left the body untouched, so the move is a clean cut/paste.

2.5 Case 3: extract to a new module — one move commit, no prep

  • The move gathers the defs from wherever they sit — no prep staging at the source tail. Replayed by extract_symbols_to_new_module.
  • Each def/class is cut verbatim (the byte diff certifies the bodies); the new file's small header (imports, a logger, constants, a TYPE_CHECKING block) is authored from the target and audited (spec-reproduction-utils.md §2.1).
  • A module-level constant that moved into the header (e.g. _is_hip = is_hip()) is dropped from the source too.
  • The only work outside the move: a non-mechanical reference the move cannot derive (a string-literal module path) — a one-line postpare.
  • A symbol not top-level in the source (a method still in a class): prepare de-selfs it out first (§2.3); the proof reports UNSUPPORTED until then.

2.6 Case 4: extract-function — the bulk goes in the move

  • The relocated body belongs in a certified move, not buried in a prep: the extract_function primitive cuts the inline block verbatim and authors only the interface (signature, optional return, the replacing call).
  • Faithful only when the body moves unchanged. De-self, control-flow restructure, or a bookkeeping change folded in → do that as a separate semantic commit (reviewed for equivalence) first, then move the now-unchanged body.
  • An extraction that rewrites the body as it extracts is a semantic commit, not a certifiable move — do not dress it up as one.

2.7 Remarks

2.7.1 A move never renames

  • The moved symbol keeps the same name on both sides.
  • A rename — even a privacy flip _foofoo — is its own single-purpose commit before the move (rename in place, update call sites).
  • A move that also renames cannot be machine-certified: split it — rename first, then move.

2.7.2 Anti-pattern: prep adds the body, move deletes it

  • Symptom: prep adds a large block to the target; the move deletes the same block from the source. The order is reversed.
  • Correct order: prep leaves the body in the source (target skeleton, header retype, caller qualification only); the move does the cut/paste.
  • The body appears and disappears exactly once — on the move side. Fix by pushing the "add the body" work out of prep into the move.

2.7.3 Anti-pattern: the giant prep (relocating inside the source to stage the move)

  • Symptom: the prep's diff is hundreds of lines for a single function — because it also moved the function to the source file's tail, rewrote it as a free function next to a staged import/constant block, or reordered its neighbors so the move can cut one contiguous block.
  • All of that staging is unnecessary: extract_symbols_to_new_module gathers symbols from wherever they sit (§2.5) — the move needs no contiguity and no tail parking.
  • A prep's legitimate diff is the handful of lines the primitives cannot derive: the @staticmethod decorator, the kwargs signature, self.x → parameter reads, an added return, the class-qualified call site. For one function that is tens of lines, not hundreds — a prep in the hundreds is the signal the relocation leaked into it.
  • Why it matters: every relocated-but-not-certified line in a prep is a line the machine never checks and a reviewer must eyeball; parking blocks mid-file also leaves broken or duplicated intermediate states (a staged import for a module that does not exist yet).
  • Fix: strip the prep back to the interface edits above, leave the body in place, and let the certified move do all relocation.

2.7.4 When NOT to split (single commit)

  • Moving an already module-level free function.
  • Pure file rename / whole-file move.
  • Trivial field deletion, or getattr(obj, "x", ...) → direct attribute access.
  • A class-internal helper relocated next to another helper in the same module.

2.7.5 Which actions are mechanical vs not

  • Boundary: building the component correctly the first time is mechanical; reshaping it after it exists is not.
ActionBucket
target class skeleton + ctor + fieldsmechanical (prep)
destination module scaffolding (a logger, a module-level constant) the moved symbol needs, when moving into an existing modulemechanical (prep)
@dataclass(frozen=True, slots=True, kw_only=True) decorationmechanical (prep)
composition wiring (self.component = Target(...))mechanical (prep)
Callable getter injection for runtime-mutable statemechanical (prep)
platform conditionals carried along with the bodymechanical (prep / move)
cross-file import path rewritesmechanical (move)
field-ownership migration into the component ctormechanical (a single pre-step)
inlining an init_* method body into a ctormechanical (a single pre-step)
privacy flip (_xx)mechanical (a single rename)
signature redesign (new kwargs, changed defaults, positional → kw-only)not mechanical
body simplification / dead-branch removal / logic rewritenot mechanical
semantic method renamenot mechanical
  • The smaller the prep, the easier "behavior unchanged" is to confirm.
  • Many small, independently reviewable commits beat one big prep mixing ten flavors of change.
  • Review order = commit order: prep → move → non-mechanical follow-ups.

2.7.6 Anti-pattern: the non-mechanical label as an escape hatch

  • Symptom: a commit whose body is a pure relocation (a cut+paste move, a module-level constant move, a verbatim inline-block extract) is labelled non_mechanical_provable and ships with no proof — because the generator reported UNSUPPORTED or a primitive could not express the exact insertion point, so the author reached for the softer label instead of a proof.
  • Real example from this repo's history: kvc-move-lazy-compaction-gate relocated the module-level _should_enable_lazy_compaction unchanged into kv_cache_configurator.py but was labelled non_mechanical_provable, because it had to land above an if TYPE_CHECKING: guard and move_symbol only anchored with before=, which overshot past the guard. The relocation was fully mechanical; only the tool's insertion-anchor was missing — so the fix was to add a move_symbol(after=) anchor and prove it, not to keep the softer label. (A sibling commit that moves a contiguous block of constants plus their leading comment needs a block-move primitive the toolkit does not yet have — that one is still awaiting an enhancement, which is the correct disposition, not a relabel.)
  • The rule, in order:
    1. A pure relocation must be mechanical_provable and carry a proof. The label is a claim about the change, not about how easy the tooling made it.
    2. Generator says UNSUPPORTED but the change is a relocation → hand-write the Repro from the same primitives (guide-construct-proof.md §2.3). Inference falling short is not a licence to drop the proof.
    3. A primitive genuinely cannot express the faithful edit (the missing after= anchor above) → enhance the primitive first, then prove it. The fix for a tooling gap is to close the gap, not to relabel the commit as unprovable.
  • Only a change that is genuinely not a relocation (a signature redesign, a logic rewrite, a de-self restructure) earns non_mechanical_provable. If you cannot say which non-relocation edit justifies the label, the label is wrong.
Referenced from SKILL.md