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