cgcardona / muse public
patch.py python
187 lines 6.6 KB
44b98511 fix(code): close 4 architectural gaps — validation, deps, find-symbol, … Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """muse patch — surgical semantic patch at symbol granularity.
2
3 Modifies exactly one named symbol in a source file without touching any
4 surrounding code. The target is identified by its Muse symbol address
5 (``"file.py::SymbolName"`` or ``"file.py::ClassName.method"``).
6
7 This command is the foundation for AI-agent-driven code modification. An
8 agent that needs to change ``src/billing.py::compute_invoice_total`` can
9 do so with surgical precision — no risk of accidentally modifying adjacent
10 functions, no diff noise, no merge headache.
11
12 After patching, the working tree is dirty and ``muse status`` will show
13 exactly which symbol changed. Run ``muse commit`` as usual.
14
15 Usage::
16
17 # Write new body to a file and apply it
18 muse patch "src/billing.py::compute_invoice_total" --body new_body.py
19
20 # Read new body from stdin
21 echo "def foo(): return 42" | muse patch "src/utils.py::foo" --body -
22
23 # Preview what will change without writing
24 muse patch "src/billing.py::compute_invoice_total" --body new_body.py --dry-run
25
26 Output::
27
28 ✅ Patched src/billing.py::compute_invoice_total
29 Lines 2–4 replaced (was 3 lines, now 4 lines)
30 Surrounding code untouched (4 symbols preserved)
31 Run `muse status` to review, then `muse commit`
32 """
33 from __future__ import annotations
34
35 import logging
36 import pathlib
37 import sys
38
39 import typer
40
41 from muse.core.errors import ExitCode
42 from muse.core.repo import require_repo
43 from muse.plugins.code.ast_parser import parse_symbols, validate_syntax
44
45 logger = logging.getLogger(__name__)
46
47 app = typer.Typer()
48
49
50 def _locate_symbol(file_path: pathlib.Path, address: str) -> tuple[int, int] | None:
51 """Return ``(lineno, end_lineno)`` for the symbol at *address* in *file_path*.
52
53 Both values are 1-indexed. Returns ``None`` when the symbol is not found.
54 """
55 try:
56 raw = file_path.read_bytes()
57 except OSError:
58 return None
59 rel = address.split("::")[0]
60 tree = parse_symbols(raw, rel)
61 rec = tree.get(address)
62 if rec is None:
63 return None
64 return rec["lineno"], rec["end_lineno"]
65
66
67 def _read_new_body(body_arg: str) -> str | None:
68 """Read the replacement source from *body_arg* (file path or ``"-"``)."""
69 if body_arg == "-":
70 return sys.stdin.read()
71 src = pathlib.Path(body_arg)
72 if not src.exists():
73 return None
74 return src.read_text()
75
76
77 @app.callback(invoke_without_command=True)
78 def patch(
79 ctx: typer.Context,
80 address: str = typer.Argument(
81 ..., metavar="ADDRESS",
82 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
83 ),
84 body_arg: str = typer.Option(
85 ..., "--body", "-b", metavar="FILE",
86 help='File containing the replacement source (use "-" for stdin).',
87 ),
88 dry_run: bool = typer.Option(
89 False, "--dry-run", "-n",
90 help="Print what would change without writing to disk.",
91 ),
92 ) -> None:
93 """Replace exactly one symbol's source — surgical precision for agents.
94
95 ``muse patch`` locates the symbol at ADDRESS in the working tree,
96 reads the replacement source from --body, and splices it in at the
97 exact line range the symbol currently occupies. Every other symbol
98 in the file is untouched.
99
100 The replacement source must define exactly the symbol being replaced
101 (same name, at the top level of the file passed via --body). Muse
102 verifies the patched file remains parseable before writing.
103
104 After patching, run ``muse status`` to review the change, then
105 ``muse commit`` to record it. The structured delta will describe
106 exactly what changed at the semantic level (implementation changed,
107 signature changed, etc.).
108 """
109 root = require_repo()
110
111 # Parse address to get file path.
112 if "::" not in address:
113 typer.echo(f"❌ Invalid address '{address}' — must be 'file.py::SymbolName'.", err=True)
114 raise typer.Exit(code=ExitCode.USER_ERROR)
115
116 rel_path, sym_name = address.split("::", 1)
117
118 # Try muse-work/ first (the Muse working directory), fall back to repo root.
119 candidates = [
120 root / "muse-work" / rel_path,
121 root / rel_path,
122 ]
123 file_path: pathlib.Path | None = None
124 for c in candidates:
125 if c.exists():
126 file_path = c
127 break
128
129 if file_path is None:
130 typer.echo(f"❌ File '{rel_path}' not found in working tree.", err=True)
131 raise typer.Exit(code=ExitCode.USER_ERROR)
132
133 # Locate the symbol.
134 location = _locate_symbol(file_path, address)
135 if location is None:
136 typer.echo(
137 f"❌ Symbol '{address}' not found in {rel_path}.\n"
138 f" Run `muse symbols --file {rel_path}` to see available symbols.",
139 err=True,
140 )
141 raise typer.Exit(code=ExitCode.USER_ERROR)
142
143 start_line, end_line = location # 1-indexed, inclusive
144
145 # Read the replacement source.
146 new_body = _read_new_body(body_arg)
147 if new_body is None:
148 typer.echo(f"❌ Could not read body from '{body_arg}'.", err=True)
149 raise typer.Exit(code=ExitCode.USER_ERROR)
150
151 # Read current file.
152 original = file_path.read_text(encoding="utf-8")
153 lines = original.splitlines(keepends=True)
154 old_lines = lines[start_line - 1 : end_line]
155
156 # Ensure new_body ends with a newline.
157 if not new_body.endswith("\n"):
158 new_body += "\n"
159
160 # Splice.
161 new_lines = lines[: start_line - 1] + [new_body] + lines[end_line:]
162 new_content = "".join(new_lines)
163
164 # Verify the patched file is still parseable for all supported languages.
165 syntax_error = validate_syntax(new_content.encode("utf-8"), rel_path)
166 if syntax_error is not None:
167 typer.echo(f"❌ Patched file has a {syntax_error}", err=True)
168 raise typer.Exit(code=ExitCode.USER_ERROR)
169
170 if dry_run:
171 typer.echo(f"\n[dry-run] Would patch {rel_path}")
172 typer.echo(f" Symbol: {sym_name}")
173 typer.echo(f" Replace lines: {start_line}–{end_line} ({len(old_lines)} line(s))")
174 typer.echo(f" New source: {new_body.count(chr(10))} line(s)")
175 typer.echo(" No changes written (--dry-run).")
176 return
177
178 file_path.write_text(new_content, encoding="utf-8")
179
180 # Count remaining symbols for the "surrounding code untouched" message.
181 remaining = parse_symbols(file_path.read_bytes(), rel_path)
182 other_count = sum(1 for addr in remaining if addr != address)
183
184 typer.echo(f"\n✅ Patched {address}")
185 typer.echo(f" Lines {start_line}–{end_line} replaced ({len(old_lines)} → {new_body.count(chr(10))} line(s))")
186 typer.echo(f" Surrounding code untouched ({other_count} symbol(s) preserved)")
187 typer.echo(" Run `muse status` to review, then `muse commit`")