rename.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """muse code rename β€” symbol-aware rename across definition, imports, and call sites.
2
3 Unlike ``sed -i 's/old/new/g'``, ``muse code rename`` operates at the AST
4 level β€” it understands Python's parse tree and only rewrites the identifier
5 token at each relevant site:
6
7 - **Definition** β€” the ``def``/``class`` name token, scoped to the correct
8 class body for methods. String literals and comments that happen to contain
9 the old name are never touched.
10 - **Import sites** β€” every ``from module import old_name`` (and
11 ``import old_name``) in the repository.
12 - **Call / reference sites** β€” every bare ``old_name(...)`` usage
13 (``ast.Name``) and every attribute access ``obj.old_name(...)``
14 (``ast.Attribute``).
15
16 Each edit is character-precise: the command records the exact (line, col)
17 range and replaces only that token, leaving the surrounding text intact.
18
19 The command is **dry-run by default** β€” pass ``--yes`` (or ``-y``) to write.
20 Agents should use ``--json --yes`` for fully automated pipelines.
21
22 Usage::
23
24 # Preview all changes
25 muse code rename billing.py::compute_total compute_invoice_total
26
27 # Apply without prompt
28 muse code rename billing.py::compute_total compute_invoice_total --yes
29
30 # Methods (scoped to the class body)
31 muse code rename billing.py::Invoice.compute_total compute_invoice_total -y
32
33 # Only rename the definition β€” leave call sites for manual review
34 muse code rename billing.py::compute_total compute_invoice_total \\
35 --scope definition --yes
36
37 # Machine-readable output for agents
38 muse code rename billing.py::compute_total compute_invoice_total --json
39
40 # Larger codebases β€” lift the file cap
41 muse code rename billing.py::compute_total compute_invoice_total \\
42 --max-files 200 --yes
43
44 Output (human)::
45
46 Renaming billing.py::compute_total β†’ compute_invoice_total
47
48 Definition
49 billing.py:4 def compute_total(self, items):
50
51 Import sites
52 tests/test_billing.py:2 from billing import compute_total
53
54 Call / reference sites
55 services/order.py:15 result = compute_total(items)
56 tests/test_billing.py:8 total = compute_total([1, 2, 3])
57
58 4 edit site(s) across 3 file(s)
59
60 Apply changes? [y/N]
61 """
62
63 from __future__ import annotations
64
65 import ast
66 import argparse
67 import json
68 import logging
69 import pathlib
70 import re
71 import sys
72 from collections import defaultdict
73 from typing import TypedDict
74
75 from muse.core.errors import ExitCode
76 from muse.core.repo import read_repo_id, require_repo
77 from muse.core.store import (
78 get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref,
79 Manifest,
80 )
81 from muse.plugins.code._query import language_of
82 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
83
84
85 type _EditSiteMap = dict[str, list["_EditSite"]]
86 logger = logging.getLogger(__name__)
87
88 # ── Constants ──────────────────────────────────────────────────────────────────
89
90 _MAX_SYMBOL_ADDR_LEN = 500
91 _MAX_NAME_LEN = 200
92 _DEFAULT_MAX_FILES = 100
93
94 # Valid Python identifier (post-normalisation).
95 _IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
96
97 # Dunder names require --force to rename.
98 _DUNDER_RE = re.compile(r"^__[A-Za-z_][A-Za-z0-9_]*__$")
99
100 _SCOPE_CHOICES = ("all", "definition", "imports", "callsites")
101
102
103 # ── TypedDicts ─────────────────────────────────────────────────────────────────
104
105
106 class _EditSite(TypedDict):
107 """A single token replacement site."""
108
109 file: str
110 line: int # 1-indexed
111 col_start: int # 0-indexed, inclusive
112 col_end: int # 0-indexed, exclusive
113 kind: str # "definition" | "import" | "reference"
114 context: str # full source line for display / JSON
115
116
117 class _RenameResult(TypedDict):
118 """Machine-readable summary of a rename operation."""
119
120 from_address: str
121 to_address: str
122 from_name: str
123 to_name: str
124 scope: str
125 dry_run: bool
126 files_to_modify: list[str]
127 total_edit_sites: int
128 edit_sites: list[_EditSite]
129 warnings: list[str]
130
131
132 # ── Helpers ────────────────────────────────────────────────────────────────────
133
134
135
136 def _validate_identifier(name: str, force: bool) -> str | None:
137 """Return an error string if *name* is not a valid rename target, else None."""
138 if not name:
139 return "New name must not be empty."
140 if len(name) > _MAX_NAME_LEN:
141 return f"New name too long (max {_MAX_NAME_LEN} chars)."
142 if not _IDENT_RE.match(name):
143 return f"'{name}' is not a valid Python identifier."
144 if _DUNDER_RE.match(name) and not force:
145 return (
146 f"'{name}' is a dunder name. Pass --force to rename it anyway."
147 )
148 return None
149
150
151 def _parse_address(address: str) -> tuple[str, list[str]] | None:
152 """Split 'billing.py::Invoice.method' into ('billing.py', ['Invoice', 'method']).
153
154 Returns None if the address is malformed.
155 """
156 if "::" not in address:
157 return None
158 file_part, _, symbol_part = address.partition("::")
159 if not file_part or not symbol_part:
160 return None
161 name_parts = symbol_part.split(".")
162 if any(not p for p in name_parts):
163 return None
164 return file_part, name_parts
165
166
167 def _line(lines: list[str], lineno: int) -> str:
168 """Return the source line (1-indexed) or empty string if out of range."""
169 return lines[lineno - 1] if 1 <= lineno <= len(lines) else ""
170
171
172 # ── Definition finder ──────────────────────────────────────────────────────────
173
174
175 def _find_definition_site(
176 source: str,
177 file_path: str,
178 name_parts: list[str],
179 ) -> _EditSite | None:
180 """Locate the AST position of the symbol's def/class token.
181
182 Respects class scope: ``billing.py::Invoice.compute_total`` only matches
183 the ``def compute_total`` *inside* the ``Invoice`` class body.
184 """
185 try:
186 if len(source) > MAX_AST_BYTES:
187 return None
188 tree = ast.parse(source, filename=file_path)
189 except SyntaxError:
190 return None
191
192 lines = source.splitlines()
193 target = name_parts[-1]
194
195 def _site(
196 node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef,
197 ) -> _EditSite:
198 if isinstance(node, ast.AsyncFunctionDef):
199 prefix_len = len("async def ")
200 elif isinstance(node, ast.FunctionDef):
201 prefix_len = len("def ")
202 else:
203 prefix_len = len("class ")
204 col_start = node.col_offset + prefix_len
205 col_end = col_start + len(target)
206 return _EditSite(
207 file=file_path,
208 line=node.lineno,
209 col_start=col_start,
210 col_end=col_end,
211 kind="definition",
212 context=_line(lines, node.lineno),
213 )
214
215 if len(name_parts) == 1:
216 # Module-level function or class.
217 for node in tree.body:
218 if (
219 isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
220 and node.name == target
221 ):
222 return _site(node)
223 if isinstance(node, ast.ClassDef) and node.name == target:
224 return _site(node)
225 elif len(name_parts) >= 2:
226 # Method (or nested class) β€” walk into the correct parent class.
227 class_name = name_parts[-2]
228 for top in tree.body:
229 if isinstance(top, ast.ClassDef) and top.name == class_name:
230 for item in ast.walk(top):
231 if (
232 isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
233 and item.name == target
234 ):
235 return _site(item)
236 if isinstance(item, ast.ClassDef) and item.name == target:
237 return _site(item)
238 return None
239
240
241 # ── Reference / import finder ──────────────────────────────────────────────────
242
243
244 def _find_reference_sites(
245 source: str,
246 file_path: str,
247 old_name: str,
248 include_imports: bool,
249 include_callsites: bool,
250 ) -> list[_EditSite]:
251 """Find all import and call / reference sites of *old_name* in *source*.
252
253 Does NOT return the definition site β€” call :func:`_find_definition_site`
254 for that separately.
255 """
256 try:
257 if len(source) > MAX_AST_BYTES:
258 return []
259 tree = ast.parse(source, filename=file_path)
260 except SyntaxError:
261 return []
262
263 lines = source.splitlines()
264 sites: list[_EditSite] = []
265 pattern = re.compile(r"\b" + re.escape(old_name) + r"\b")
266
267 for node in ast.walk(tree):
268 # ── Import sites ─────────────────────────────────────────────────────
269 if include_imports and isinstance(node, ast.ImportFrom):
270 for alias in node.names:
271 if alias.name == old_name:
272 # Python's AST does not expose per-alias column offsets, so
273 # we use a word-boundary regex against the specific import
274 # line. This is safe because we already know old_name
275 # appears here as an imported name.
276 import_line = _line(lines, node.lineno)
277 for m in pattern.finditer(import_line):
278 sites.append(_EditSite(
279 file=file_path,
280 line=node.lineno,
281 col_start=m.start(),
282 col_end=m.end(),
283 kind="import",
284 context=import_line,
285 ))
286
287 if include_imports and isinstance(node, ast.Import):
288 for alias in node.names:
289 if alias.name == old_name or alias.name.split(".")[-1] == old_name:
290 import_line = _line(lines, node.lineno)
291 for m in pattern.finditer(import_line):
292 sites.append(_EditSite(
293 file=file_path,
294 line=node.lineno,
295 col_start=m.start(),
296 col_end=m.end(),
297 kind="import",
298 context=import_line,
299 ))
300
301 if not include_callsites:
302 continue
303
304 # ── Bare name references (ast.Name) ──────────────────────────────────
305 if isinstance(node, ast.Name) and node.id == old_name:
306 sites.append(_EditSite(
307 file=file_path,
308 line=node.lineno,
309 col_start=node.col_offset,
310 col_end=node.col_offset + len(old_name),
311 kind="reference",
312 context=_line(lines, node.lineno),
313 ))
314
315 # ── Attribute access (ast.Attribute) β€” obj.old_name ──────────────────
316 elif isinstance(node, ast.Attribute) and node.attr == old_name:
317 end_line: int = node.end_lineno if node.end_lineno is not None else node.lineno
318 end_col: int = node.end_col_offset if node.end_col_offset is not None else 0
319 col_start = end_col - len(old_name)
320 if col_start < 0:
321 continue # defensive: malformed node
322 sites.append(_EditSite(
323 file=file_path,
324 line=end_line,
325 col_start=col_start,
326 col_end=end_col,
327 kind="reference",
328 context=_line(lines, end_line),
329 ))
330
331 return sites
332
333
334 # ── Edit applicator ────────────────────────────────────────────────────────────
335
336
337 def _apply_edits(source: str, sites: list[_EditSite], new_name: str) -> str:
338 """Apply all edit sites to *source*, replacing each token with *new_name*.
339
340 Edits are applied right-to-left within each line so that column offsets
341 remain valid for subsequent replacements on the same line.
342 """
343 lines = source.splitlines(keepends=True)
344
345 by_line: dict[int, list[_EditSite]] = defaultdict(list)
346 for site in sites:
347 by_line[site["line"]].append(site)
348
349 result: list[str] = []
350 for i, raw_line in enumerate(lines):
351 lineno = i + 1
352 if lineno not in by_line:
353 result.append(raw_line)
354 continue
355
356 # Strip the trailing newline, apply edits, then restore it.
357 stripped = raw_line.rstrip("\r\n")
358 tail = raw_line[len(stripped):]
359
360 # Sort descending by col_start so right-to-left replacement works.
361 edits = sorted(by_line[lineno], key=lambda s: -s["col_start"])
362 for edit in edits:
363 cs, ce = edit["col_start"], edit["col_end"]
364 # Defensive bounds check.
365 cs = max(0, min(cs, len(stripped)))
366 ce = max(cs, min(ce, len(stripped)))
367 stripped = stripped[:cs] + new_name + stripped[ce:]
368
369 result.append(stripped + tail)
370
371 return "".join(result)
372
373
374 # ── Deduplication ──────────────────────────────────────────────────────────────
375
376
377 def _dedup(sites: list[_EditSite]) -> list[_EditSite]:
378 """Remove duplicate sites (same file, line, col_start)."""
379 seen: set[tuple[int, int]] = set()
380 out: list[_EditSite] = []
381 for s in sites:
382 key = (s["line"], s["col_start"])
383 if key not in seen:
384 seen.add(key)
385 out.append(s)
386 return out
387
388
389 # ── CLI registration ───────────────────────────────────────────────────────────
390
391
392 def register(
393 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
394 ) -> None:
395 """Register the rename subcommand."""
396 parser = subparsers.add_parser(
397 "rename",
398 help=(
399 "Rename a symbol across its definition, all import sites, "
400 "and all call sites β€” AST-level, never touches string literals."
401 ),
402 description=__doc__,
403 formatter_class=argparse.RawDescriptionHelpFormatter,
404 )
405 parser.add_argument(
406 "address",
407 metavar="ADDRESS",
408 help=(
409 "Symbol to rename (e.g. billing.py::compute_total or "
410 "billing.py::Invoice.compute_total)."
411 ),
412 )
413 parser.add_argument(
414 "new_name",
415 metavar="NEW_NAME",
416 help="New bare identifier (e.g. compute_invoice_total).",
417 )
418 parser.add_argument(
419 "--scope",
420 default="all",
421 choices=_SCOPE_CHOICES,
422 metavar="SCOPE",
423 help=(
424 f"What to rename: {', '.join(_SCOPE_CHOICES)} (default: all). "
425 "'definition' renames only the def/class token; 'imports' updates "
426 "only import statements; 'callsites' updates only call / reference "
427 "sites. 'all' does all three."
428 ),
429 )
430 parser.add_argument(
431 "--yes", "-y",
432 dest="yes",
433 action="store_true",
434 help="Apply without an interactive confirmation prompt (for agents).",
435 )
436 parser.add_argument(
437 "--dry-run", "-n",
438 dest="dry_run",
439 action="store_true",
440 help="Show what would change without writing any files.",
441 )
442 parser.add_argument(
443 "--json",
444 dest="as_json",
445 action="store_true",
446 help=(
447 "Emit a machine-readable JSON summary of every edit site. "
448 "Combine with --yes to apply in one step."
449 ),
450 )
451 parser.add_argument(
452 "--max-files",
453 type=int,
454 default=_DEFAULT_MAX_FILES,
455 metavar="N",
456 dest="max_files",
457 help=(
458 f"Maximum number of Python files to scan for references "
459 f"(default: {_DEFAULT_MAX_FILES}). Increase for large repos."
460 ),
461 )
462 parser.add_argument(
463 "--force",
464 action="store_true",
465 help="Allow renaming dunder methods (__init__, __str__, …).",
466 )
467 parser.set_defaults(func=run)
468
469
470 # ── Main logic ─────────────────────────────────────────────────────────────────
471
472
473 def run(args: argparse.Namespace) -> None:
474 """Rename a symbol across its definition, imports, and call sites.
475
476 Operates exclusively on the working tree (HEAD snapshot for file discovery,
477 disk files for content). After renaming, run ``muse status`` then
478 ``muse commit`` as normal.
479 """
480 address: str = args.address
481 new_name: str = args.new_name
482 scope: str = args.scope
483 dry_run: bool = args.dry_run
484 yes: bool = args.yes
485 as_json: bool = args.as_json
486 max_files: int = clamp_int(args.max_files, 1, 10000, 'max_files')
487 force: bool = args.force
488
489 # ── Input validation ──────────────────────────────────────────────────────
490
491 if len(address) > _MAX_SYMBOL_ADDR_LEN:
492 print(
493 f"❌ ADDRESS too long (max {_MAX_SYMBOL_ADDR_LEN} chars).",
494 file=sys.stderr,
495 )
496 raise SystemExit(ExitCode.USER_ERROR)
497
498 if "::" not in address:
499 print(
500 "❌ ADDRESS must contain '::' (e.g. billing.py::compute_total).",
501 file=sys.stderr,
502 )
503 raise SystemExit(ExitCode.USER_ERROR)
504
505 if max_files < 1:
506 print("❌ --max-files must be >= 1.", file=sys.stderr)
507 raise SystemExit(ExitCode.USER_ERROR)
508
509 id_error = _validate_identifier(new_name, force)
510 if id_error:
511 print(f"❌ {id_error}", file=sys.stderr)
512 raise SystemExit(ExitCode.USER_ERROR)
513
514 parsed = _parse_address(address)
515 if parsed is None:
516 print(f"❌ Cannot parse address '{sanitize_display(address)}'.", file=sys.stderr)
517 raise SystemExit(ExitCode.USER_ERROR)
518 file_path, name_parts = parsed
519 old_name = name_parts[-1]
520
521 if old_name == new_name:
522 print("❌ New name is identical to the old name.", file=sys.stderr)
523 raise SystemExit(ExitCode.USER_ERROR)
524
525 # ── Locate repo ───────────────────────────────────────────────────────────
526
527 root = require_repo()
528 repo_id = read_repo_id(root)
529 branch = read_current_branch(root)
530
531 head = resolve_commit_ref(root, repo_id, branch, None)
532 if head is None:
533 print("❌ HEAD commit not found.", file=sys.stderr)
534 raise SystemExit(ExitCode.USER_ERROR)
535
536 manifest: Manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
537
538 # ── Security: path containment ────────────────────────────────────────────
539 # Reject addresses that would escape the repository root.
540
541 target_abs = (root / file_path).resolve()
542 try:
543 target_abs.relative_to(root.resolve())
544 except ValueError:
545 print(
546 f"❌ Path '{file_path}' escapes the repository root.",
547 file=sys.stderr,
548 )
549 raise SystemExit(ExitCode.USER_ERROR)
550
551 if not target_abs.is_file():
552 print(
553 f"❌ '{file_path}' not found in the working tree.",
554 file=sys.stderr,
555 )
556 raise SystemExit(ExitCode.USER_ERROR)
557
558 # ── Scope flags ───────────────────────────────────────────────────────────
559
560 include_definition = scope in ("all", "definition")
561 include_imports = scope in ("all", "imports")
562 include_callsites = scope in ("all", "callsites")
563
564 # ── Collect definition site ───────────────────────────────────────────────
565
566 all_sites: _EditSiteMap = {}
567
568 if include_definition:
569 def_source = target_abs.read_text(encoding="utf-8")
570 def_site = _find_definition_site(def_source, file_path, name_parts)
571 if def_site is None:
572 print(
573 f"❌ Symbol '{old_name}' not found in '{file_path}'.",
574 file=sys.stderr,
575 )
576 print(
577 f" Hint: muse code symbols --file {file_path}",
578 file=sys.stderr,
579 )
580 raise SystemExit(ExitCode.USER_ERROR)
581 all_sites[file_path] = [def_site]
582
583 # ── Collect reference / import sites ──────────────────────────────────────
584
585 run_warnings: list[str] = []
586
587 if include_imports or include_callsites:
588 # Gather all Python files in the snapshot.
589 python_files = sorted(
590 fp for fp in manifest if language_of(fp) == "Python"
591 )
592
593 if len(python_files) > max_files:
594 msg = (
595 f"{len(python_files)} Python files found; scanning only the "
596 f"first {max_files} (pass --max-files {len(python_files)} to scan all)."
597 )
598 run_warnings.append(msg)
599 if not as_json:
600 print(f"⚠️ {msg}", file=sys.stderr)
601 python_files = python_files[:max_files]
602
603 # The definition file is always included regardless of ordering.
604 if file_path not in python_files:
605 python_files = [file_path, *python_files]
606
607 for fp in python_files:
608 disk_path = root / fp
609 if not disk_path.is_file():
610 continue
611 source = disk_path.read_text(encoding="utf-8")
612 ref_sites = _find_reference_sites(
613 source, fp, old_name,
614 include_imports=include_imports,
615 include_callsites=include_callsites,
616 )
617
618 # Remove the definition token from reference results β€” it is already
619 # captured by _find_definition_site and must not be double-applied.
620 if fp == file_path and file_path in all_sites:
621 def_key = (
622 all_sites[file_path][0]["line"],
623 all_sites[file_path][0]["col_start"],
624 )
625 ref_sites = [
626 s for s in ref_sites
627 if (s["line"], s["col_start"]) != def_key
628 ]
629
630 if ref_sites:
631 all_sites.setdefault(fp, []).extend(ref_sites)
632
633 # ── Deduplicate ───────────────────────────────────────────────────────────
634
635 for fp in all_sites:
636 all_sites[fp] = _dedup(all_sites[fp])
637
638 total_sites = sum(len(v) for v in all_sites.values())
639 files_affected = sorted(all_sites)
640
641 # ── Build new address ─────────────────────────────────────────────────────
642
643 new_parts = name_parts[:-1] + [new_name]
644 new_address = file_path + "::" + ".".join(new_parts)
645
646 # ── Method rename warning ─────────────────────────────────────────────────
647 # Attribute renames (obj.old_name) are potentially ambiguous when multiple
648 # classes define a method with the same name. Warn in human mode.
649
650 is_method = len(name_parts) >= 2
651 attr_sites_found = any(
652 s["kind"] == "reference"
653 for sites in all_sites.values()
654 for s in sites
655 )
656
657 # ── JSON output ───────────────────────────────────────────────────────────
658
659 if as_json:
660 flat: list[_EditSite] = []
661 for fp in files_affected:
662 flat.extend(sorted(all_sites[fp], key=lambda s: s["line"]))
663 result: _RenameResult = {
664 "from_address": address,
665 "to_address": new_address,
666 "from_name": old_name,
667 "to_name": new_name,
668 "scope": scope,
669 "dry_run": dry_run,
670 "files_to_modify": files_affected if not dry_run else [],
671 "total_edit_sites": total_sites,
672 "edit_sites": flat,
673 "warnings": run_warnings,
674 }
675 print(json.dumps(result, indent=2))
676 if dry_run:
677 return
678 # Fall through to apply.
679
680 # ── Human output: preview ─────────────────────────────────────────────────
681
682 if not as_json:
683 print(f"\nRenaming {sanitize_display(address)} β†’ {sanitize_display(new_name)}\n")
684
685 for fp in files_affected:
686 sites = sorted(all_sites[fp], key=lambda s: s["line"])
687 defs = [s for s in sites if s["kind"] == "definition"]
688 imports = [s for s in sites if s["kind"] == "import"]
689 refs = [s for s in sites if s["kind"] == "reference"]
690 if defs:
691 print(" Definition")
692 for s in defs:
693 print(f" {sanitize_display(fp)}:{s['line']} {sanitize_display(s['context'].rstrip())}")
694 if imports:
695 print(" Import sites")
696 for s in imports:
697 print(f" {sanitize_display(fp)}:{s['line']} {sanitize_display(s['context'].rstrip())}")
698 if refs:
699 print(" Call / reference sites")
700 for s in refs:
701 print(f" {sanitize_display(fp)}:{s['line']} {sanitize_display(s['context'].rstrip())}")
702
703 print(f"\n {total_sites} edit site(s) across {len(files_affected)} file(s)")
704
705 if is_method and attr_sites_found:
706 print(
707 "\n ⚠️ Method rename: attribute call sites (obj.old_name) may "
708 "include false positives\n"
709 " if other classes define a method with the same name.\n"
710 " Review with --dry-run, then pass --scope definition if needed."
711 )
712
713 if dry_run:
714 print("\n (dry run β€” no files written)")
715 return
716
717 if not yes:
718 try:
719 answer = input("\nApply changes? [y/N] ").strip().lower()
720 except (EOFError, KeyboardInterrupt):
721 print("\nAborted.", file=sys.stderr)
722 raise SystemExit(ExitCode.USER_ERROR)
723 if answer not in ("y", "yes"):
724 print("Aborted.", file=sys.stderr)
725 raise SystemExit(ExitCode.USER_ERROR)
726
727 # ── Apply edits ───────────────────────────────────────────────────────────
728
729 for fp in files_affected:
730 disk_path = root / fp
731 if not disk_path.is_file():
732 logger.warning("⚠️ Skipping %s β€” not found on disk", fp)
733 continue
734 original = disk_path.read_text(encoding="utf-8")
735 modified = _apply_edits(original, all_sites[fp], new_name)
736 if modified != original:
737 disk_path.write_text(modified, encoding="utf-8")
738 if not as_json:
739 print(f" βœ… {sanitize_display(fp)}")
740
741 if not as_json:
742 print(
743 f"\n{total_sites} rename(s) applied across {len(files_affected)} file(s)."
744 )
745 print("Run `muse status` to review, then `muse commit`.")