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