gabriel / muse public
patch.py python
228 lines 7.9 KB
8912a997 feat: code porcelain hardening — security, perf, JSON, docs Gabriel Cardona <gabriel@tellurstori.com> 2d 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 Security note: the file path component of ADDRESS is validated via
16 ``contain_path()`` before any disk access. Paths that escape the repo root
17 (e.g. ``../../etc/passwd::foo``) are rejected with exit 1.
18
19 Usage::
20
21 # Write new body to a file and apply it
22 muse patch "src/billing.py::compute_invoice_total" --body new_body.py
23
24 # Read new body from stdin
25 echo "def foo(): return 42" | muse patch "src/utils.py::foo" --body -
26
27 # Preview what will change without writing
28 muse patch "src/billing.py::compute_invoice_total" --body new_body.py --dry-run
29
30 # Machine-readable output for agents
31 muse patch "src/utils.py::foo" --body new.py --json
32
33 Output::
34
35 ✅ Patched src/billing.py::compute_invoice_total
36 Lines 2–4 replaced (was 3 lines, now 4 lines)
37 Surrounding code untouched (4 symbols preserved)
38 Run `muse status` to review, then `muse commit`
39
40 JSON output (``--json``)::
41
42 {
43 "address": "src/billing.py::compute_invoice_total",
44 "file": "src/billing.py",
45 "lines_replaced": 3,
46 "new_lines": 4,
47 "dry_run": false
48 }
49 """
50
51 from __future__ import annotations
52
53 import json
54 import logging
55 import pathlib
56 import sys
57
58 import typer
59
60 from muse.core.errors import ExitCode
61 from muse.core.repo import require_repo
62 from muse.core.validation import contain_path
63 from muse.plugins.code.ast_parser import parse_symbols, validate_syntax
64
65 logger = logging.getLogger(__name__)
66
67 app = typer.Typer()
68
69
70 def _locate_symbol(file_path: pathlib.Path, address: str) -> tuple[int, int] | None:
71 """Return ``(lineno, end_lineno)`` for the symbol at *address* in *file_path*.
72
73 Both values are 1-indexed. Returns ``None`` when the symbol is not found.
74 """
75 try:
76 raw = file_path.read_bytes()
77 except OSError:
78 return None
79 rel = address.split("::")[0]
80 tree = parse_symbols(raw, rel)
81 rec = tree.get(address)
82 if rec is None:
83 return None
84 return rec["lineno"], rec["end_lineno"]
85
86
87 def _read_new_body(body_arg: str) -> str | None:
88 """Read the replacement source from *body_arg* (file path or ``"-"``)."""
89 if body_arg == "-":
90 return sys.stdin.read()
91 src = pathlib.Path(body_arg)
92 if not src.exists():
93 return None
94 return src.read_text()
95
96
97 @app.callback(invoke_without_command=True)
98 def patch(
99 ctx: typer.Context,
100 address: str = typer.Argument(
101 ..., metavar="ADDRESS",
102 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
103 ),
104 body_arg: str = typer.Option(
105 ..., "--body", "-b", metavar="FILE",
106 help='File containing the replacement source (use "-" for stdin).',
107 ),
108 dry_run: bool = typer.Option(
109 False, "--dry-run", "-n",
110 help="Print what would change without writing to disk.",
111 ),
112 as_json: bool = typer.Option(False, "--json", help="Emit result as JSON for agent consumption."),
113 ) -> None:
114 """Replace exactly one symbol's source — surgical precision for agents.
115
116 ``muse patch`` locates the symbol at ADDRESS in the working tree,
117 reads the replacement source from --body, and splices it in at the
118 exact line range the symbol currently occupies. Every other symbol
119 in the file is untouched.
120
121 The replacement source must define exactly the symbol being replaced
122 (same name, at the top level of the file passed via --body). Muse
123 verifies the patched file remains parseable before writing.
124
125 The file path component of ADDRESS is validated against the repo root —
126 path-traversal addresses (e.g. ``../../etc/passwd::foo``) are rejected.
127
128 After patching, run ``muse status`` to review the change, then
129 ``muse commit`` to record it. The structured delta will describe
130 exactly what changed at the semantic level (implementation changed,
131 signature changed, etc.).
132 """
133 root = require_repo()
134
135 # Parse address to get file path.
136 if "::" not in address:
137 typer.echo(f"❌ Invalid address '{address}' — must be 'file.py::SymbolName'.", err=True)
138 raise typer.Exit(code=ExitCode.USER_ERROR)
139
140 rel_path, sym_name = address.split("::", 1)
141
142 # Validate the file path stays inside the repo root.
143 try:
144 file_path = contain_path(root, rel_path)
145 except ValueError as exc:
146 typer.echo(f"❌ {exc}", err=True)
147 raise typer.Exit(code=ExitCode.USER_ERROR)
148
149 if not file_path.exists():
150 typer.echo(f"❌ File '{rel_path}' not found in working tree.", err=True)
151 raise typer.Exit(code=ExitCode.USER_ERROR)
152
153 # Locate the symbol.
154 location = _locate_symbol(file_path, address)
155 if location is None:
156 typer.echo(
157 f"❌ Symbol '{address}' not found in {rel_path}.\n"
158 f" Run `muse symbols --file {rel_path}` to see available symbols.",
159 err=True,
160 )
161 raise typer.Exit(code=ExitCode.USER_ERROR)
162
163 start_line, end_line = location # 1-indexed, inclusive
164
165 # Read the replacement source.
166 new_body = _read_new_body(body_arg)
167 if new_body is None:
168 typer.echo(f"❌ Could not read body from '{body_arg}'.", err=True)
169 raise typer.Exit(code=ExitCode.USER_ERROR)
170
171 # Read current file.
172 original = file_path.read_text(encoding="utf-8")
173 lines = original.splitlines(keepends=True)
174 old_lines = lines[start_line - 1 : end_line]
175
176 # Ensure new_body ends with a newline.
177 if not new_body.endswith("\n"):
178 new_body += "\n"
179
180 # Splice.
181 new_lines = lines[: start_line - 1] + [new_body] + lines[end_line:]
182 new_content = "".join(new_lines)
183
184 # Verify the patched file is still parseable for all supported languages.
185 syntax_error = validate_syntax(new_content.encode("utf-8"), rel_path)
186 if syntax_error is not None:
187 typer.echo(f"❌ Patched file has a {syntax_error}", err=True)
188 raise typer.Exit(code=ExitCode.USER_ERROR)
189
190 new_line_count = new_body.count(chr(10))
191
192 if dry_run:
193 if as_json:
194 typer.echo(json.dumps({
195 "address": address,
196 "file": rel_path,
197 "lines_replaced": len(old_lines),
198 "new_lines": new_line_count,
199 "dry_run": True,
200 }, indent=2))
201 return
202 typer.echo(f"\n[dry-run] Would patch {rel_path}")
203 typer.echo(f" Symbol: {sym_name}")
204 typer.echo(f" Replace lines: {start_line}–{end_line} ({len(old_lines)} line(s))")
205 typer.echo(f" New source: {new_line_count} line(s)")
206 typer.echo(" No changes written (--dry-run).")
207 return
208
209 file_path.write_text(new_content, encoding="utf-8")
210
211 # Count remaining symbols for the "surrounding code untouched" message.
212 remaining = parse_symbols(file_path.read_bytes(), rel_path)
213 other_count = sum(1 for addr in remaining if addr != address)
214
215 if as_json:
216 typer.echo(json.dumps({
217 "address": address,
218 "file": rel_path,
219 "lines_replaced": len(old_lines),
220 "new_lines": new_line_count,
221 "dry_run": False,
222 }, indent=2))
223 return
224
225 typer.echo(f"\n✅ Patched {address}")
226 typer.echo(f" Lines {start_line}–{end_line} replaced ({len(old_lines)} → {new_line_count} line(s))")
227 typer.echo(f" Surrounding code untouched ({other_count} symbol(s) preserved)")
228 typer.echo(" Run `muse status` to review, then `muse commit`")