patch.py python
243 lines 8.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 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
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``)::
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 argparse
54 import json
55 import logging
56 import pathlib
57 import sys
58
59 from muse.core.errors import ExitCode
60 from muse.core.repo import require_repo
61 from muse.core.validation import contain_path, sanitize_display
62 from muse.plugins.code.ast_parser import parse_symbols, validate_syntax
63
64 logger = logging.getLogger(__name__)
65
66
67 def _locate_symbol(file_path: pathlib.Path, address: str) -> tuple[int, int] | None:
68 """Return ``(lineno, end_lineno)`` for the symbol at *address* in *file_path*.
69
70 Both values are 1-indexed. Returns ``None`` when the symbol is not found.
71 """
72 try:
73 raw = file_path.read_bytes()
74 except OSError:
75 return None
76 rel = address.split("::")[0]
77 tree = parse_symbols(raw, rel)
78 rec = tree.get(address)
79 if rec is None:
80 return None
81 return rec["lineno"], rec["end_lineno"]
82
83
84 def _read_new_body(body_arg: str) -> str | None:
85 """Read the replacement source from *body_arg* (file path or ``"-"``)."""
86 if body_arg == "-":
87 return sys.stdin.read()
88 src = pathlib.Path(body_arg)
89 if not src.exists():
90 return None
91 return src.read_text()
92
93
94 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
95 """Register the patch subcommand."""
96 parser = subparsers.add_parser(
97 "patch",
98 help="Replace exactly one symbol's source — surgical precision for agents.",
99 description=__doc__,
100 formatter_class=argparse.RawDescriptionHelpFormatter,
101 )
102 parser.add_argument(
103 "address",
104 metavar="ADDRESS",
105 help='Symbol address, e.g. "src/billing.py::compute_invoice_total".',
106 )
107 parser.add_argument(
108 "--body", "-b",
109 dest="body_arg",
110 required=True,
111 metavar="FILE",
112 help='File containing the replacement source (use "-" for stdin).',
113 )
114 parser.add_argument(
115 "--dry-run", "-n",
116 action="store_true",
117 help="Print what would change without writing to disk.",
118 )
119 parser.add_argument("--json", dest="as_json", action="store_true", help="Emit result as JSON for agent consumption.")
120 parser.set_defaults(func=run)
121
122
123 def run(args: argparse.Namespace) -> None:
124 """Replace exactly one symbol's source — surgical precision for agents.
125
126 ``muse patch`` locates the symbol at ADDRESS in the working tree,
127 reads the replacement source from --body, and splices it in at the
128 exact line range the symbol currently occupies. Every other symbol
129 in the file is untouched.
130
131 The replacement source must define exactly the symbol being replaced
132 (same name, at the top level of the file passed via --body). Muse
133 verifies the patched file remains parseable before writing.
134
135 The file path component of ADDRESS is validated against the repo root —
136 path-traversal addresses (e.g. ``../../etc/passwd::foo``) are rejected.
137
138 After patching, run ``muse status`` to review the change, then
139 ``muse commit`` to record it. The structured delta will describe
140 exactly what changed at the semantic level (implementation changed,
141 signature changed, etc.).
142 """
143 address: str = args.address
144 body_arg: str = args.body_arg
145 dry_run: bool = args.dry_run
146 as_json: bool = args.as_json
147
148 root = require_repo()
149
150 # Parse address to get file path.
151 if "::" not in address:
152 print(f"❌ Invalid address '{sanitize_display(address)}' — must be 'file.py::SymbolName'.", file=sys.stderr)
153 raise SystemExit(ExitCode.USER_ERROR)
154
155 rel_path, sym_name = address.split("::", 1)
156
157 # Validate the file path stays inside the repo root.
158 try:
159 file_path = contain_path(root, rel_path)
160 except ValueError as exc:
161 print(f"❌ {exc}", file=sys.stderr)
162 raise SystemExit(ExitCode.USER_ERROR)
163
164 if not file_path.exists():
165 print(f"❌ File '{rel_path}' not found in working tree.", file=sys.stderr)
166 raise SystemExit(ExitCode.USER_ERROR)
167
168 # Locate the symbol.
169 location = _locate_symbol(file_path, address)
170 if location is None:
171 print(
172 f"❌ Symbol '{sanitize_display(address)}' not found in {sanitize_display(rel_path)}.\n"
173 f" Run `muse symbols --file {sanitize_display(rel_path)}` to see available symbols.",
174 file=sys.stderr,
175 )
176 raise SystemExit(ExitCode.USER_ERROR)
177
178 start_line, end_line = location # 1-indexed, inclusive
179
180 # Read the replacement source.
181 new_body = _read_new_body(body_arg)
182 if new_body is None:
183 print(f"❌ Could not read body from '{body_arg}'.", file=sys.stderr)
184 raise SystemExit(ExitCode.USER_ERROR)
185
186 # Read current file.
187 original = file_path.read_text(encoding="utf-8")
188 lines = original.splitlines(keepends=True)
189 old_lines = lines[start_line - 1 : end_line]
190
191 # Ensure new_body ends with a newline.
192 if not new_body.endswith("\n"):
193 new_body += "\n"
194
195 # Splice.
196 new_lines = lines[: start_line - 1] + [new_body] + lines[end_line:]
197 new_content = "".join(new_lines)
198
199 # Verify the patched file is still parseable for all supported languages.
200 syntax_error = validate_syntax(new_content.encode("utf-8"), rel_path)
201 if syntax_error is not None:
202 print(f"❌ Patched file has a {syntax_error}", file=sys.stderr)
203 raise SystemExit(ExitCode.USER_ERROR)
204
205 new_line_count = new_body.count(chr(10))
206
207 if dry_run:
208 if as_json:
209 print(json.dumps({
210 "address": address,
211 "file": rel_path,
212 "lines_replaced": len(old_lines),
213 "new_lines": new_line_count,
214 "dry_run": True,
215 }, indent=2))
216 return
217 print(f"\n[dry-run] Would patch {rel_path}")
218 print(f" Symbol: {sym_name}")
219 print(f" Replace lines: {start_line}–{end_line} ({len(old_lines)} line(s))")
220 print(f" New source: {new_line_count} line(s)")
221 print(" No changes written (--dry-run).")
222 return
223
224 file_path.write_text(new_content, encoding="utf-8")
225
226 # Count remaining symbols for the "surrounding code untouched" message.
227 remaining = parse_symbols(file_path.read_bytes(), rel_path)
228 other_count = sum(1 for addr in remaining if addr != address)
229
230 if as_json:
231 print(json.dumps({
232 "address": address,
233 "file": rel_path,
234 "lines_replaced": len(old_lines),
235 "new_lines": new_line_count,
236 "dry_run": False,
237 }, indent=2))
238 return
239
240 print(f"\n✅ Patched {sanitize_display(address)}")
241 print(f" Lines {start_line}–{end_line} replaced ({len(old_lines)} → {new_line_count} line(s))")
242 print(f" Surrounding code untouched ({other_count} symbol(s) preserved)")
243 print(" Run `muse status` to review, then `muse commit`")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago