gabriel / muse public
detect_refactor.py python
448 lines 16.5 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 125 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 import argparse
94 import json
95 import logging
96 import pathlib
97 import sys
98 from typing import TypedDict
99
100 from muse.core.envelope import EnvelopeJson, make_envelope
101 from muse.core.errors import ExitCode
102 from muse.core.repo import read_repo_id, require_repo
103 from muse.core.store import CommitRecord, read_commit, read_current_branch, resolve_commit_ref
104 from muse.core.timing import start_timer
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 type _KindCounts = dict[str, int]
110 type _LabelMap = dict[str, str]
111 logger = logging.getLogger(__name__)
112
113 # ---------------------------------------------------------------------------
114 # Typed output shape
115 # ---------------------------------------------------------------------------
116
117 class _RefactorPayload(TypedDict):
118 from_ref: str
119 to_ref: str
120 commits_scanned: int
121 truncated: bool
122 total: int
123 events: list[_LabelMap]
124
125 class _RefactorOutputJson(_RefactorPayload, EnvelopeJson):
126 """Full wire shape for ``muse code detect-refactor --json``."""
127
128 _VALID_KINDS: frozenset[str] = frozenset({"rename", "move", "signature", "implementation"})
129
130 # ---------------------------------------------------------------------------
131 # Repository helpers
132 # ---------------------------------------------------------------------------
133
134 # ---------------------------------------------------------------------------
135 # Event classification
136 # ---------------------------------------------------------------------------
137
138 def _flat_child_ops(ops: list[DomainOp]) -> list[DomainOp]:
139 """Flatten PatchOp child_ops; return all leaf ops."""
140 result: list[DomainOp] = []
141 for op in ops:
142 if op["op"] == "patch":
143 result.extend(op["child_ops"])
144 else:
145 result.append(op)
146 return result
147
148 class RefactorEvent:
149 """A single detected refactoring event."""
150
151 __slots__ = ("kind", "address", "detail", "commit")
152
153 def __init__(
154 self,
155 kind: str,
156 address: str,
157 detail: str,
158 commit: CommitRecord,
159 ) -> None:
160 self.kind = kind
161 self.address = address
162 self.detail = detail
163 self.commit = commit
164
165 def to_dict(self) -> _LabelMap:
166 return {
167 "kind": self.kind,
168 "address": self.address,
169 "detail": self.detail,
170 "commit_id": self.commit.commit_id,
171 "commit_message": self.commit.message,
172 "committed_at": self.commit.committed_at.isoformat(),
173 }
174
175 def _classify_ops(commit: CommitRecord) -> list[RefactorEvent]:
176 """Extract refactoring events from *commit*'s structured delta.
177
178 Classification rules (checked in priority order):
179
180 1. ``renamed to <name>`` → rename
181 2. ``moved to <path>`` → move (on both replace and delete ops)
182 3. ``signature`` keyword → signature
183 4. ``implementation`` or ``modified`` keyword → implementation
184 5. ``reformatted`` → skipped (explicitly "no semantic change")
185 6. everything else → skipped (non-semantic or unrecognised)
186 """
187 events: list[RefactorEvent] = []
188 if commit.structured_delta is None:
189 return events
190
191 all_ops = _flat_child_ops(commit.structured_delta["ops"])
192
193 for op in all_ops:
194 address = op["address"]
195
196 if op["op"] == "delete":
197 content_summary = op.get("content_summary", "")
198 if "moved to" in content_summary:
199 target = content_summary.split("moved to")[-1].strip()
200 events.append(RefactorEvent(
201 kind="move",
202 address=address,
203 detail=f"→ {target}",
204 commit=commit,
205 ))
206
207 elif op["op"] == "replace":
208 new_summary: str = op.get("new_summary", "")
209 old_summary: str = op.get("old_summary", "")
210
211 if new_summary.startswith("renamed to "):
212 new_name = new_summary.removeprefix("renamed to ").strip()
213 events.append(RefactorEvent(
214 kind="rename",
215 address=address,
216 detail=f"→ {new_name}",
217 commit=commit,
218 ))
219 elif new_summary.startswith("moved to "):
220 target = new_summary.removeprefix("moved to ").strip()
221 events.append(RefactorEvent(
222 kind="move",
223 address=address,
224 detail=f"→ {target}",
225 commit=commit,
226 ))
227 elif "signature" in new_summary or "signature" in old_summary:
228 detail = new_summary or f"{address} signature changed"
229 events.append(RefactorEvent(
230 kind="signature",
231 address=address,
232 detail=detail,
233 commit=commit,
234 ))
235 elif "implementation" in new_summary or "modified" in new_summary:
236 # Both "implementation changed" and "(modified)" map to this.
237 events.append(RefactorEvent(
238 kind="implementation",
239 address=address,
240 detail=new_summary or "implementation changed",
241 commit=commit,
242 ))
243 elif "reformatted" in new_summary:
244 # Explicitly "no semantic change" — skip without noise.
245 pass
246
247 return events
248
249 # ---------------------------------------------------------------------------
250 # Output
251 # ---------------------------------------------------------------------------
252
253 _LABEL: _LabelMap = {
254 "rename": "RENAME ",
255 "move": "MOVE ",
256 "signature": "SIGNATURE ",
257 "implementation": "IMPLEMENTATION",
258 }
259
260 def _print_human(
261 events: list[RefactorEvent],
262 from_label: str,
263 to_label: str,
264 commits_scanned: int,
265 truncated: bool,
266 ) -> None:
267 print("\nSemantic refactoring report")
268 print(f"From: {from_label}")
269 print(f"To: {to_label}")
270 print("─" * 62)
271
272 if truncated:
273 print(
274 f"\n⚠️ Results may be incomplete — scanned {commits_scanned:,} commits "
275 "(use --max to increase the limit).",
276 )
277
278 if not events:
279 print("\n (no semantic refactoring detected in this range)")
280 return
281
282 for ev in events:
283 label = _LABEL.get(ev.kind, ev.kind.upper().ljust(14))
284 print(f"\n{label} {sanitize_display(ev.address)}")
285 print(f" {ev.detail}")
286 print(f' commit {ev.commit.commit_id} "{sanitize_display(ev.commit.message)}"')
287
288 print(f"\n{'─' * 62}")
289 kind_counts: _KindCounts = {}
290 for ev in events:
291 kind_counts[ev.kind] = kind_counts.get(ev.kind, 0) + 1
292 summary_parts = [f"{v} {k}" for k, v in sorted(kind_counts.items())]
293 print(f"{len(events)} refactoring operation(s) detected")
294 print(f"({' · '.join(summary_parts)})")
295
296 # ---------------------------------------------------------------------------
297 # Argument parser registration
298 # ---------------------------------------------------------------------------
299
300 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
301 """Register the detect-refactor subcommand."""
302 parser = subparsers.add_parser(
303 "detect-refactor",
304 help="Detect semantic refactoring operations across a commit range.",
305 description=__doc__,
306 formatter_class=argparse.RawDescriptionHelpFormatter,
307 )
308 parser.add_argument(
309 "--from", default=None, metavar="REF", dest="from_ref",
310 help="Start of range (exclusive). Default: initial commit.",
311 )
312 parser.add_argument(
313 "--to", default=None, metavar="REF", dest="to_ref",
314 help="End of range (inclusive). Default: HEAD.",
315 )
316 parser.add_argument(
317 "--max", default=500, type=int, metavar="N", dest="max_commits",
318 help="Maximum number of commits to inspect (default: 500).",
319 )
320 parser.add_argument(
321 "--kind", "-k", default=None, metavar="KIND", dest="kind_filter",
322 help="Filter to one category: implementation, move, rename, signature.",
323 )
324 parser.add_argument(
325 "--json", "-j", action="store_true", dest="json_out",
326 help="Emit the full refactoring report as JSON.",
327 )
328 parser.set_defaults(func=run)
329
330 # ---------------------------------------------------------------------------
331 # Command entry point
332 # ---------------------------------------------------------------------------
333
334 def run(args: argparse.Namespace) -> None:
335 """Detect semantic refactoring operations across a commit range.
336
337 Walks the commit DAG at AST level and classifies semantic events: symbol
338 renames, moves across files, splits, merges, and body-hash changes.
339 Unlike Git's heuristic ``--find-renames``, Muse tracks function identity
340 across commits so no event is hidden behind a merge commit.
341
342 Agent quickstart
343 ----------------
344 ::
345
346 muse code detect-refactor --json
347 muse code detect-refactor --from HEAD~20 --json
348 muse code detect-refactor --kind rename --json
349 muse code detect-refactor --from v1.0.0 --to v2.0.0 --json
350
351 JSON fields
352 -----------
353 from Start ref (exclusive).
354 to End ref (inclusive).
355 commits_scanned Number of commits walked.
356 truncated ``true`` if ``--max`` was reached before root.
357 total Total refactoring events detected.
358 events List of event objects: ``kind``, ``from_address``,
359 ``to_address``, ``commit_id``, ``committed_at``.
360
361 Exit codes
362 ----------
363 0 Analysis complete.
364 1 Invalid arguments or ref not found.
365 2 Not inside a Muse repository.
366 """
367 elapsed = start_timer()
368 from_ref: str | None = args.from_ref
369 to_ref: str | None = args.to_ref
370 max_commits: int = clamp_int(args.max_commits, 1, 100_000, 'max_commits')
371 kind_filter: str | None = args.kind_filter
372 json_out: bool = args.json_out
373
374 # ── Input validation ──────────────────────────────────────────────────────
375
376 if kind_filter and kind_filter not in _VALID_KINDS:
377 print(
378 f"❌ Unknown kind '{kind_filter}'. "
379 f"Valid: {', '.join(sorted(_VALID_KINDS))}",
380 file=sys.stderr,
381 )
382 raise SystemExit(ExitCode.USER_ERROR)
383
384 if max_commits < 1:
385 print("❌ --max must be at least 1.", file=sys.stderr)
386 raise SystemExit(ExitCode.USER_ERROR)
387
388 # ── Repo / commit resolution ──────────────────────────────────────────────
389
390 root = require_repo()
391 repo_id = read_repo_id(root)
392 branch = read_current_branch(root)
393
394 to_commit = resolve_commit_ref(root, repo_id, branch, to_ref)
395 if to_commit is None:
396 label = to_ref or "HEAD"
397 print(f"❌ Commit '{label}' not found.", file=sys.stderr)
398 raise SystemExit(ExitCode.USER_ERROR)
399
400 from_commit_id: str | None = None
401 if from_ref is not None:
402 from_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
403 if from_commit is None:
404 print(f"❌ Commit '{from_ref}' not found.", file=sys.stderr)
405 raise SystemExit(ExitCode.USER_ERROR)
406 from_commit_id = from_commit.commit_id
407
408 # ── DAG walk + classification ─────────────────────────────────────────────
409
410 commits, truncated = walk_commits_bfs(
411 root, to_commit.commit_id, max_commits=max_commits, stop_at_commit_id=from_commit_id
412 )
413
414 all_events: list[RefactorEvent] = []
415 for commit in commits:
416 evs = _classify_ops(commit)
417 if kind_filter:
418 evs = [e for e in evs if e.kind == kind_filter]
419 all_events.extend(evs)
420
421 # ── Labels ────────────────────────────────────────────────────────────────
422
423 if from_commit_id is not None:
424 _fc = read_commit(root, from_commit_id)
425 from_label = (
426 f'{from_commit_id} "{_fc.message}"'
427 if _fc is not None
428 else "initial commit"
429 )
430 else:
431 from_label = "initial commit"
432 to_label = f'{to_commit.commit_id} "{to_commit.message}"'
433
434 # ── Output ────────────────────────────────────────────────────────────────
435
436 if json_out:
437 print(json.dumps(_RefactorOutputJson(
438 **make_envelope(elapsed),
439 from_ref=from_label,
440 to_ref=to_label,
441 commits_scanned=len(commits),
442 truncated=truncated,
443 total=len(all_events),
444 events=[e.to_dict() for e in all_events],
445 )))
446 return
447
448 _print_human(all_events, from_label, to_label, len(commits), truncated)
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 125 days ago