detect_refactor.py python
431 lines 15.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 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 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago