detect_refactor.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """muse code detect-refactor -- semantic refactoring detection across commits. |
| 2 | |
| 3 | This command is impossible in Git. Git sees every refactoring operation as |
| 4 | a diff of text lines. A function extracted into a helper module? Delete lines |
| 5 | here, add lines there -- no semantic connection. A class renamed? Every file |
| 6 | that imports it becomes a "modification". Muse understands *what actually |
| 7 | happened* at the symbol level. |
| 8 | |
| 9 | ``muse code detect-refactor`` scans the commit range and classifies every |
| 10 | semantic operation into one of four refactoring categories: |
| 11 | |
| 12 | ``rename`` |
| 13 | A symbol kept its body but changed its name. Detected via a |
| 14 | ``renamed to <new_name>`` marker in the structured delta. |
| 15 | |
| 16 | ``move`` |
| 17 | A symbol moved to a different file without changing its content. |
| 18 | Detected via a ``moved to <file>`` marker in the structured delta. |
| 19 | |
| 20 | ``signature`` |
| 21 | A symbol's name and body are unchanged; only its parameter list or |
| 22 | return type changed. |
| 23 | |
| 24 | ``implementation`` |
| 25 | A symbol's signature is stable; its internal logic changed. |
| 26 | |
| 27 | Output:: |
| 28 | |
| 29 | Semantic refactoring report |
| 30 | From: cb4afaed "Layer 2: add harmonic dimension" |
| 31 | To: a3f2c9e1 "Refactor: rename and move helpers" |
| 32 | ---------------------------------------------------------------------- |
| 33 | |
| 34 | RENAME src/utils.py::calculate_total |
| 35 | -> compute_total |
| 36 | commit a3f2c9e1 "Rename: improve naming clarity" |
| 37 | |
| 38 | MOVE src/utils.py::compute_total |
| 39 | -> src/helpers.py::compute_total |
| 40 | commit 1d2e3faa "Move: extract helpers module" |
| 41 | |
| 42 | SIGNATURE src/api.py::handle_request |
| 43 | parameters changed: (req, ctx) -> (request, context, timeout) |
| 44 | commit 4b5c6d7e "API: add timeout parameter" |
| 45 | |
| 46 | IMPLEMENTATION src/core.py::process_batch |
| 47 | implementation changed (signature stable) |
| 48 | commit 8f9a0b1c "Perf: vectorise batch processing" |
| 49 | |
| 50 | ---------------------------------------------------------------------- |
| 51 | 4 refactoring operation(s) detected |
| 52 | (1 implementation · 1 move · 1 rename · 1 signature) |
| 53 | |
| 54 | Flags:: |
| 55 | |
| 56 | --from <ref> |
| 57 | Start of the commit range (exclusive). Default: initial commit. |
| 58 | Accepts a full or abbreviated commit SHA or a branch name. |
| 59 | |
| 60 | --to <ref> |
| 61 | End of the commit range (inclusive). Default: HEAD. |
| 62 | |
| 63 | --max <n> |
| 64 | Cap the number of commits inspected (default: 500). When hit, |
| 65 | a warning is shown; increase with --max to see the full range. |
| 66 | |
| 67 | --kind <kind> |
| 68 | Filter to one category: implementation, move, rename, signature. |
| 69 | |
| 70 | --json |
| 71 | Emit the full refactoring report as JSON:: |
| 72 | |
| 73 | { |
| 74 | "schema_version": "<version>", |
| 75 | "from": "<sha8> \\"message\\"", |
| 76 | "to": "<sha8> \\"message\\"", |
| 77 | "commits_scanned": 42, |
| 78 | "truncated": false, |
| 79 | "total": 4, |
| 80 | "events": [ |
| 81 | { |
| 82 | "kind": "implementation", |
| 83 | "address": "src/core.py::process_batch", |
| 84 | "detail": "implementation changed ...", |
| 85 | "commit_id": "<sha256>", |
| 86 | "commit_message": "...", |
| 87 | "committed_at": "2026-03-14T..." |
| 88 | } |
| 89 | ] |
| 90 | } |
| 91 | """ |
| 92 | |
| 93 | from __future__ import annotations |
| 94 | |
| 95 | import argparse |
| 96 | import json |
| 97 | import logging |
| 98 | import pathlib |
| 99 | import sys |
| 100 | |
| 101 | from muse._version import __version__ |
| 102 | from muse.core.errors import ExitCode |
| 103 | from muse.core.repo import read_repo_id, require_repo |
| 104 | from muse.core.store import CommitRecord, read_commit, read_current_branch, resolve_commit_ref |
| 105 | from muse.domain import DomainOp |
| 106 | from muse.plugins.code._query import walk_commits_bfs |
| 107 | from muse.core.validation import clamp_int, sanitize_display |
| 108 | |
| 109 | |
| 110 | |
| 111 | type _KindCounts = dict[str, int] |
| 112 | type _LabelMap = dict[str, str] |
| 113 | logger = logging.getLogger(__name__) |
| 114 | |
| 115 | _VALID_KINDS: frozenset[str] = frozenset({"rename", "move", "signature", "implementation"}) |
| 116 | |
| 117 | # --------------------------------------------------------------------------- |
| 118 | # Repository helpers |
| 119 | # --------------------------------------------------------------------------- |
| 120 | |
| 121 | |
| 122 | |
| 123 | # --------------------------------------------------------------------------- |
| 124 | # Event classification |
| 125 | # --------------------------------------------------------------------------- |
| 126 | |
| 127 | |
| 128 | def _flat_child_ops(ops: list[DomainOp]) -> list[DomainOp]: |
| 129 | """Flatten PatchOp child_ops; return all leaf ops.""" |
| 130 | result: list[DomainOp] = [] |
| 131 | for op in ops: |
| 132 | if op["op"] == "patch": |
| 133 | result.extend(op["child_ops"]) |
| 134 | else: |
| 135 | result.append(op) |
| 136 | return result |
| 137 | |
| 138 | |
| 139 | class RefactorEvent: |
| 140 | """A single detected refactoring event.""" |
| 141 | |
| 142 | __slots__ = ("kind", "address", "detail", "commit") |
| 143 | |
| 144 | def __init__( |
| 145 | self, |
| 146 | kind: str, |
| 147 | address: str, |
| 148 | detail: str, |
| 149 | commit: CommitRecord, |
| 150 | ) -> None: |
| 151 | self.kind = kind |
| 152 | self.address = address |
| 153 | self.detail = detail |
| 154 | self.commit = commit |
| 155 | |
| 156 | def to_dict(self) -> _LabelMap: |
| 157 | return { |
| 158 | "kind": self.kind, |
| 159 | "address": self.address, |
| 160 | "detail": self.detail, |
| 161 | "commit_id": self.commit.commit_id, |
| 162 | "commit_message": self.commit.message, |
| 163 | "committed_at": self.commit.committed_at.isoformat(), |
| 164 | } |
| 165 | |
| 166 | |
| 167 | def _classify_ops(commit: CommitRecord) -> list[RefactorEvent]: |
| 168 | """Extract refactoring events from *commit*'s structured delta. |
| 169 | |
| 170 | Classification rules (checked in priority order): |
| 171 | |
| 172 | 1. ``renamed to <name>`` → rename |
| 173 | 2. ``moved to <path>`` → move (on both replace and delete ops) |
| 174 | 3. ``signature`` keyword → signature |
| 175 | 4. ``implementation`` or ``modified`` keyword → implementation |
| 176 | 5. ``reformatted`` → skipped (explicitly "no semantic change") |
| 177 | 6. everything else → skipped (non-semantic or unrecognised) |
| 178 | """ |
| 179 | events: list[RefactorEvent] = [] |
| 180 | if commit.structured_delta is None: |
| 181 | return events |
| 182 | |
| 183 | all_ops = _flat_child_ops(commit.structured_delta["ops"]) |
| 184 | |
| 185 | for op in all_ops: |
| 186 | address = op["address"] |
| 187 | |
| 188 | if op["op"] == "delete": |
| 189 | content_summary = op.get("content_summary", "") |
| 190 | if "moved to" in content_summary: |
| 191 | target = content_summary.split("moved to")[-1].strip() |
| 192 | events.append(RefactorEvent( |
| 193 | kind="move", |
| 194 | address=address, |
| 195 | detail=f"→ {target}", |
| 196 | commit=commit, |
| 197 | )) |
| 198 | |
| 199 | elif op["op"] == "replace": |
| 200 | new_summary: str = op.get("new_summary", "") |
| 201 | old_summary: str = op.get("old_summary", "") |
| 202 | |
| 203 | if new_summary.startswith("renamed to "): |
| 204 | new_name = new_summary.removeprefix("renamed to ").strip() |
| 205 | events.append(RefactorEvent( |
| 206 | kind="rename", |
| 207 | address=address, |
| 208 | detail=f"→ {new_name}", |
| 209 | commit=commit, |
| 210 | )) |
| 211 | elif new_summary.startswith("moved to "): |
| 212 | target = new_summary.removeprefix("moved to ").strip() |
| 213 | events.append(RefactorEvent( |
| 214 | kind="move", |
| 215 | address=address, |
| 216 | detail=f"→ {target}", |
| 217 | commit=commit, |
| 218 | )) |
| 219 | elif "signature" in new_summary or "signature" in old_summary: |
| 220 | detail = new_summary or f"{address} signature changed" |
| 221 | events.append(RefactorEvent( |
| 222 | kind="signature", |
| 223 | address=address, |
| 224 | detail=detail, |
| 225 | commit=commit, |
| 226 | )) |
| 227 | elif "implementation" in new_summary or "modified" in new_summary: |
| 228 | # Both "implementation changed" and "(modified)" map to this. |
| 229 | events.append(RefactorEvent( |
| 230 | kind="implementation", |
| 231 | address=address, |
| 232 | detail=new_summary or "implementation changed", |
| 233 | commit=commit, |
| 234 | )) |
| 235 | elif "reformatted" in new_summary: |
| 236 | # Explicitly "no semantic change" — skip without noise. |
| 237 | pass |
| 238 | |
| 239 | return events |
| 240 | |
| 241 | |
| 242 | # --------------------------------------------------------------------------- |
| 243 | # Output |
| 244 | # --------------------------------------------------------------------------- |
| 245 | |
| 246 | _LABEL: _LabelMap = { |
| 247 | "rename": "RENAME ", |
| 248 | "move": "MOVE ", |
| 249 | "signature": "SIGNATURE ", |
| 250 | "implementation": "IMPLEMENTATION", |
| 251 | } |
| 252 | |
| 253 | |
| 254 | def _print_human( |
| 255 | events: list[RefactorEvent], |
| 256 | from_label: str, |
| 257 | to_label: str, |
| 258 | commits_scanned: int, |
| 259 | truncated: bool, |
| 260 | ) -> None: |
| 261 | print("\nSemantic refactoring report") |
| 262 | print(f"From: {from_label}") |
| 263 | print(f"To: {to_label}") |
| 264 | print("─" * 62) |
| 265 | |
| 266 | if truncated: |
| 267 | print( |
| 268 | f"\n⚠️ Results may be incomplete — scanned {commits_scanned:,} commits " |
| 269 | "(use --max to increase the limit).", |
| 270 | ) |
| 271 | |
| 272 | if not events: |
| 273 | print("\n (no semantic refactoring detected in this range)") |
| 274 | return |
| 275 | |
| 276 | for ev in events: |
| 277 | label = _LABEL.get(ev.kind, ev.kind.upper().ljust(14)) |
| 278 | short_id = ev.commit.commit_id[:8] |
| 279 | print(f"\n{label} {sanitize_display(ev.address)}") |
| 280 | print(f" {ev.detail}") |
| 281 | print(f' commit {short_id} "{sanitize_display(ev.commit.message)}"') |
| 282 | |
| 283 | print("\n" + "─" * 62) |
| 284 | kind_counts: _KindCounts = {} |
| 285 | for ev in events: |
| 286 | kind_counts[ev.kind] = kind_counts.get(ev.kind, 0) + 1 |
| 287 | summary_parts = [f"{v} {k}" for k, v in sorted(kind_counts.items())] |
| 288 | print(f"{len(events)} refactoring operation(s) detected") |
| 289 | print(f"({' · '.join(summary_parts)})") |
| 290 | |
| 291 | |
| 292 | # --------------------------------------------------------------------------- |
| 293 | # Argument parser registration |
| 294 | # --------------------------------------------------------------------------- |
| 295 | |
| 296 | |
| 297 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 298 | """Register the detect-refactor subcommand.""" |
| 299 | parser = subparsers.add_parser( |
| 300 | "detect-refactor", |
| 301 | help="Detect semantic refactoring operations across a commit range.", |
| 302 | description=__doc__, |
| 303 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 304 | ) |
| 305 | parser.add_argument( |
| 306 | "--from", default=None, metavar="REF", dest="from_ref", |
| 307 | help="Start of range (exclusive). Default: initial commit.", |
| 308 | ) |
| 309 | parser.add_argument( |
| 310 | "--to", default=None, metavar="REF", dest="to_ref", |
| 311 | help="End of range (inclusive). Default: HEAD.", |
| 312 | ) |
| 313 | parser.add_argument( |
| 314 | "--max", default=500, type=int, metavar="N", dest="max_commits", |
| 315 | help="Maximum number of commits to inspect (default: 500).", |
| 316 | ) |
| 317 | parser.add_argument( |
| 318 | "--kind", "-k", default=None, metavar="KIND", dest="kind_filter", |
| 319 | help="Filter to one category: implementation, move, rename, signature.", |
| 320 | ) |
| 321 | parser.add_argument( |
| 322 | "--json", action="store_true", dest="as_json", |
| 323 | help="Emit the full refactoring report as JSON.", |
| 324 | ) |
| 325 | parser.set_defaults(func=run) |
| 326 | |
| 327 | |
| 328 | # --------------------------------------------------------------------------- |
| 329 | # Command entry point |
| 330 | # --------------------------------------------------------------------------- |
| 331 | |
| 332 | |
| 333 | def run(args: argparse.Namespace) -> None: |
| 334 | """Detect semantic refactoring operations across a commit range. |
| 335 | |
| 336 | ``muse code detect-refactor`` is impossible in Git. Git reports renames |
| 337 | only as heuristic line-similarity guesses (``git diff --find-renames``); |
| 338 | it has no concept of function identity, body hashes, or cross-file symbol |
| 339 | continuity. |
| 340 | |
| 341 | Muse detects every semantic refactoring at the AST level and walks the |
| 342 | full commit DAG — including commits on merged feature branches — so no |
| 343 | event is silently hidden behind a merge commit. |
| 344 | |
| 345 | Use ``--from`` / ``--to`` to scope the range. Without flags, scans the |
| 346 | full history from the first commit to HEAD. |
| 347 | """ |
| 348 | from_ref: str | None = args.from_ref |
| 349 | to_ref: str | None = args.to_ref |
| 350 | max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits') |
| 351 | kind_filter: str | None = args.kind_filter |
| 352 | as_json: bool = args.as_json |
| 353 | |
| 354 | # ── Input validation ────────────────────────────────────────────────────── |
| 355 | |
| 356 | if kind_filter and kind_filter not in _VALID_KINDS: |
| 357 | print( |
| 358 | f"❌ Unknown kind '{kind_filter}'. " |
| 359 | f"Valid: {', '.join(sorted(_VALID_KINDS))}", |
| 360 | file=sys.stderr, |
| 361 | ) |
| 362 | raise SystemExit(ExitCode.USER_ERROR) |
| 363 | |
| 364 | if max_commits < 1: |
| 365 | print("❌ --max must be at least 1.", file=sys.stderr) |
| 366 | raise SystemExit(ExitCode.USER_ERROR) |
| 367 | |
| 368 | # ── Repo / commit resolution ────────────────────────────────────────────── |
| 369 | |
| 370 | root = require_repo() |
| 371 | repo_id = read_repo_id(root) |
| 372 | branch = read_current_branch(root) |
| 373 | |
| 374 | to_commit = resolve_commit_ref(root, repo_id, branch, to_ref) |
| 375 | if to_commit is None: |
| 376 | label = to_ref or "HEAD" |
| 377 | print(f"❌ Commit '{label}' not found.", file=sys.stderr) |
| 378 | raise SystemExit(ExitCode.USER_ERROR) |
| 379 | |
| 380 | from_commit_id: str | None = None |
| 381 | if from_ref is not None: |
| 382 | from_commit = resolve_commit_ref(root, repo_id, branch, from_ref) |
| 383 | if from_commit is None: |
| 384 | print(f"❌ Commit '{from_ref}' not found.", file=sys.stderr) |
| 385 | raise SystemExit(ExitCode.USER_ERROR) |
| 386 | from_commit_id = from_commit.commit_id |
| 387 | |
| 388 | # ── DAG walk + classification ───────────────────────────────────────────── |
| 389 | |
| 390 | commits, truncated = walk_commits_bfs( |
| 391 | root, to_commit.commit_id, max_commits=max_commits, stop_at_commit_id=from_commit_id |
| 392 | ) |
| 393 | |
| 394 | all_events: list[RefactorEvent] = [] |
| 395 | for commit in commits: |
| 396 | evs = _classify_ops(commit) |
| 397 | if kind_filter: |
| 398 | evs = [e for e in evs if e.kind == kind_filter] |
| 399 | all_events.extend(evs) |
| 400 | |
| 401 | # ── Labels ──────────────────────────────────────────────────────────────── |
| 402 | |
| 403 | if from_commit_id is not None: |
| 404 | _fc = read_commit(root, from_commit_id) |
| 405 | from_label = ( |
| 406 | f'{from_commit_id[:8]} "{_fc.message}"' |
| 407 | if _fc is not None |
| 408 | else "initial commit" |
| 409 | ) |
| 410 | else: |
| 411 | from_label = "initial commit" |
| 412 | to_label = f'{to_commit.commit_id[:8]} "{to_commit.message}"' |
| 413 | |
| 414 | # ── Output ──────────────────────────────────────────────────────────────── |
| 415 | |
| 416 | if as_json: |
| 417 | print(json.dumps( |
| 418 | { |
| 419 | "schema_version": __version__, |
| 420 | "from": from_label, |
| 421 | "to": to_label, |
| 422 | "commits_scanned": len(commits), |
| 423 | "truncated": truncated, |
| 424 | "total": len(all_events), |
| 425 | "events": [e.to_dict() for e in all_events], |
| 426 | }, |
| 427 | indent=2, |
| 428 | )) |
| 429 | return |
| 430 | |
| 431 | _print_human(all_events, from_label, to_label, len(commits), truncated) |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago