"""muse code patch — surgical semantic patch at symbol granularity. Modifies exactly one named symbol in a source file without touching any surrounding code. The target is identified by its Muse symbol address (``"file.py::SymbolName"`` or ``"file.py::ClassName.method"``). This command is the foundation for AI-agent-driven code modification. An agent that needs to change ``src/billing.py::compute_invoice_total`` can do so with surgical precision — no risk of accidentally modifying adjacent functions, no diff noise, no merge headache. After patching, the working tree is dirty and ``muse status`` will show exactly which symbol changed. Run ``muse commit`` as usual. Security note: the file path component of ADDRESS is validated via ``contain_path()`` before any disk access. Paths that escape the repo root (e.g. ``../../etc/passwd::foo``) are rejected with exit 1. Usage:: # Write new body to a file and apply it muse code patch "src/billing.py::compute_invoice_total" --body new_body.py # Read new body from stdin echo "def foo(): return 42" | muse code patch "src/utils.py::foo" --body - # Preview what will change without writing muse code patch "src/billing.py::compute_invoice_total" --body new_body.py --dry-run # Machine-readable output for agents (short flag: -j) muse code patch "src/utils.py::foo" --body new.py --json Output:: ✅ Patched src/billing.py::compute_invoice_total Lines 2–4 replaced (was 3 lines, now 4 lines) Surrounding code untouched (4 symbols preserved) Run `muse status` to review, then `muse commit` JSON output (``--json`` / ``-j``):: { "address": "src/billing.py::compute_invoice_total", "file": "src/billing.py", "lines_replaced": 3, "new_lines": 4, "symbols_preserved": 4, "dry_run": false, "exit_code": 0, "duration_ms": 12.3 } """ import argparse import json import logging import pathlib import sys import textwrap from typing import TypedDict from muse.core.envelope import EnvelopeJson, make_envelope from muse.core.errors import ExitCode from muse.core.repo import require_repo from muse.core.timing import start_timer from muse.core.validation import contain_path, sanitize_display from muse.plugins.code.ast_parser import parse_symbols, validate_syntax logger = logging.getLogger(__name__) class _PatchJson(EnvelopeJson): """Formal schema for the ``muse code patch --json`` output envelope. All fields are always present regardless of whether ``--dry-run`` is set. Fields ------ address: Full Muse symbol address that was (or would be) patched, e.g. ``"src/billing.py::compute_invoice_total"``. file: Repo-relative file path (the path component of *address*). lines_replaced: Number of source lines the old symbol body occupied. new_lines: Number of source lines in the replacement body. symbols_preserved: Count of other symbols in the file that were not touched. For dry-run this is computed from the pre-patch file. dry_run: ``true`` when ``--dry-run`` was passed (no disk writes). """ address: str file: str lines_replaced: int new_lines: int symbols_preserved: int dry_run: bool def _locate_symbol(file_path: pathlib.Path, address: str) -> tuple[int, int] | None: """Return ``(lineno, end_lineno)`` for the symbol at *address* in *file_path*. Both line numbers are 1-indexed and inclusive. Returns ``None`` when: - *file_path* does not exist or cannot be read (``OSError``). - The file is empty or contains no parseable symbols. - *address* names a symbol that does not exist in the file. Args: file_path: Absolute path to the source file on disk. address: Full Muse symbol address, e.g. ``"billing.py::Invoice.compute_total"``. The file-path prefix (everything before ``"::"`` ) must match the relative path that was used when the file was parsed. Returns: ``(start_line, end_line)`` tuple (1-indexed, inclusive), or ``None``. """ try: raw = file_path.read_bytes() except OSError: return None rel = address.split("::")[0] tree = parse_symbols(raw, rel) rec = tree.get(address) if rec is None: return None return rec["lineno"], rec["end_lineno"] def _read_new_body(body_arg: str) -> str | None: """Read the replacement source from *body_arg* (file path or ``"-"``). Args: body_arg: Either ``"-"`` to read from ``sys.stdin``, or a filesystem path to a file containing the replacement source text. Returns: The source text as a ``str``, or ``None`` if *body_arg* is a path that does not exist on disk. An empty string is returned (not ``None``) when the file or stdin contains no bytes. """ if body_arg == "-": return sys.stdin.read() src = pathlib.Path(body_arg) if not src.exists(): return None return src.read_text() def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register ``patch`` as a subcommand of ``muse code``. Adds the following arguments: - ``ADDRESS`` (positional) — full Muse symbol address to patch. - ``--body`` / ``-b`` FILE — file containing the replacement source (``"-"`` reads from stdin). - ``--dry-run`` / ``-n`` — preview changes without writing to disk. - ``--json`` / ``-j`` — emit a structured JSON result envelope. """ parser = subparsers.add_parser( "patch", help="Replace exactly one symbol's source — surgical precision for agents.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "address", metavar="ADDRESS", help='Symbol address, e.g. "src/billing.py::compute_invoice_total".', ) parser.add_argument( "--body", dest="body_arg", required=True, metavar="FILE", help='File containing the replacement source (use "-" for stdin).', ) parser.add_argument( "--dry-run", "-n", action="store_true", help="Print what would change without writing to disk.", ) parser.add_argument( "--json", "-j", dest="json_out", action="store_true", help="Emit result as JSON for agent consumption (see _PatchJson schema).", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Replace exactly one symbol's source — surgical precision for agents. Locates the symbol at ADDRESS in the working tree, reads the replacement source from ``--body``, and splices it in at the exact line range the symbol currently occupies. Every other symbol in the file is untouched. The file is validated for syntax before writing — the original is never modified if the replacement is invalid. Agent quickstart ---------------- :: muse code patch "billing.py::compute_total" --body /tmp/new.py --json muse code patch "billing.py::Invoice" --body /tmp/cls.py --dry-run --json JSON fields ----------- address Full symbol address that was (or would be) patched. file Repo-relative file path. lines_replaced Line count of the old body. new_lines Line count of the new body. symbols_preserved Count of other symbols unchanged in the file. dry_run ``true`` when ``--dry-run`` was passed. Exit codes ---------- 0 Success (or would-succeed in dry-run). 1 Symbol not found, syntax error in replacement, or path traversal. 2 Not inside a Muse repository. """ elapsed = start_timer() address: str = args.address body_arg: str = args.body_arg dry_run: bool = args.dry_run json_out: bool = args.json_out root = require_repo() # Parse address to get file path. if "::" not in address: print(f"❌ Invalid address '{sanitize_display(address)}' — must be 'file.py::SymbolName'.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) rel_path, sym_name = address.split("::", 1) # Validate the file path stays inside the repo root. try: file_path = contain_path(root, rel_path) except ValueError as exc: print(f"❌ {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if not file_path.exists(): print(f"❌ File '{rel_path}' not found in working tree.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # Locate the symbol. location = _locate_symbol(file_path, address) if location is None: print( f"❌ Symbol '{sanitize_display(address)}' not found in {sanitize_display(rel_path)}.\n" f" Run `muse symbols --file {sanitize_display(rel_path)}` to see available symbols.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) start_line, end_line = location # 1-indexed, inclusive # Read the replacement source. new_body = _read_new_body(body_arg) if new_body is None: print(f"❌ Could not read body from '{body_arg}'.", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) # Read current file. original = file_path.read_text(encoding="utf-8") lines = original.splitlines(keepends=True) old_lines = lines[start_line - 1 : end_line] # Ensure new_body ends with a newline. if not new_body.endswith("\n"): new_body += "\n" # Re-indent the replacement body to match the original symbol's indentation. # This lets agents supply unindented code (the natural form when writing a # function body in isolation) and have it spliced correctly into methods or # any nested scope. if old_lines: first_original = old_lines[0] orig_indent = len(first_original) - len(first_original.lstrip()) if orig_indent > 0: prefix = " " * orig_indent dedented = textwrap.dedent(new_body) re_indented: list[str] = [] for line in dedented.splitlines(keepends=True): stripped_content = line.rstrip("\n\r") ending = line[len(stripped_content):] if stripped_content.strip(): # non-blank line re_indented.append(prefix + stripped_content + ending) else: re_indented.append(line) # preserve blank lines as-is new_body = "".join(re_indented) # Splice. new_lines = lines[: start_line - 1] + [new_body] + lines[end_line:] new_content = "".join(new_lines) # Verify the patched file is still parseable for all supported languages. syntax_error = validate_syntax(new_content.encode("utf-8"), rel_path) if syntax_error is not None: print(f"❌ Patched file has a {syntax_error}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) new_line_count = new_body.count(chr(10)) # Count symbols other than the one being patched. # For dry-run we count from the original file; for live we count post-write. if dry_run: current_symbols = parse_symbols(file_path.read_bytes(), rel_path) other_count = sum(1 for addr in current_symbols if addr != address) if json_out: print(json.dumps(_PatchJson( **make_envelope(elapsed), address=address, file=rel_path, lines_replaced=len(old_lines), new_lines=new_line_count, symbols_preserved=other_count, dry_run=True, ))) return print(f"\n[dry-run] Would patch {rel_path}") print(f" Symbol: {sym_name}") print(f" Replace lines: {start_line}–{end_line} ({len(old_lines)} line(s))") print(f" New source: {new_line_count} line(s)") print(f" Preserves: {other_count} other symbol(s)") print(" No changes written (--dry-run).") return file_path.write_text(new_content, encoding="utf-8") # Count remaining symbols for the "surrounding code untouched" message. remaining = parse_symbols(file_path.read_bytes(), rel_path) other_count = sum(1 for addr in remaining if addr != address) if json_out: print(json.dumps(_PatchJson( **make_envelope(elapsed), address=address, file=rel_path, lines_replaced=len(old_lines), new_lines=new_line_count, symbols_preserved=other_count, dry_run=False, ))) return print(f"\n✅ Patched {sanitize_display(address)}") print(f" Lines {start_line}–{end_line} replaced ({len(old_lines)} → {new_line_count} line(s))") print(f" Surrounding code untouched ({other_count} symbol(s) preserved)") print(" Run `muse status` to review, then `muse commit`")