scripts/mechanical_refactor_proof_generator.py
scripts/mechanical_refactor_proof_generator.pyBrowse 41 files
15,125 tokens
64,274 bytes
Token encoding: o200k_base
Snapshot a9fb1c3
← Back to SKILL.md
1"""Infer a faithful reproduce recipe for a move commit, then emit and run a self-contained2reproduce script. Lets the verifier turn a commit a formatter re-wrapped into an auditable,3runnable reproduce script -- no one hand-writes it.4 5A recipe is inferred from the commit's diff and its before-state AST: which symbols moved6(src -> dst, into which class, or into a new module), which call sites were adapted, which7imports were repathed, and the symmetric module-level import diff each file gained or lost8(realised directly with add_import / remove_imported_name, since an import diff is always9whitelisted).10``recipe_to_script`` emits a standalone ``repro_scripts/<sha>.py`` (importing only the11reproduce util); running it reproduces the commit, diffs it byte-for-byte, and exits12non-zero unless the diff is empty (PASS).13``generate_range`` writes a whole folder (scripts + output.log + output.html) for a range.14 15Handles a method moved onto an existing class (call sites lowered), a method moved to a16module-level free function (call sites requalified), a free-function-source move to an17existing module (callers repath their import), a new-file extract -- where the prep18commit staged the whole module body (scaffolding plus def) as a trailing block in the19source, so the move cuts that tail into the new file (extract_to_new_module) -- and an20intra-file inline-block extract-function (a new helper whose verbatim body is a block carved21from a sibling function, that block replaced by a call). A rename or a statement-level22reorder relocates no def and is reported unsupported. Runnable directly:23 24 python3 mechanical_refactor_proof_generator.py <commit>25 python3 mechanical_refactor_proof_generator.py <base>..<tip> \26 --match '(?<!_)mechanical_provable' --out DIR27"""28 29import ast30import html31import json32import re33import subprocess34import sys35from dataclasses import asdict, dataclass, field36from pathlib import Path37 38sys.path.insert(0, str(Path(__file__).resolve().parent))39 40import mechanical_refactor_reproduction_utils as rr41 42 43def _git_output(args: list[str], cwd: str) -> str:44 """Raw stdout of a git command ("" if it fails). Not stripped, so ``ast`` line numbers45 stay aligned with a file's real lines."""46 result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)47 return result.stdout if result.returncode == 0 else ""48 49 50def _repo_root() -> str:51 return subprocess.run(52 ["git", "rev-parse", "--show-toplevel"],53 capture_output=True,54 text=True,55 check=True,56 ).stdout.strip()57 58 59def _removed_symbol_names(lines: list[str]) -> set[str]:60 """Names of top-level defs and classes among removed diff lines (the extract's source61 relinquishes these), so a moved class is found, not just a moved def."""62 return {63 m.group(2)64 for ln in lines65 if (m := re.match(r"\s*(?:async\s+)?(def|class)\s+(\w+)", ln))66 }67 68 69def _def_indent(lines: list[str], name: str) -> int | None:70 for line in lines:71 match = re.match(r"(\s*)(?:async\s+)?def\s+" + re.escape(name) + r"\b", line)72 if match:73 return len(match.group(1))74 return None75 76 77def _per_file_diff(commit: str, root: str) -> dict[str, dict]:78 """Per-file removed/added content lines (whitespace intact) and a new-file flag."""79 out = _git_output(80 ["show", commit, "--format=", "--no-color", "--no-ext-diff"], root81 )82 files: dict[str, dict] = {}83 path: str | None = None84 in_hunk = False85 for line in out.splitlines():86 header = re.match(r"diff --git a/(.*) b/(.+)$", line)87 if header:88 path = header.group(2)89 files[path] = {"removed": [], "added": [], "new": False, "deleted": False}90 in_hunk = False91 elif line.startswith("new file"):92 files[path]["new"] = True93 elif line.startswith("deleted file"):94 files[path]["deleted"] = True95 elif line.startswith("@@"):96 in_hunk = True97 elif in_hunk and line.startswith("+"):98 files[path]["added"].append(line[1:])99 elif in_hunk and line.startswith("-"):100 files[path]["removed"].append(line[1:])101 return files102 103 104def _enclosing_function(tree: ast.AST, lineno: int) -> str | None:105 best: tuple[int, str] | None = None106 for node in ast.walk(tree):107 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):108 if node.lineno <= lineno <= node.end_lineno:109 if best is None or node.lineno > best[0]:110 best = (node.lineno, node.name)111 return best[1] if best else None112 113 114def _enclosing_class_of_def(tree: ast.AST, name: str) -> str | None:115 for node in ast.walk(tree):116 if isinstance(node, ast.ClassDef):117 for child in node.body:118 if (119 isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))120 and child.name == name121 ):122 return node.name123 return None124 125 126def _delegate_stub_attr(tree: ast.AST, name: str) -> tuple[str, str] | None:127 """The component attribute a forwarding stub ``def name``: ``return self.<attr>.<m>(...)``128 delegates through, with the forwarded method name -- None when no such stub exists.129 """130 for node in ast.walk(tree):131 if not (132 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))133 and node.name == name134 ):135 continue136 if (137 len(node.body) == 1138 and isinstance(node.body[0], ast.Return)139 and isinstance(node.body[0].value, ast.Call)140 and isinstance(node.body[0].value.func, ast.Attribute)141 and isinstance(node.body[0].value.func.value, ast.Attribute)142 and isinstance(node.body[0].value.func.value.value, ast.Name)143 and node.body[0].value.func.value.value.id == "self"144 ):145 return (node.body[0].value.func.value.attr, node.body[0].value.func.attr)146 return None147 return None148 149 150def _nested_in_function(tree: ast.AST, name: str) -> bool:151 target = rr._find_def(tree, name)152 if target is None:153 return False154 for node in ast.walk(tree):155 if (156 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))157 and node is not target158 and node.lineno <= target.lineno <= node.end_lineno159 ):160 return True161 return False162 163 164def _module_of_path(path: str) -> str:165 return path.removeprefix("python/").removesuffix(".py").replace("/", ".")166 167 168def _import_pairs(text: str) -> dict:169 """Module-level imports keyed one entry per imported name, so removing a single name from170 a multi-name ``from x import a, b`` is not mistaken for the whole statement changing. The171 value is the one-name ``add_import`` text for a gained name (the import sorter merges it).172 """173 pairs: dict = {}174 for node in ast.parse(text).body:175 if isinstance(node, ast.Import):176 for alias in node.names:177 stmt = "import " + alias.name178 if alias.asname:179 stmt += f" as {alias.asname}"180 pairs[stmt] = stmt181 elif isinstance(node, ast.ImportFrom):182 module = "." * node.level + (node.module or "")183 for alias in node.names:184 name = alias.name + (f" as {alias.asname}" if alias.asname else "")185 pairs[(module, alias.name, alias.asname)] = (186 f"from {module} import {name}"187 )188 return pairs189 190 191def _typechecking_pairs(text: str) -> dict:192 """Imports inside the module's ``if TYPE_CHECKING:`` block, keyed per name (same shape as193 ``_import_pairs``), so a type-only import the destination gains for a moved annotation is194 inferable separately from the runtime imports."""195 pairs: dict = {}196 for node in ast.parse(text).body:197 if not (198 isinstance(node, ast.If)199 and ast.unparse(node.test) in ("TYPE_CHECKING", "typing.TYPE_CHECKING")200 ):201 continue202 for stmt in node.body:203 if isinstance(stmt, ast.ImportFrom):204 module = "." * stmt.level + (stmt.module or "")205 for alias in stmt.names:206 name = alias.name + (f" as {alias.asname}" if alias.asname else "")207 pairs[(module, alias.name, alias.asname)] = (208 f"from {module} import {name}"209 )210 elif isinstance(stmt, ast.Import):211 for alias in stmt.names:212 stmt_text = "import " + alias.name213 if alias.asname:214 stmt_text += f" as {alias.asname}"215 pairs[stmt_text] = stmt_text216 return pairs217 218 219def _local_import_of(220 tree: ast.AST, fn_name: str, module: str, symbol: str221) -> str | None:222 fn = rr._find_def(tree, fn_name)223 if fn is None:224 return None225 for node in ast.walk(fn):226 if (227 isinstance(node, ast.ImportFrom)228 and node.module == module229 and any(alias.name == symbol for alias in node.names)230 ):231 return f"from {module} import {symbol}"232 return None233 234 235def _removal_from_key(path: str, key) -> dict:236 """Turn an ``_import_pairs`` key the target dropped into a ``remove_imported_name`` call. A237 ``(module, name, asname)`` key drops one name from a ``from`` import; a bare ``import x``238 statement string drops a plain import."""239 if isinstance(key, tuple):240 module, name, asname = key241 return {"path": path, "module": module, "name": name, "asname": asname}242 rest = key.removeprefix("import ")243 if " as " in rest:244 name, asname = rest.split(" as ", 1)245 else:246 name, asname = rest, None247 return {"path": path, "module": None, "name": name, "asname": asname}248 249 250def _module_assign_names(text: str) -> set:251 """Names bound by module-level assignments (``logger = ...``, ``_is_hip = is_hip()``), so a252 constant relocated into an extracted module can be told apart from one the source keeps.253 """254 names: set = set()255 for node in ast.parse(text).body:256 targets = (257 node.targets258 if isinstance(node, ast.Assign)259 else [node.target]260 if isinstance(node, ast.AnnAssign)261 else []262 )263 names |= {t.id for t in targets if isinstance(t, ast.Name)}264 return names265 266 267def _import_additions(268 path: str, after: str, before_pairs: dict, after_pairs: dict269) -> list:270 """The module-level imports a file gained, as ``add_import`` texts. A name gained from a271 module the file already imported is added per-name (the sorter merges it); a name from a272 wholly new module is added as the target's *verbatim* statement, so a multi-line or273 magic-trailing-comma wrapping the target chose is reproduced (a freshly merged single line274 would otherwise collapse and not match)."""275 before_modules = {key[0] for key in before_pairs if isinstance(key, tuple)}276 additions: list = []277 verbatim_modules: set = set()278 for key in after_pairs:279 if key in before_pairs:280 continue281 if isinstance(key, tuple) and key[0] not in before_modules:282 verbatim_modules.add(key[0])283 else:284 additions.append({"path": path, "text": after_pairs[key]})285 if verbatim_modules:286 after_lines = after.splitlines(keepends=True)287 for node in ast.parse(after).body:288 if (289 isinstance(node, ast.ImportFrom)290 and "." * node.level + (node.module or "") in verbatim_modules291 ):292 text = "".join(after_lines[node.lineno - 1 : node.end_lineno])293 additions.append({"path": path, "text": text.rstrip("\n")})294 return additions295 296 297@dataclass298class Recipe:299 base: str300 target: str301 supported: bool = True302 moves: list = field(default_factory=list)303 assign_moves: list = field(default_factory=list)304 extracts: list = field(default_factory=list)305 extract_functions: list = field(default_factory=list)306 scatter_extracts: list = field(default_factory=list)307 lowerings: list = field(default_factory=list)308 repaths: list = field(default_factory=list)309 import_removals: list = field(default_factory=list)310 module_import_removals: list = field(default_factory=list)311 import_additions: list = field(default_factory=list)312 typechecking_additions: list = field(default_factory=list)313 deletes: list = field(default_factory=list)314 notes: list = field(default_factory=list)315 316 317def _infer_call_adaptations(318 recipe: Recipe,319 files: dict[str, dict],320 *,321 name: str,322 src: str,323 src_class: str,324 into_class: str | None,325 commit: str,326 root: str,327) -> None:328 """A method-source move adapts its call sites: a move onto a class lowers the receiver329 out of the args; a move to a module-level free function drops the qualifier. A caller is330 a before-state call ``<src_class>.name(...)`` -- matched on ``src_class`` (not a loose331 text search) so the moved body's own same-named calls on a different receiver are332 excluded -- and its orphaned local import of ``src_class`` is removed."""333 kind = "lower" if into_class is not None else "requalify"334 src_module = _module_of_path(src)335 for path, f in files.items():336 before = _git_output(["show", f"{commit}^:{path}"], root)337 try:338 tree = ast.parse(before)339 except SyntaxError:340 continue341 caller_fns: set[str] = set()342 for node in ast.walk(tree):343 if (344 isinstance(node, ast.Call)345 and isinstance(node.func, ast.Attribute)346 and node.func.attr == name347 and (node.args or kind == "requalify")348 and ast.unparse(node.func.value) == src_class349 ):350 fn = _enclosing_function(tree, node.lineno)351 if fn is not None:352 caller_fns.add(fn)353 if not caller_fns:354 continue355 recipe.lowerings.append(356 {"name": name, "owner": src_class, "path": path, "kind": kind}357 )358 for fn in sorted(caller_fns):359 imp = _local_import_of(tree, fn, src_module, src_class)360 if imp is not None and any(imp in r for r in f["removed"]):361 recipe.import_removals.append(362 {"path": path, "text": imp, "in_function": fn}363 )364 365 366def _infer_function_scoped_repaths(367 recipe: Recipe,368 files: dict[str, dict],369 *,370 name: str,371 src: str,372 dst: str,373 commit: str,374 root: str,375) -> None:376 """A moved free function keeps the same bare call, so a caller only repaths its import.377 Module-level repaths fall out of the symmetric import diff; only function-scoped imports378 (which ``add_import`` cannot place) need an explicit in-place repath."""379 src_module = _module_of_path(src)380 dst_module = _module_of_path(dst)381 for path in sorted(files):382 if path == src:383 continue384 before = _git_output(["show", f"{commit}^:{path}"], root)385 try:386 tree = ast.parse(before)387 except SyntaxError:388 continue389 top_level = {id(node) for node in tree.body}390 nested = any(391 isinstance(node, ast.ImportFrom)392 and id(node) not in top_level393 and node.module == src_module394 and any(alias.name == name for alias in node.names)395 for node in ast.walk(tree)396 )397 if nested:398 recipe.repaths.append(399 {400 "path": path,401 "old_module": src_module,402 "new_module": dst_module,403 "name": name,404 }405 )406 407 408def _self_annotation_dropped(src_def: ast.AST | None, dst_def: ast.AST | None) -> bool:409 """Whether the move drops a ``self: Target`` annotation -- the source had it and the410 destination does not. Some class moves keep it (a retyped self that stays annotated), so411 this is inferred from both sides rather than assumed."""412 413 def has_self_annotation(node: ast.AST | None) -> bool:414 return bool(415 node is not None416 and node.args.args417 and node.args.args[0].arg == "self"418 and node.args.args[0].annotation is not None419 )420 421 return has_self_annotation(src_def) and not has_self_annotation(dst_def)422 423 424def _wants_future_import(files: dict[str, dict], src: str, dst: str) -> bool:425 future = "from __future__ import annotations"426 gained = any(future in line for line in files[dst]["added"])427 travelled = any(future in line for line in files[src]["removed"])428 return gained and not travelled429 430 431def _next_sibling_def_name(432 dst_tree: ast.AST, name: str, into_class: str | None433) -> str | None:434 """The name of the def that immediately follows ``name`` at its scope in the destination435 (module level, or inside ``into_class``), or None when ``name`` is the last def there. Lets436 a move reinsert the relocated def in the chain's order instead of appending at the end.437 """438 container: list = []439 if into_class is not None:440 cls = rr._find_class(dst_tree, into_class)441 container = cls.body if cls is not None else []442 else:443 container = getattr(dst_tree, "body", [])444 defs = [445 n446 for n in container447 if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))448 ]449 for i, node in enumerate(defs):450 if node.name == name:451 return defs[i + 1].name if i + 1 < len(defs) else None452 return None453 454 455def _next_sibling_assign_or_def(dst_tree: ast.AST, name: str) -> str | None:456 """The name of the top-level statement (def/class/single-Name assign) that immediately457 follows the assignment ``name`` in the destination, or None when it is last."""458 459 def stmt_name(node: ast.AST) -> str | None:460 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):461 return node.name462 if (463 isinstance(node, ast.Assign)464 and len(node.targets) == 1465 and isinstance(node.targets[0], ast.Name)466 ):467 return node.targets[0].id468 if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):469 return node.target.id470 return None471 472 named = [(stmt_name(n), n) for n in getattr(dst_tree, "body", []) if stmt_name(n)]473 for i, (nm, _) in enumerate(named):474 if nm == name:475 return named[i + 1][0] if i + 1 < len(named) else None476 return None477 478 479def _stmt_symbol_name(node: ast.AST) -> str | None:480 """The name a top-level statement defines -- a def/class name, or a single-Name481 assignment target -- else None (an ``if TYPE_CHECKING:`` guard, a tuple assign, ...).482 """483 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):484 return node.name485 if (486 isinstance(node, ast.Assign)487 and len(node.targets) == 1488 and isinstance(node.targets[0], ast.Name)489 ):490 return node.targets[0].id491 if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):492 return node.target.id493 return None494 495 496def _module_move_anchor(497 dst_tree: ast.AST, name: str, into_class: str | None498) -> tuple[str | None, str | None]:499 """The ``(before, after)`` anchor for reinserting the moved def ``name``. Normally500 ``before=<next sibling def>``. But when a module-level def lands immediately above an501 unnameable statement (e.g. an ``if TYPE_CHECKING:`` guard) with a nameable statement502 immediately above it, a ``before`` anchor would resolve to the next def *past* that block503 and overshoot, so anchor with ``after=<preceding symbol>`` instead."""504 before = _next_sibling_def_name(dst_tree, name, into_class)505 if into_class is not None:506 return before, None507 body = list(getattr(dst_tree, "body", []))508 idx = next(509 (510 i511 for i, n in enumerate(body)512 if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))513 and n.name == name514 ),515 None,516 )517 if idx is None:518 return before, None519 following = body[idx + 1] if idx + 1 < len(body) else None520 next_is_named_def = isinstance(521 following, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)522 )523 if following is None or next_is_named_def:524 return before, None525 preceding = body[idx - 1] if idx > 0 else None526 prev_name = _stmt_symbol_name(preceding) if preceding is not None else None527 if prev_name is None:528 return before, None529 return None, prev_name530 531 532def _symbols_form_tail(src_text: str, symbols: list[str]) -> bool:533 """Whether ``symbols`` sit at the end of the source as a contiguous block of defs/classes534 and the scaffolding leading into them -- the trailing block a prep commit stages for a535 new-module extract. A method still inside a class, or a symbol separated from the tail by536 other code, fails this and is not an extractable tail."""537 body = ast.parse(src_text).body538 wanted = set(symbols)539 540 def is_scaffolding(node: ast.stmt) -> bool:541 if isinstance(node, (ast.Import, ast.ImportFrom)):542 return True543 if isinstance(node, ast.If):544 return ast.unparse(node.test) in ("TYPE_CHECKING", "typing.TYPE_CHECKING")545 if isinstance(node, ast.Assign):546 return all(isinstance(x, ast.Name) for x in node.targets)547 if isinstance(node, ast.AnnAssign):548 return isinstance(node.target, ast.Name)549 return False550 551 cut = len(body)552 while cut > 0:553 node = body[cut - 1]554 is_symbol = (555 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))556 and node.name in wanted557 )558 if is_symbol or is_scaffolding(node):559 cut -= 1560 else:561 break562 present = {563 node.name564 for node in body[cut:]565 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))566 }567 return wanted <= present568 569 570def _scatter_extract_layout(dst_after: str, symbols: list[str]) -> dict | None:571 """For a new module whose relocated ``symbols`` form a contiguous block at the end (after572 an authored header of imports, constants, a logger, a ``TYPE_CHECKING`` block), return the573 ``header`` text and the ``symbols`` in target order. Returns None when a non-symbol574 statement is interleaved among the symbols, so there is no clean header/body split.575 """576 lines = dst_after.splitlines(keepends=True)577 body = ast.parse(dst_after).body578 wanted = set(symbols)579 580 def is_wanted(node: ast.AST) -> bool:581 return (582 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))583 and node.name in wanted584 )585 586 sym_nodes = [node for node in body if is_wanted(node)]587 if len(sym_nodes) != len(wanted):588 return None589 first_index = body.index(sym_nodes[0])590 if any(not is_wanted(node) for node in body[first_index:]):591 return None592 header = "".join(lines[: rr._def_span(sym_nodes[0])[0] - 1])593 return {"header": header, "order": [node.name for node in sym_nodes]}594 595 596def _iter_defs_with_container(597 tree: ast.AST,598) -> list[tuple[str | None, ast.AST]]:599 """(container_class_name_or_None, def_node) for every module-level function and every600 method one class deep -- the two nesting depths an extract_function helper can land at.601 """602 out: list[tuple[str | None, ast.AST]] = []603 for node in getattr(tree, "body", []):604 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):605 out.append((None, node))606 elif isinstance(node, ast.ClassDef):607 for child in node.body:608 if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):609 out.append((node.name, child))610 return out611 612 613def _statements_parse(lines: list[str]) -> bool:614 """Whether ``lines`` (dedented to their own minimum indent) parse as complete Python615 statements -- used to keep a prefix/suffix split from cutting through the middle of a616 multi-line statement."""617 text = "".join(lines)618 if not text.strip():619 return True620 indents = [len(ln) - len(ln.lstrip(" ")) for ln in lines if ln.strip()]621 dedented = rr.dedent(text, min(indents)) if indents else text622 try:623 ast.parse(dedented)624 return True625 except SyntaxError:626 return False627 628 629def _common_prefix_suffix(a: list[str], b: list[str]) -> tuple[int, int]:630 """Longest common leading and trailing run of identical lines between two line lists,631 kept non-overlapping -- isolates the single contiguous region where they differ. The632 greedy match is then shrunk (suffix first, then prefix) until the differing middle of633 *both* lists parses as complete statements, so a boundary line the removed block and its634 replacement happen to share (e.g. a lone ``)``) is not absorbed mid-statement."""635 prefix = 0636 while prefix < len(a) and prefix < len(b) and a[prefix] == b[prefix]:637 prefix += 1638 suffix = 0639 while (640 suffix < len(a) - prefix641 and suffix < len(b) - prefix642 and a[-1 - suffix] == b[-1 - suffix]643 ):644 suffix += 1645 while suffix > 0 and not (646 _statements_parse(a[prefix : len(a) - suffix])647 and _statements_parse(b[prefix : len(b) - suffix])648 ):649 suffix -= 1650 while prefix > 0 and not (651 _statements_parse(a[prefix : len(a) - suffix])652 and _statements_parse(b[prefix : len(b) - suffix])653 ):654 prefix -= 1655 return prefix, suffix656 657 658def _call_names_in(node: ast.AST) -> set[str]:659 """Names invoked as ``self.<name>(...)`` or ``<name>(...)`` anywhere under ``node``."""660 names: set[str] = set()661 for sub in ast.walk(node):662 if isinstance(sub, ast.Call):663 if isinstance(sub.func, ast.Attribute):664 names.add(sub.func.attr)665 elif isinstance(sub.func, ast.Name):666 names.add(sub.func.id)667 return names668 669 670def _infer_extract_functions(671 recipe: Recipe, files: dict[str, dict], commit: str, root: str672) -> None:673 """Infer intra-file extract_function ops: a new helper ``H`` whose body is a verbatim block674 cut from another function ``F`` of the same file, with ``F``'s block replaced by a call to675 ``H`` (optionally an ``lhs = self.H(...)`` assignment mirrored by a ``return lhs`` the helper676 appends). The relocated body is byte-checked; only the header/call/return are authored. A677 body whose reindent does not reconstruct the helper (a bundled edit) yields no op, so the678 residual surfaces it instead of a false pass."""679 for path, f in files.items():680 if f.get("new") or f.get("deleted"):681 continue682 before_text = _git_output(["show", f"{commit}^:{path}"], root)683 after_text = _git_output(["show", f"{commit}:{path}"], root)684 try:685 before_tree = ast.parse(before_text)686 after_tree = ast.parse(after_text)687 except SyntaxError:688 continue689 before_lines = rr._split_keepends(before_text)690 after_lines = rr._split_keepends(after_text)691 before_defs = _iter_defs_with_container(before_tree)692 before_keys = {(c, n.name) for c, n in before_defs}693 for container, helper in _iter_defs_with_container(after_tree):694 if (container, helper.name) in before_keys:695 continue696 if not helper.body:697 continue698 # The signature is the def header only (through its colon), not everything up to699 # the first statement -- a leading comment sits between them and belongs to the700 # extracted body, not the authored signature.701 helper_text = "".join(after_lines[helper.lineno - 1 : helper.end_lineno])702 header_len = rr._def_header_end(helper_text)703 header_text = "".join(704 after_lines[helper.lineno - 1 : helper.lineno - 1 + header_len]705 )706 # F is the one sibling function that changed and now calls the helper.707 candidates = []708 for cont, node in before_defs:709 after_node = next(710 (711 n712 for c, n in _iter_defs_with_container(after_tree)713 if c == cont and n.name == node.name714 ),715 None,716 )717 if after_node is None or node.name == helper.name:718 continue719 b_lines = before_lines[node.lineno - 1 : node.end_lineno]720 a_lines = after_lines[after_node.lineno - 1 : after_node.end_lineno]721 if b_lines == a_lines:722 continue723 if helper.name not in _call_names_in(after_node):724 continue725 candidates.append((b_lines, a_lines))726 if len(candidates) != 1:727 continue728 f_before, f_after = candidates[0]729 prefix, suffix = _common_prefix_suffix(f_before, f_after)730 block = f_before[prefix : len(f_before) - suffix]731 call_lines = f_after[prefix : len(f_after) - suffix]732 if not block or not call_lines:733 continue734 body_indent = len(block[0]) - len(block[0].lstrip(" "))735 body_text = "".join(block)736 # Detect the authored `return <name>` structurally, by statement count -- the737 # formatter reflows lines differently at the helper's shallower indent, so a738 # byte comparison of the reindented body would spuriously fail; the repro's739 # byte-diff (which runs the formatter) is the real arbiter.740 try:741 block_stmts = ast.parse(rr.dedent(body_text, body_indent)).body742 except SyntaxError:743 continue744 helper_stmts = helper.body745 return_text: str | None = None746 if len(helper_stmts) == len(block_stmts) + 1 and isinstance(747 helper_stmts[-1], ast.Return748 ):749 ret = helper_stmts[-1]750 return_text = "".join(751 after_lines[ret.lineno - 1 : ret.end_lineno]752 ).strip("\n")753 elif len(helper_stmts) != len(block_stmts):754 continue755 recipe.extract_functions.append(756 {757 "src": path,758 "dst": path,759 "name": helper.name,760 "signature": header_text,761 "body": body_text,762 "body_indent": body_indent,763 "call": "".join(call_lines),764 "return_text": return_text,765 "into_class": container,766 "before": _next_sibling_def_name(767 after_tree, helper.name, container768 ),769 }770 )771 772 773def infer_recipe(commit: str, root: str) -> Recipe:774 """Infer a faithful relocation recipe for a move commit from its diff + before-state.775 776 A move onto an existing module/class becomes a ``move_symbol``; a move whose destination777 file is new becomes an ``extract_to_new_module`` (the prep commit staged the whole module778 body -- scaffolding plus def -- as a trailing block in the source, so the move cuts that779 tail into the new file). Method-source moves adapt their call sites; free-function-source780 moves keep the bare call and only repath imports. A rename or a statement-level reorder781 relocates no def, so nothing is inferred and the commit is reported unsupported."""782 all_files = _per_file_diff(commit, root)783 files = {path: f for path, f in all_files.items() if path.endswith(".py")}784 recipe = Recipe(base=f"{commit}~1", target=commit)785 for path in sorted(set(all_files) - set(files)):786 recipe.notes.append(787 f"non-Python file changed: {path} (left to the residual diff)"788 )789 790 def def_names(lines: list[str]) -> set[str]:791 return {792 m.group(1)793 for ln in lines794 if (m := re.match(r"\s*(?:async\s+)?def\s+(\w+)", ln))795 }796 797 def class_names(lines: list[str]) -> set[str]:798 return {m.group(1) for ln in lines if (m := re.match(r"class\s+(\w+)", ln))}799 800 new_files = {p for p, f in files.items() if f["new"]}801 802 # A new file is a staged module body cut from one source: its top-level defs and classes803 # are exactly the relocated symbols (the prep commit inlined them, scaffolding included, as804 # a trailing block in the source). Take the symbol list from the new file itself so a805 # moved class -- not just a moved def -- is in the cut tail.806 for dst in sorted(new_files):807 dst_after = _git_output(["show", f"{commit}:{dst}"], root)808 try:809 dst_body = ast.parse(dst_after).body810 except SyntaxError:811 continue812 symbols = [813 node.name814 for node in dst_body815 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))816 ]817 if not symbols:818 continue819 srcs = {820 p821 for name in symbols822 for p, f in files.items()823 if p not in new_files and name in _removed_symbol_names(f["removed"])824 }825 if len(srcs) != 1:826 recipe.supported = False827 recipe.notes.append(828 f"{dst}: extract source not a single file ({sorted(srcs)})"829 )830 continue831 src = next(iter(srcs))832 src_before = _git_output(["show", f"{commit}^:{src}"], root)833 if _symbols_form_tail(src_before, symbols):834 recipe.extracts.append(835 {836 "src": src,837 "dst": dst,838 "symbols": symbols,839 "future_import": _wants_future_import(files, src, dst),840 }841 )842 continue843 src_top_level = {844 node.name845 for node in ast.parse(src_before).body846 if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))847 }848 if not set(symbols) <= src_top_level:849 recipe.supported = False850 recipe.notes.append(851 f"{dst}: relocated symbols are not all top-level in {src} "852 "(a method still inside a class needs prep to lift it out first)"853 )854 continue855 # The symbols are scattered in the source (not a staged trailing block): cut each one856 # verbatim and assemble the new module under its authored header (imports/constants/857 # logger/TYPE_CHECKING reproduced from the target). The defs are the proven relocation;858 # only the small header is authored, so the prep no longer has to gather them at the859 # source tail first.860 layout = _scatter_extract_layout(dst_after, symbols)861 if layout is None:862 recipe.supported = False863 recipe.notes.append(864 f"{dst}: relocated symbols are not a trailing block in the new module "865 "(a non-symbol statement is interleaved with them)"866 )867 continue868 # A module-level constant the source no longer assigns but the new module does (e.g. a869 # ``_is_hip = is_hip()`` flag) relocated into the header, so it is dropped from the870 # source too -- its copy is reproduced in the authored header.871 src_after = _git_output(["show", f"{commit}:{src}"], root)872 drop_assigns = sorted(873 (_module_assign_names(src_before) - _module_assign_names(src_after))874 & _module_assign_names(dst_after)875 )876 recipe.scatter_extracts.append(877 {878 "src": src,879 "dst": dst,880 "symbols": symbols,881 "header": layout["header"],882 "order": layout["order"],883 "drop_assigns": drop_assigns,884 }885 )886 887 all_removed = [ln for f in files.values() for ln in f["removed"]]888 all_added = [ln for f in files.values() for ln in f["added"]]889 890 # A top-level class relocated between existing files moves as one block: move_symbol891 # cuts the ClassDef; its methods are excluded from the per-def loop below.892 moved_classes: set[str] = set()893 for cname in sorted(class_names(all_removed) & class_names(all_added)):894 csrc = next(895 (p for p, f in files.items() if cname in class_names(f["removed"])), None896 )897 cdst = next(898 (p for p, f in files.items() if cname in class_names(f["added"])), None899 )900 if csrc is None or cdst is None or csrc == cdst or cdst in new_files:901 continue902 cdst_tree = ast.parse(_git_output(["show", f"{commit}:{cdst}"], root))903 cdst_def = rr._find_def(cdst_tree, cname) or next(904 (905 n906 for n in ast.walk(cdst_tree)907 if isinstance(n, ast.ClassDef) and n.name == cname908 ),909 None,910 )911 moved_classes.add(cname)912 recipe.moves.append(913 {914 "name": cname,915 "src": csrc,916 "dst": cdst,917 "into_class": None,918 "from_class": None,919 "dedent": 0,920 "dst_order": cdst_def.lineno if cdst_def else 0,921 "before": _next_sibling_def_name(cdst_tree, cname, None),922 "drop_self_annotation": False,923 }924 )925 926 # A move whose destination already exists becomes a move_symbol (the def relocated in927 # order).928 for name in sorted(def_names(all_removed) & def_names(all_added)):929 src = next(930 (p for p, f in files.items() if name in def_names(f["removed"])), None931 )932 dst = next(933 (p for p, f in files.items() if name in def_names(f["added"]) and p != src),934 None,935 )936 # A def cut and re-added within the same file (no other file gained it) is an937 # in-file reorder -- a move_symbol whose src and dst are that file. A signature or938 # body edit that happens to touch the def line is not a faithful move, but the939 # reproduction's byte-diff surfaces it as a residual, so this never false-passes.940 if dst is None and src is not None and name in def_names(files[src]["added"]):941 dst = src942 if src is None or dst is None or dst in new_files:943 continue944 src_before = _git_output(["show", f"{commit}^:{src}"], root)945 if _nested_in_function(ast.parse(src_before), name):946 recipe.notes.append(f"skip {name}: nested function (moves with parent)")947 continue948 src_tree = ast.parse(src_before)949 dst_tree = ast.parse(_git_output(["show", f"{commit}:{dst}"], root))950 src_indent = _def_indent(files[src]["removed"], name)951 dst_indent = _def_indent(files[dst]["added"], name)952 # The diff's def-line indentation says which same-named def actually moved: a953 # column-0 cut is the module-level def even when a class method shares its name.954 src_class = None if src_indent == 0 else _enclosing_class_of_def(src_tree, name)955 if src_class in moved_classes:956 recipe.notes.append(f"skip {name}: method of relocated class {src_class}")957 continue958 into_class = (959 None if dst_indent == 0 else _enclosing_class_of_def(dst_tree, name)960 )961 try:962 src_def = rr._find_unique_def(963 src_tree, name, from_class=src_class, where=src964 )965 dst_def = rr._find_unique_def(966 dst_tree, name, from_class=into_class, where=dst967 )968 except AssertionError as exc:969 recipe.supported = False970 recipe.notes.append(f"{name}: cannot disambiguate moved def ({exc})")971 continue972 src_indent = src_indent or 0973 dst_indent = dst_indent or 0974 # A same-named def re-added to the source is a forwarding delegate the move975 # leaves behind: a body of exactly `return self.<attr>.<name>(...)` names the976 # component attribute move_symbol authors the stub through.977 leave_delegate = None978 delegate_name = None979 if name in def_names(files[src]["added"]):980 src_after_tree = ast.parse(_git_output(["show", f"{commit}:{src}"], root))981 stub = _delegate_stub_attr(src_after_tree, name)982 if stub is not None:983 leave_delegate, forwarded = stub984 if forwarded != name:985 delegate_name = forwarded986 move_before, move_after = _module_move_anchor(dst_tree, name, into_class)987 recipe.moves.append(988 {989 "name": name,990 "src": src,991 "dst": dst,992 "into_class": into_class,993 "from_class": src_class,994 "dedent": src_indent - dst_indent,995 "dst_order": dst_def.lineno if dst_def else 0,996 "before": move_before,997 "after": move_after,998 "drop_self_annotation": _self_annotation_dropped(src_def, dst_def),999 "leave_delegate": leave_delegate,1000 "delegate_name": delegate_name,1001 }1002 )1003 if src_class is not None:1004 _infer_call_adaptations(1005 recipe,1006 files,1007 name=name,1008 src=src,1009 src_class=src_class,1010 into_class=into_class,1011 commit=commit,1012 root=root,1013 )1014 else:1015 _infer_function_scoped_repaths(1016 recipe, files, name=name, src=src, dst=dst, commit=commit, root=root1017 )1018 1019 # A method whose signature line never changed leaves no def-line in the removed set:1020 # the body was replaced by a forwarding stub in place while the full body landed in1021 # another file. Detect it from the destination's added def + the source's after-state1022 # delegate stub.1023 for name in sorted(def_names(all_added) - def_names(all_removed)):1024 dst = next((p for p, f in files.items() if name in def_names(f["added"])), None)1025 if dst is None or dst in new_files:1026 continue1027 src = None1028 stub_info = None1029 for p in files:1030 if p == dst:1031 continue1032 try:1033 after_tree = ast.parse(_git_output(["show", f"{commit}:{p}"], root))1034 before_tree = ast.parse(_git_output(["show", f"{commit}^:{p}"], root))1035 except Exception:1036 continue1037 stub = _delegate_stub_attr(after_tree, name)1038 if stub is None:1039 continue1040 full_before = rr._find_def(before_tree, name)1041 if full_before is None or _delegate_stub_attr(before_tree, name):1042 continue1043 src, stub_info = p, stub1044 break1045 if src is None:1046 continue1047 src_before = _git_output(["show", f"{commit}^:{src}"], root)1048 src_tree = ast.parse(src_before)1049 dst_tree = ast.parse(_git_output(["show", f"{commit}:{dst}"], root))1050 src_class = _enclosing_class_of_def(src_tree, name)1051 dst_indent = _def_indent(files[dst]["added"], name)1052 into_class = (1053 None if dst_indent == 0 else _enclosing_class_of_def(dst_tree, name)1054 )1055 try:1056 src_def = rr._find_unique_def(1057 src_tree, name, from_class=src_class, where=src1058 )1059 dst_def = rr._find_unique_def(1060 dst_tree, name, from_class=into_class, where=dst1061 )1062 except AssertionError as exc:1063 recipe.supported = False1064 recipe.notes.append(f"{name}: cannot disambiguate moved def ({exc})")1065 continue1066 leave_delegate, forwarded = stub_info1067 move_before, move_after = _module_move_anchor(dst_tree, name, into_class)1068 recipe.moves.append(1069 {1070 "name": name,1071 "src": src,1072 "dst": dst,1073 "into_class": into_class,1074 "from_class": src_class,1075 "dedent": (src_def.col_offset or 0) - (dst_indent or 0),1076 "dst_order": dst_def.lineno if dst_def else 0,1077 "before": move_before,1078 "after": move_after,1079 "drop_self_annotation": _self_annotation_dropped(src_def, dst_def),1080 "leave_delegate": leave_delegate,1081 "delegate_name": forwarded if forwarded != name else None,1082 }1083 )1084 1085 # A module-level constant that vanished from one changed file and appeared in another1086 # relocated with the moved code: realise it as a move_assign.1087 changed_paths = [p for p in files if p.endswith(".py")]1088 texts_before: dict = {}1089 texts_after: dict = {}1090 for p in changed_paths:1091 try:1092 texts_before[p] = (1093 "" if files[p]["new"] else _git_output(["show", f"{commit}^:{p}"], root)1094 )1095 texts_after[p] = _git_output(["show", f"{commit}:{p}"], root)1096 except Exception:1097 continue1098 for p_src in changed_paths:1099 if p_src not in texts_before:1100 continue1101 lost = _module_assign_names(texts_before[p_src]) - _module_assign_names(1102 texts_after.get(p_src, "")1103 )1104 if not lost:1105 continue1106 for p_dst in changed_paths:1107 if p_dst == p_src or p_dst in new_files or p_dst not in texts_after:1108 continue1109 gained = _module_assign_names(texts_after[p_dst]) - _module_assign_names(1110 texts_before.get(p_dst, "")1111 )1112 for cname in sorted(lost & gained):1113 dst_tree = ast.parse(texts_after[p_dst])1114 recipe.assign_moves.append(1115 {1116 "name": cname,1117 "src": p_src,1118 "dst": p_dst,1119 "before": _next_sibling_assign_or_def(dst_tree, cname),1120 }1121 )1122 1123 # Module-level imports a file gained or lost are realised directly from the symmetric1124 # base<->target diff: a gained name is added (the destination needs the moved code's1125 # imports, or a caller of a moved free function gains one), a lost name is removed. An1126 # import diff is always whitelisted, so this is deterministic and does not depend on the1127 # formatter pruning (this repo's ruff has no F811, so a still-used symbol repointed to a new1128 # module would otherwise leave a duplicate). A file written whole by extract_to_new_module1129 # (the new file, or the extract source whose tail the cut removed) is skipped.1130 extract_dsts = {ex["dst"] for ex in recipe.extracts}1131 extract_srcs = {ex["src"] for ex in recipe.extracts}1132 for path in sorted(files):1133 if path in new_files or path in extract_dsts:1134 continue1135 before = _git_output(["show", f"{commit}^:{path}"], root)1136 after = _git_output(["show", f"{commit}:{path}"], root)1137 before_pairs = _import_pairs(before) if before.strip() else {}1138 after_pairs = _import_pairs(after) if after.strip() else {}1139 recipe.import_additions.extend(1140 _import_additions(path, after, before_pairs, after_pairs)1141 )1142 if path not in extract_srcs:1143 for key in before_pairs:1144 if key not in after_pairs:1145 recipe.module_import_removals.append(_removal_from_key(path, key))1146 before_tc = _typechecking_pairs(before) if before.strip() else {}1147 after_tc = _typechecking_pairs(after) if after.strip() else {}1148 for key, stmt in after_tc.items():1149 if key not in before_tc:1150 recipe.typechecking_additions.append({"path": path, "text": stmt})1151 1152 # An intra-file helper carved out of a sibling function's body (its block replaced by a1153 # call) is an extract_function -- inferred only when no cross-file move already explains it.1154 if not recipe.moves:1155 _infer_extract_functions(recipe, files, commit, root)1156 1157 # A move source the commit deletes (its defs all relocated, leaving only scaffolding) is1158 # removed after the moves; move_symbol only cuts defs, it does not delete the emptied file.1159 move_srcs = {mv["src"] for mv in recipe.moves}1160 for path, f in files.items():1161 if f.get("deleted") and path in move_srcs:1162 recipe.deletes.append(path)1163 1164 if (1165 not recipe.moves1166 and not recipe.extracts1167 and not recipe.scatter_extracts1168 and not recipe.extract_functions1169 ):1170 recipe.supported = False1171 if not recipe.notes:1172 recipe.notes.append(1173 "no def relocated (rename or statement-level change): review as prep"1174 )1175 return recipe1176 1177 1178def _recipe_ops(recipe: Recipe) -> list:1179 """The ordered relocation operations a recipe replays, as ``(method, args, kwargs)`` --1180 shared by ``build_repro`` (which runs them on a Repro) and ``recipe_to_script`` (which1181 renders them as ``r.method(...)`` lines), so the emitted script and the in-process run can1182 never drift.1183 1184 Call sites and import repaths/removals run BEFORE the moves, so a call to a moved method1185 from inside another moved method is adapted while still in the source and travels with the1186 body. The moves (in destination order) and the new-module extracts relocate next.1187 Module-level import additions/removals run LAST, so a consumer import lands after an extract1188 has cut the source tail (otherwise it would be swept into the new module). Same-destination1189 moves are emitted in reverse destination order so each move's ``before`` anchor (a sibling1190 further down) is already present when the move is inserted."""1191 ops: list = []1192 for lo in recipe.lowerings:1193 method = (1194 "requalify_call_sites" if lo["kind"] == "requalify" else "lower_call_sites"1195 )1196 ops.append((method, (lo["name"], lo["owner"]), {"paths": [lo["path"]]}))1197 for rp in recipe.repaths:1198 ops.append(1199 (1200 "repath_import",1201 (rp["path"],),1202 {1203 "old_module": rp["old_module"],1204 "new_module": rp["new_module"],1205 "name": rp["name"],1206 },1207 )1208 )1209 for im in recipe.import_removals:1210 ops.append(1211 (1212 "remove_import",1213 (im["path"], im["text"]),1214 {"in_function": im["in_function"]},1215 )1216 )1217 for mv in sorted(recipe.moves, key=lambda m: (m["dst"], -m["dst_order"])):1218 ops.append(1219 (1220 "move_symbol",1221 (mv["name"],),1222 {1223 "src": mv["src"],1224 "dst": mv["dst"],1225 "into_class": mv["into_class"],1226 "from_class": mv.get("from_class"),1227 "dedent": mv["dedent"],1228 "drop_self_annotation": mv["drop_self_annotation"],1229 "before": mv.get("before"),1230 "after": mv.get("after"),1231 "leave_delegate": mv.get("leave_delegate"),1232 "delegate_name": mv.get("delegate_name"),1233 },1234 )1235 )1236 for am in recipe.assign_moves:1237 ops.append(1238 (1239 "move_assign",1240 (am["name"],),1241 {"src": am["src"], "dst": am["dst"], "before": am.get("before")},1242 )1243 )1244 for ex in recipe.extract_functions:1245 ops.append(1246 (1247 "extract_function",1248 (ex["src"], ex["dst"]),1249 {1250 "name": ex["name"],1251 "signature": ex["signature"],1252 "body": ex["body"],1253 "body_indent": ex["body_indent"],1254 "call": ex["call"],1255 "return_text": ex["return_text"],1256 "into_class": ex["into_class"],1257 "before": ex["before"],1258 },1259 )1260 )1261 for ex in recipe.extracts:1262 ops.append(1263 (1264 "extract_to_new_module",1265 (ex["src"], ex["dst"]),1266 {"symbols": ex["symbols"], "future_import": ex["future_import"]},1267 )1268 )1269 for ex in recipe.scatter_extracts:1270 ops.append(1271 (1272 "extract_symbols_to_new_module",1273 (ex["src"], ex["dst"]),1274 {1275 "symbols": ex["symbols"],1276 "header": ex["header"],1277 "order": ex["order"],1278 "drop_assigns": ex["drop_assigns"],1279 },1280 )1281 )1282 for path in recipe.deletes:1283 ops.append(("delete_file", (path,), {}))1284 for im in recipe.module_import_removals:1285 ops.append(1286 (1287 "remove_imported_name",1288 (im["path"],),1289 {"module": im["module"], "name": im["name"], "asname": im["asname"]},1290 )1291 )1292 for im in recipe.import_additions:1293 ops.append(("add_import", (im["path"], im["text"]), {}))1294 for im in recipe.typechecking_additions:1295 ops.append(("add_typechecking_import", (im["path"], im["text"]), {}))1296 return ops1297 1298 1299def build_repro(recipe: Recipe, repo_root: str | None = None) -> rr.Repro:1300 """Compose a Repro from the recipe's canonical ordered operations (``_recipe_ops``)."""1301 repro = rr.Repro(base=recipe.base, target=recipe.target, repo_root=repo_root)1302 for method, args, kwargs in _recipe_ops(recipe):1303 getattr(repro, method)(*args, **kwargs)1304 return repro1305 1306 1307def recipe_to_script(recipe: Recipe, subject: str) -> str:1308 """A standalone, auditable reproduce script (imports only the reproduce util)."""1309 lines = [1310 '"""Auto-generated reproduce script. Audit each call, then run.',1311 "",1312 f"commit: {recipe.target}",1313 f"subject: {subject}",1314 "",1315 "Each call is a faithful relocation primitive. Running this reproduces the commit",1316 "in a throwaway worktree and diffs it byte-for-byte; PASS means the commit is",1317 "exactly these relocations.",1318 '"""',1319 "import sys",1320 "from pathlib import Path",1321 "",1322 "sys.path.insert(0, str(Path(__file__).resolve().parent.parent))",1323 "from mechanical_refactor_reproduction_utils import Repro",1324 "",1325 f"r = Repro(base={recipe.base!r}, target={recipe.target!r})",1326 ]1327 for method, args, kwargs in _recipe_ops(recipe):1328 rendered = [repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()]1329 lines.append(f"r.{method}(" + ", ".join(rendered) + ")")1330 lines += ["residual = r.run()", "sys.exit(1 if residual else 0)", ""]1331 return "\n".join(lines)1332 1333 1334@dataclass1335class GenResult:1336 commit: str1337 subject: str1338 supported: bool1339 passed: bool1340 residual: str1341 script: str1342 notes: list1343 1344 1345def generate_range(1346 rev_range: str,1347 *,1348 match: str | None = None,1349 out_dir: str,1350 repo_root: str | None = None,1351) -> list[GenResult]:1352 """For each matched commit: infer a recipe, emit repro_scripts/<sha>.py, run it, and1353 record PASS / residual. Writes output.log + output.html and copies the reproduce util1354 so the folder is self-contained."""1355 root = repo_root or _repo_root()1356 commits = _git_output(["rev-list", "--reverse", rev_range], root).split()1357 pattern = re.compile(match) if match else None1358 1359 out = Path(out_dir)1360 scripts_dir = out / "repro_scripts"1361 scripts_dir.mkdir(parents=True, exist_ok=True)1362 (out / "mechanical_refactor_reproduction_utils.py").write_text(1363 Path(rr.__file__).read_text()1364 )1365 1366 results: list[GenResult] = []1367 for commit in commits:1368 subject = _git_output(["log", "-1", "--format=%s", commit], root).strip()1369 if pattern is not None and not pattern.search(subject):1370 continue1371 passed, residual, script, supported, notes = False, "", "", False, []1372 try:1373 recipe = infer_recipe(commit, root)1374 script = recipe_to_script(recipe, subject)1375 (scripts_dir / f"{commit[:9]}.py").write_text(script)1376 relocates = bool(1377 recipe.moves1378 or recipe.extracts1379 or recipe.scatter_extracts1380 or recipe.extract_functions1381 )1382 supported = recipe.supported and relocates1383 notes = recipe.notes1384 if supported:1385 residual = build_repro(recipe, repo_root=root).run()1386 passed = residual == ""1387 except Exception as exc:1388 supported = False1389 residual = f"reproduce raised {type(exc).__name__}: {exc}"1390 notes = notes + [residual]1391 results.append(1392 GenResult(1393 commit=commit,1394 subject=subject,1395 supported=supported,1396 passed=passed,1397 residual=residual,1398 script=script,1399 notes=notes,1400 )1401 )1402 1403 _write_log(out / "output.log", rev_range, results)1404 _write_html(out / "output.html", rev_range, results)1405 return results1406 1407 1408def _write_log(path: Path, rev_range: str, results: list[GenResult]) -> None:1409 n_pass = sum(1 for r in results if r.passed)1410 lines = [1411 f"reproduce-gen: {rev_range}",1412 f"{len(results)} commit(s): {n_pass} reproduced, {len(results) - n_pass} not",1413 "",1414 ]1415 for r in results:1416 if r.passed:1417 verdict = "PASS"1418 elif not r.supported:1419 verdict = "UNSUPPORTED (" + "; ".join(r.notes) + ")"1420 else:1421 verdict = f"RESIDUAL ({len(r.residual.splitlines())} lines)"1422 lines.append(f"{r.commit[:9]} {verdict} {r.subject}")1423 path.write_text("\n".join(lines) + "\n")1424 1425 1426def _write_html(path: Path, rev_range: str, results: list[GenResult]) -> None:1427 payload = {1428 "title": rev_range,1429 "passed": sum(1 for r in results if r.passed),1430 "total": len(results),1431 "results": [asdict(r) for r in results],1432 }1433 data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")1434 path.write_text(1435 _HTML_TEMPLATE.replace("__TITLE__", html.escape(rev_range)).replace(1436 "__DATA_JSON__", data1437 )1438 )1439 1440 1441_HTML_TEMPLATE = """<!doctype html>1442<html lang="en"><head><meta charset="utf-8">1443<meta name="viewport" content="width=device-width, initial-scale=1">1444<title>reproduce-gen __TITLE__</title>1445<style>1446*{box-sizing:border-box}1447body{margin:0;background:#fff;color:#1f2328;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}1448header{position:sticky;top:0;background:#fff;border-bottom:1px solid #d1d9e0;padding:10px 16px}1449header h1{margin:0 0 4px;font-size:15px}1450.chip{display:inline-block;padding:1px 8px;border-radius:10px;margin-right:6px;font-size:12px}1451.chip.ok{background:#e6ffec;border:1px solid #4ac26b}1452.chip.no{background:#ffebe9;border:1px solid #ff8182}1453main{padding:8px 16px 40px}1454.card{border:1px solid #d1d9e0;border-left-width:4px;border-radius:6px;margin:8px 0}1455.card.ok{border-left-color:#4ac26b}.card.no{border-left-color:#ff8182}.card.un{border-left-color:#d4a72c}1456.hd{padding:6px 10px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px}1457.hd:hover{background:#f6f8fa}1458.badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:10px;flex:none}1459.badge.ok{background:#e6ffec;border:1px solid #4ac26b;color:#1a7f37}1460.badge.no{background:#ffebe9;border:1px solid #ff8182;color:#cf222e}1461.badge.un{background:#fff8c5;border:1px solid #d4a72c;color:#7d4e00}1462.hd code{color:#59636e;flex:none}1463.bd{display:none;padding:6px 10px;border-top:1px solid #f0f1f3;font:11.5px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}1464.card.open .bd{display:block}1465.k{color:#59636e;margin-top:6px}1466pre{white-space:pre-wrap;word-break:break-word;background:#f6f8fa;padding:8px;border-radius:6px;overflow-x:auto}1467.res .d{white-space:pre-wrap;word-break:break-word}1468.res .add{background:#e6ffec}.res .del{background:#ffebe9}.res .h{background:#eff5ff;color:#0550ae}1469</style></head>1470<body>1471<header><h1>reproduce-gen: <code>__TITLE__</code></h1>1472<div><span id="counts"></span></div></header>1473<main id="list"></main>1474<script id="data" type="application/json">__DATA_JSON__</script>1475<script>1476'use strict';1477const DATA = JSON.parse(document.getElementById('data').textContent);1478const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');1479function cls(r){ return r.passed?'ok':(r.supported?'no':'un'); }1480function label(r){ return r.passed?'PASS':(r.supported?'RESIDUAL':'UNSUPPORTED'); }1481let out='';1482DATA.results.forEach(r => {1483 const c = cls(r);1484 out += '<div class="card '+c+'"><div class="hd"><span class="badge '+c+'">'+label(r)+'</span>';1485 out += '<code>'+esc(r.commit.slice(0,9))+'</code><span>'+esc(r.subject)+'</span></div><div class="bd">';1486 if(r.notes.length) out += '<div class="k">notes:</div><pre>'+esc(r.notes.join('\\n'))+'</pre>';1487 out += '<div class="k">repro script:</div><pre>'+esc(r.script)+'</pre>';1488 if(r.residual){1489 out += '<div class="k">residual diff:</div><div class="res">';1490 r.residual.split('\\n').forEach(l => {1491 const k = l.startsWith('@@')?'h':(l[0]==='+'?'add':(l[0]==='-'?'del':''));1492 out += '<div class="d '+k+'">'+esc(l)+'</div>';1493 });1494 out += '</div>';1495 }1496 out += '</div></div>';1497});1498document.getElementById('counts').innerHTML =1499 '<span class="chip ok">'+DATA.passed+' reproduced</span><span class="chip no">'+(DATA.total-DATA.passed)+' not</span>';1500document.getElementById('list').innerHTML = out;1501document.getElementById('list').addEventListener('click', e => {1502 const hd = e.target.closest('.hd'); if(hd) hd.parentNode.classList.toggle('open');1503});1504</script></body></html>"""1505 1506 1507def _main(argv: list[str]) -> int:1508 out_dir = None1509 if "--out" in argv:1510 i = argv.index("--out")1511 out_dir = argv[i + 1]1512 argv = argv[:i] + argv[i + 2 :]1513 match = None1514 if "--match" in argv:1515 i = argv.index("--match")1516 match = argv[i + 1]1517 argv = argv[:i] + argv[i + 2 :]1518 if len(argv) != 1:1519 print(1520 "usage: python3 mechanical_refactor_proof_generator.py <commit>\n"1521 " python3 mechanical_refactor_proof_generator.py <base>..<tip> "1522 "[--match REGEX] --out DIR",1523 file=sys.stderr,1524 )1525 return 21526 target = argv[0]1527 if ".." in target:1528 assert out_dir, "--out DIR is required for a range"1529 results = generate_range(target, match=match, out_dir=out_dir)1530 n = sum(1 for r in results if r.passed)1531 print(f"{n}/{len(results)} reproduced; folder: {out_dir}")1532 return 01533 root = _repo_root()1534 recipe = infer_recipe(target, root)1535 print(1536 recipe_to_script(1537 recipe, _git_output(["log", "-1", "--format=%s", target], root)1538 )1539 )1540 relocates = bool(1541 recipe.moves1542 or recipe.extracts1543 or recipe.scatter_extracts1544 or recipe.extract_functions1545 )1546 if not (recipe.supported and relocates):1547 print("UNSUPPORTED: " + "; ".join(recipe.notes), file=sys.stderr)1548 return 11549 residual = build_repro(recipe, repo_root=root).run()1550 return 0 if residual == "" else 11551 1552 1553if __name__ == "__main__":1554 sys.exit(_main(sys.argv[1:]))1555 Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 35.SKILL.mdView in source ↗35 whole chain's proof folder (the flags are the generator's:36 `scripts/mechanical_refactor_proof_generator.py <base>..<tip> --match REGEX --out DIR`);37 §2 proves a single commit — pass just `<commit>` (the generator, or a hand-written
Source excerpt starting at line 70.70 the spec: the spec-leads rule, the byte-faithfulness invariant, and the testing bar.71- [`scripts/mechanical_refactor_proof_generator.py`](scripts/mechanical_refactor_proof_generator.py) —72 the **generator**: infers a reproduce recipe from a commit's diff and emits/runs a