gabriel / muse public
patch.py python
348 lines 12.9 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """muse code 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 code 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 code patch "src/utils.py::foo" --body -
26
27 # Preview what will change without writing
28 muse code patch "src/billing.py::compute_invoice_total" --body new_body.py --dry-run
29
30 # Machine-readable output for agents (short flag: -j)
31 muse code 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`` / ``-j``)::
41
42 {
43 "address": "src/billing.py::compute_invoice_total",
44 "file": "src/billing.py",
45 "lines_replaced": 3,
46 "new_lines": 4,
47 "symbols_preserved": 4,
48 "dry_run": false,
49 "exit_code": 0,
50 "duration_ms": 12.3
51 }
52 """
53
54 import argparse
55 import json
56 import logging
57 import pathlib
58 import sys
59 import textwrap
60 from typing import TypedDict
61
62 from muse.core.envelope import EnvelopeJson, make_envelope
63 from muse.core.errors import ExitCode
64 from muse.core.repo import require_repo
65 from muse.core.timing import start_timer
66 from muse.core.validation import contain_path, sanitize_display
67 from muse.plugins.code.ast_parser import parse_symbols, validate_syntax
68
69 logger = logging.getLogger(__name__)
70
71 class _PatchJson(EnvelopeJson):
72 """Formal schema for the ``muse code patch --json`` output envelope.
73
74 All fields are always present regardless of whether ``--dry-run`` is set.
75
76 Fields
77 ------
78 address: Full Muse symbol address that was (or would be) patched,
79 e.g. ``"src/billing.py::compute_invoice_total"``.
80 file: Repo-relative file path (the path component of *address*).
81 lines_replaced: Number of source lines the old symbol body occupied.
82 new_lines: Number of source lines in the replacement body.
83 symbols_preserved: Count of other symbols in the file that were not touched.
84 For dry-run this is computed from the pre-patch file.
85 dry_run: ``true`` when ``--dry-run`` was passed (no disk writes).
86 """
87
88 address: str
89 file: str
90 lines_replaced: int
91 new_lines: int
92 symbols_preserved: int
93 dry_run: bool
94
95 def _locate_symbol(file_path: pathlib.Path, address: str) -> tuple[int, int] | None:
96 """Return ``(lineno, end_lineno)`` for the symbol at *address* in *file_path*.
97
98 Both line numbers are 1-indexed and inclusive. Returns ``None`` when:
99
100 - *file_path* does not exist or cannot be read (``OSError``).
101 - The file is empty or contains no parseable symbols.
102 - *address* names a symbol that does not exist in the file.
103
104 Args:
105 file_path: Absolute path to the source file on disk.
106 address: Full Muse symbol address, e.g. ``"billing.py::Invoice.compute_total"``.
107 The file-path prefix (everything before ``"::"`` ) must match
108 the relative path that was used when the file was parsed.
109
110 Returns:
111 ``(start_line, end_line)`` tuple (1-indexed, inclusive), or ``None``.
112 """
113 try:
114 raw = file_path.read_bytes()
115 except OSError:
116 return None
117 rel = address.split("::")[0]
118 tree = parse_symbols(raw, rel)
119 rec = tree.get(address)
120 if rec is None:
121 return None
122 return rec["lineno"], rec["end_lineno"]
123
124 def _read_new_body(body_arg: str) -> str | None:
125 """Read the replacement source from *body_arg* (file path or ``"-"``).
126
127 Args:
128 body_arg: Either ``"-"`` to read from ``sys.stdin``, or a filesystem
129 path to a file containing the replacement source text.
130
131 Returns:
132 The source text as a ``str``, or ``None`` if *body_arg* is a path
133 that does not exist on disk. An empty string is returned (not
134 ``None``) when the file or stdin contains no bytes.
135 """
136 if body_arg == "-":
137 return sys.stdin.read()
138 src = pathlib.Path(body_arg)
139 if not src.exists():
140 return None
141 return src.read_text()
142
143 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
144 """Register ``patch`` as a subcommand of ``muse code``.
145
146 Adds the following arguments:
147
148 - ``ADDRESS`` (positional) — full Muse symbol address to patch.
149 - ``--body`` / ``-b`` FILE — file containing the replacement source
150 (``"-"`` reads from stdin).
151 - ``--dry-run`` / ``-n`` — preview changes without writing to disk.
152 - ``--json`` / ``-j`` — emit a structured JSON result envelope.
153 """
154 parser = subparsers.add_parser(
155 "patch",
156 help="Replace exactly one symbol's source — surgical precision for agents.",
157 description=__doc__,
158 formatter_class=argparse.RawDescriptionHelpFormatter,
159 )
160 parser.add_argument(
161 "address",
162 metavar="ADDRESS",
163 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
164 )
165 parser.add_argument(
166 "--body",
167 dest="body_arg",
168 required=True,
169 metavar="FILE",
170 help='File containing the replacement source (use "-" for stdin).',
171 )
172 parser.add_argument(
173 "--dry-run", "-n",
174 action="store_true",
175 help="Print what would change without writing to disk.",
176 )
177 parser.add_argument(
178 "--json", "-j",
179 dest="json_out",
180 action="store_true",
181 help="Emit result as JSON for agent consumption (see _PatchJson schema).",
182 )
183 parser.set_defaults(func=run)
184
185 def run(args: argparse.Namespace) -> None:
186 """Replace exactly one symbol's source — surgical precision for agents.
187
188 Locates the symbol at ADDRESS in the working tree, reads the replacement
189 source from ``--body``, and splices it in at the exact line range the
190 symbol currently occupies. Every other symbol in the file is untouched.
191 The file is validated for syntax before writing — the original is never
192 modified if the replacement is invalid.
193
194 Agent quickstart
195 ----------------
196 ::
197
198 muse code patch "billing.py::compute_total" --body /tmp/new.py --json
199 muse code patch "billing.py::Invoice" --body /tmp/cls.py --dry-run --json
200
201 JSON fields
202 -----------
203 address Full symbol address that was (or would be) patched.
204 file Repo-relative file path.
205 lines_replaced Line count of the old body.
206 new_lines Line count of the new body.
207 symbols_preserved Count of other symbols unchanged in the file.
208 dry_run ``true`` when ``--dry-run`` was passed.
209
210 Exit codes
211 ----------
212 0 Success (or would-succeed in dry-run).
213 1 Symbol not found, syntax error in replacement, or path traversal.
214 2 Not inside a Muse repository.
215 """
216 elapsed = start_timer()
217
218 address: str = args.address
219 body_arg: str = args.body_arg
220 dry_run: bool = args.dry_run
221 json_out: bool = args.json_out
222
223 root = require_repo()
224
225 # Parse address to get file path.
226 if "::" not in address:
227 print(f"❌ Invalid address '{sanitize_display(address)}' — must be 'file.py::SymbolName'.", file=sys.stderr)
228 raise SystemExit(ExitCode.USER_ERROR)
229
230 rel_path, sym_name = address.split("::", 1)
231
232 # Validate the file path stays inside the repo root.
233 try:
234 file_path = contain_path(root, rel_path)
235 except ValueError as exc:
236 print(f"❌ {exc}", file=sys.stderr)
237 raise SystemExit(ExitCode.USER_ERROR)
238
239 if not file_path.exists():
240 print(f"❌ File '{rel_path}' not found in working tree.", file=sys.stderr)
241 raise SystemExit(ExitCode.USER_ERROR)
242
243 # Locate the symbol.
244 location = _locate_symbol(file_path, address)
245 if location is None:
246 print(
247 f"❌ Symbol '{sanitize_display(address)}' not found in {sanitize_display(rel_path)}.\n"
248 f" Run `muse symbols --file {sanitize_display(rel_path)}` to see available symbols.",
249 file=sys.stderr,
250 )
251 raise SystemExit(ExitCode.USER_ERROR)
252
253 start_line, end_line = location # 1-indexed, inclusive
254
255 # Read the replacement source.
256 new_body = _read_new_body(body_arg)
257 if new_body is None:
258 print(f"❌ Could not read body from '{body_arg}'.", file=sys.stderr)
259 raise SystemExit(ExitCode.USER_ERROR)
260
261 # Read current file.
262 original = file_path.read_text(encoding="utf-8")
263 lines = original.splitlines(keepends=True)
264 old_lines = lines[start_line - 1 : end_line]
265
266 # Ensure new_body ends with a newline.
267 if not new_body.endswith("\n"):
268 new_body += "\n"
269
270 # Re-indent the replacement body to match the original symbol's indentation.
271 # This lets agents supply unindented code (the natural form when writing a
272 # function body in isolation) and have it spliced correctly into methods or
273 # any nested scope.
274 if old_lines:
275 first_original = old_lines[0]
276 orig_indent = len(first_original) - len(first_original.lstrip())
277 if orig_indent > 0:
278 prefix = " " * orig_indent
279 dedented = textwrap.dedent(new_body)
280 re_indented: list[str] = []
281 for line in dedented.splitlines(keepends=True):
282 stripped_content = line.rstrip("\n\r")
283 ending = line[len(stripped_content):]
284 if stripped_content.strip(): # non-blank line
285 re_indented.append(prefix + stripped_content + ending)
286 else:
287 re_indented.append(line) # preserve blank lines as-is
288 new_body = "".join(re_indented)
289
290 # Splice.
291 new_lines = lines[: start_line - 1] + [new_body] + lines[end_line:]
292 new_content = "".join(new_lines)
293
294 # Verify the patched file is still parseable for all supported languages.
295 syntax_error = validate_syntax(new_content.encode("utf-8"), rel_path)
296 if syntax_error is not None:
297 print(f"❌ Patched file has a {syntax_error}", file=sys.stderr)
298 raise SystemExit(ExitCode.USER_ERROR)
299
300 new_line_count = new_body.count(chr(10))
301
302 # Count symbols other than the one being patched.
303 # For dry-run we count from the original file; for live we count post-write.
304 if dry_run:
305 current_symbols = parse_symbols(file_path.read_bytes(), rel_path)
306 other_count = sum(1 for addr in current_symbols if addr != address)
307
308 if json_out:
309 print(json.dumps(_PatchJson(
310 **make_envelope(elapsed),
311 address=address,
312 file=rel_path,
313 lines_replaced=len(old_lines),
314 new_lines=new_line_count,
315 symbols_preserved=other_count,
316 dry_run=True,
317 )))
318 return
319 print(f"\n[dry-run] Would patch {rel_path}")
320 print(f" Symbol: {sym_name}")
321 print(f" Replace lines: {start_line}–{end_line} ({len(old_lines)} line(s))")
322 print(f" New source: {new_line_count} line(s)")
323 print(f" Preserves: {other_count} other symbol(s)")
324 print(" No changes written (--dry-run).")
325 return
326
327 file_path.write_text(new_content, encoding="utf-8")
328
329 # Count remaining symbols for the "surrounding code untouched" message.
330 remaining = parse_symbols(file_path.read_bytes(), rel_path)
331 other_count = sum(1 for addr in remaining if addr != address)
332
333 if json_out:
334 print(json.dumps(_PatchJson(
335 **make_envelope(elapsed),
336 address=address,
337 file=rel_path,
338 lines_replaced=len(old_lines),
339 new_lines=new_line_count,
340 symbols_preserved=other_count,
341 dry_run=False,
342 )))
343 return
344
345 print(f"\n✅ Patched {sanitize_display(address)}")
346 print(f" Lines {start_line}–{end_line} replaced ({len(old_lines)} → {new_line_count} line(s))")
347 print(f" Surrounding code untouched ({other_count} symbol(s) preserved)")
348 print(" Run `muse status` to review, then `muse commit`")
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago