"""muse code detect-refactor -- semantic refactoring detection across commits. This command is impossible in Git. Git sees every refactoring operation as a diff of text lines. A function extracted into a helper module? Delete lines here, add lines there -- no semantic connection. A class renamed? Every file that imports it becomes a "modification". Muse understands *what actually happened* at the symbol level. ``muse code detect-refactor`` scans the commit range and classifies every semantic operation into one of four refactoring categories: ``rename`` A symbol kept its body but changed its name. Detected via a ``renamed to `` marker in the structured delta. ``move`` A symbol moved to a different file without changing its content. Detected via a ``moved to `` marker in the structured delta. ``signature`` A symbol's name and body are unchanged; only its parameter list or return type changed. ``implementation`` A symbol's signature is stable; its internal logic changed. Output:: Semantic refactoring report From: cb4afaed "Layer 2: add harmonic dimension" To: a3f2c9e1 "Refactor: rename and move helpers" ---------------------------------------------------------------------- RENAME src/utils.py::calculate_total -> compute_total commit a3f2c9e1 "Rename: improve naming clarity" MOVE src/utils.py::compute_total -> src/helpers.py::compute_total commit 1d2e3faa "Move: extract helpers module" SIGNATURE src/api.py::handle_request parameters changed: (req, ctx) -> (request, context, timeout) commit 4b5c6d7e "API: add timeout parameter" IMPLEMENTATION src/core.py::process_batch implementation changed (signature stable) commit 8f9a0b1c "Perf: vectorise batch processing" ---------------------------------------------------------------------- 4 refactoring operation(s) detected (1 implementation · 1 move · 1 rename · 1 signature) Flags:: --from Start of the commit range (exclusive). Default: initial commit. Accepts a full or abbreviated commit SHA or a branch name. --to End of the commit range (inclusive). Default: HEAD. --max Cap the number of commits inspected (default: 500). When hit, a warning is shown; increase with --max to see the full range. --kind Filter to one category: implementation, move, rename, signature. --json Emit the full refactoring report as JSON:: { "schema_version": "", "from": " \\"message\\"", "to": " \\"message\\"", "commits_scanned": 42, "truncated": false, "total": 4, "events": [ { "kind": "implementation", "address": "src/core.py::process_batch", "detail": "implementation changed ...", "commit_id": "", "commit_message": "...", "committed_at": "2026-03-14T..." } ] } """ from __future__ import annotations import argparse import json import logging import pathlib import sys from muse._version import __version__ from muse.core.errors import ExitCode from muse.core.repo import read_repo_id, require_repo from muse.core.store import CommitRecord, read_commit, read_current_branch, resolve_commit_ref from muse.domain import DomainOp from muse.plugins.code._query import walk_commits_bfs from muse.core.validation import clamp_int, sanitize_display type _KindCounts = dict[str, int] type _LabelMap = dict[str, str] logger = logging.getLogger(__name__) _VALID_KINDS: frozenset[str] = frozenset({"rename", "move", "signature", "implementation"}) # --------------------------------------------------------------------------- # Repository helpers # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Event classification # --------------------------------------------------------------------------- def _flat_child_ops(ops: list[DomainOp]) -> list[DomainOp]: """Flatten PatchOp child_ops; return all leaf ops.""" result: list[DomainOp] = [] for op in ops: if op["op"] == "patch": result.extend(op["child_ops"]) else: result.append(op) return result class RefactorEvent: """A single detected refactoring event.""" __slots__ = ("kind", "address", "detail", "commit") def __init__( self, kind: str, address: str, detail: str, commit: CommitRecord, ) -> None: self.kind = kind self.address = address self.detail = detail self.commit = commit def to_dict(self) -> _LabelMap: return { "kind": self.kind, "address": self.address, "detail": self.detail, "commit_id": self.commit.commit_id, "commit_message": self.commit.message, "committed_at": self.commit.committed_at.isoformat(), } def _classify_ops(commit: CommitRecord) -> list[RefactorEvent]: """Extract refactoring events from *commit*'s structured delta. Classification rules (checked in priority order): 1. ``renamed to `` → rename 2. ``moved to `` → move (on both replace and delete ops) 3. ``signature`` keyword → signature 4. ``implementation`` or ``modified`` keyword → implementation 5. ``reformatted`` → skipped (explicitly "no semantic change") 6. everything else → skipped (non-semantic or unrecognised) """ events: list[RefactorEvent] = [] if commit.structured_delta is None: return events all_ops = _flat_child_ops(commit.structured_delta["ops"]) for op in all_ops: address = op["address"] if op["op"] == "delete": content_summary = op.get("content_summary", "") if "moved to" in content_summary: target = content_summary.split("moved to")[-1].strip() events.append(RefactorEvent( kind="move", address=address, detail=f"→ {target}", commit=commit, )) elif op["op"] == "replace": new_summary: str = op.get("new_summary", "") old_summary: str = op.get("old_summary", "") if new_summary.startswith("renamed to "): new_name = new_summary.removeprefix("renamed to ").strip() events.append(RefactorEvent( kind="rename", address=address, detail=f"→ {new_name}", commit=commit, )) elif new_summary.startswith("moved to "): target = new_summary.removeprefix("moved to ").strip() events.append(RefactorEvent( kind="move", address=address, detail=f"→ {target}", commit=commit, )) elif "signature" in new_summary or "signature" in old_summary: detail = new_summary or f"{address} signature changed" events.append(RefactorEvent( kind="signature", address=address, detail=detail, commit=commit, )) elif "implementation" in new_summary or "modified" in new_summary: # Both "implementation changed" and "(modified)" map to this. events.append(RefactorEvent( kind="implementation", address=address, detail=new_summary or "implementation changed", commit=commit, )) elif "reformatted" in new_summary: # Explicitly "no semantic change" — skip without noise. pass return events # --------------------------------------------------------------------------- # Output # --------------------------------------------------------------------------- _LABEL: _LabelMap = { "rename": "RENAME ", "move": "MOVE ", "signature": "SIGNATURE ", "implementation": "IMPLEMENTATION", } def _print_human( events: list[RefactorEvent], from_label: str, to_label: str, commits_scanned: int, truncated: bool, ) -> None: print("\nSemantic refactoring report") print(f"From: {from_label}") print(f"To: {to_label}") print("─" * 62) if truncated: print( f"\n⚠️ Results may be incomplete — scanned {commits_scanned:,} commits " "(use --max to increase the limit).", ) if not events: print("\n (no semantic refactoring detected in this range)") return for ev in events: label = _LABEL.get(ev.kind, ev.kind.upper().ljust(14)) short_id = ev.commit.commit_id[:8] print(f"\n{label} {sanitize_display(ev.address)}") print(f" {ev.detail}") print(f' commit {short_id} "{sanitize_display(ev.commit.message)}"') print("\n" + "─" * 62) kind_counts: _KindCounts = {} for ev in events: kind_counts[ev.kind] = kind_counts.get(ev.kind, 0) + 1 summary_parts = [f"{v} {k}" for k, v in sorted(kind_counts.items())] print(f"{len(events)} refactoring operation(s) detected") print(f"({' · '.join(summary_parts)})") # --------------------------------------------------------------------------- # Argument parser registration # --------------------------------------------------------------------------- def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the detect-refactor subcommand.""" parser = subparsers.add_parser( "detect-refactor", help="Detect semantic refactoring operations across a commit range.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--from", default=None, metavar="REF", dest="from_ref", help="Start of range (exclusive). Default: initial commit.", ) parser.add_argument( "--to", default=None, metavar="REF", dest="to_ref", help="End of range (inclusive). Default: HEAD.", ) parser.add_argument( "--max", default=500, type=int, metavar="N", dest="max_commits", help="Maximum number of commits to inspect (default: 500).", ) parser.add_argument( "--kind", "-k", default=None, metavar="KIND", dest="kind_filter", help="Filter to one category: implementation, move, rename, signature.", ) parser.add_argument( "--json", action="store_true", dest="as_json", help="Emit the full refactoring report as JSON.", ) parser.set_defaults(func=run) # --------------------------------------------------------------------------- # Command entry point # --------------------------------------------------------------------------- def run(args: argparse.Namespace) -> None: """Detect semantic refactoring operations across a commit range. ``muse code detect-refactor`` is impossible in Git. Git reports renames only as heuristic line-similarity guesses (``git diff --find-renames``); it has no concept of function identity, body hashes, or cross-file symbol continuity. Muse detects every semantic refactoring at the AST level and walks the full commit DAG — including commits on merged feature branches — so no event is silently hidden behind a merge commit. Use ``--from`` / ``--to`` to scope the range. Without flags, scans the full history from the first commit to HEAD. """ from_ref: str | None = args.from_ref to_ref: str | None = args.to_ref max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits') kind_filter: str | None = args.kind_filter as_json: bool = args.as_json # ── Input validation ────────────────────────────────────────────────────── if kind_filter and kind_filter not in _VALID_KINDS: print( f"❌ Unknown kind '{kind_filter}'. " f"Valid: {', '.join(sorted(_VALID_KINDS))}", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) if max_commits < 1: print("❌ --max must be at least 1.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # ── Repo / commit resolution ────────────────────────────────────────────── root = require_repo() repo_id = read_repo_id(root) branch = read_current_branch(root) to_commit = resolve_commit_ref(root, repo_id, branch, to_ref) if to_commit is None: label = to_ref or "HEAD" print(f"❌ Commit '{label}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from_commit_id: str | None = None if from_ref is not None: from_commit = resolve_commit_ref(root, repo_id, branch, from_ref) if from_commit is None: print(f"❌ Commit '{from_ref}' not found.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from_commit_id = from_commit.commit_id # ── DAG walk + classification ───────────────────────────────────────────── commits, truncated = walk_commits_bfs( root, to_commit.commit_id, max_commits=max_commits, stop_at_commit_id=from_commit_id ) all_events: list[RefactorEvent] = [] for commit in commits: evs = _classify_ops(commit) if kind_filter: evs = [e for e in evs if e.kind == kind_filter] all_events.extend(evs) # ── Labels ──────────────────────────────────────────────────────────────── if from_commit_id is not None: _fc = read_commit(root, from_commit_id) from_label = ( f'{from_commit_id[:8]} "{_fc.message}"' if _fc is not None else "initial commit" ) else: from_label = "initial commit" to_label = f'{to_commit.commit_id[:8]} "{to_commit.message}"' # ── Output ──────────────────────────────────────────────────────────────── if as_json: print(json.dumps( { "schema_version": __version__, "from": from_label, "to": to_label, "commits_scanned": len(commits), "truncated": truncated, "total": len(all_events), "events": [e.to_dict() for e in all_events], }, indent=2, )) return _print_human(all_events, from_label, to_label, len(commits), truncated)