gabriel / muse public
test_cmd_patch.py python
509 lines 19.2 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Comprehensive tests for ``muse code patch``.
2
3 Coverage
4 --------
5 Unit
6 _locate_symbol — found, not found, OSError, nested class.method
7 _read_new_body — file path, stdin ("-"), missing file
8
9 Integration
10 patch basic — replaces symbol, leaves surrounding code intact
11 patch --dry-run — does NOT write to disk, reports correctly
12 patch --json — schema, dry-run flag, line counts
13 patch stdin — reads replacement from stdin (body_arg == "-")
14 patch missing sym — exits 1 with helpful message
15 patch bad address — exits 1 when "::" missing
16 patch syntax error — patched file invalid → rejected before writing
17 patch newline — body without trailing newline gets one added
18
19 Security
20 path traversal — ../../etc/passwd::foo rejected
21 absolute path — /etc/passwd::foo rejected
22 unicode in body — UTF-8 round-trips correctly
23 empty body — handled gracefully
24
25 Stress
26 100 symbols in file — locate_symbol for each under 1 s total
27 repeated patches — 20 sequential patches, all succeed
28 """
29
30 from __future__ import annotations
31
32 import json
33 import pathlib
34 import textwrap
35 import time
36
37 import pytest
38
39 from tests.cli_test_helper import CliRunner
40 from muse.cli.commands.patch import _locate_symbol, _read_new_body
41
42 cli = None
43 runner = CliRunner()
44
45
46 # ---------------------------------------------------------------------------
47 # Shared fixtures
48 # ---------------------------------------------------------------------------
49
50
51 @pytest.fixture
52 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
53 """Fresh code-domain repo with a small billing module."""
54 monkeypatch.chdir(tmp_path)
55 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
56 r = runner.invoke(cli, ["init", "--domain", "code"])
57 assert r.exit_code == 0, r.output
58
59 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
60 class Invoice:
61 def compute_total(self, items: list[int]) -> int:
62 return sum(items)
63
64 def apply_discount(self, total: float, pct: float) -> float:
65 return total * (1 - pct)
66
67 def validate_amount(amount: float) -> bool:
68 return amount > 0
69
70 def format_receipt(amount: float) -> str:
71 return f"Total: {amount:.2f}"
72 """))
73
74 r2 = runner.invoke(cli, ["commit", "-m", "initial billing"])
75 assert r2.exit_code == 0, r2.output
76 return tmp_path
77
78
79 # ---------------------------------------------------------------------------
80 # Unit — _locate_symbol
81 # ---------------------------------------------------------------------------
82
83
84 class TestLocateSymbol:
85 def test_finds_top_level_function(self, tmp_path: pathlib.Path) -> None:
86 src = tmp_path / "mod.py"
87 src.write_text("def foo(x: int) -> int:\n return x + 1\n")
88 result = _locate_symbol(src, "mod.py::foo")
89 assert result is not None
90 start, end = result
91 assert start == 1
92 assert end >= 1
93
94 def test_returns_none_for_missing_symbol(self, tmp_path: pathlib.Path) -> None:
95 src = tmp_path / "mod.py"
96 src.write_text("def foo(): pass\n")
97 result = _locate_symbol(src, "mod.py::bar")
98 assert result is None
99
100 def test_returns_none_for_nonexistent_file(self, tmp_path: pathlib.Path) -> None:
101 missing = tmp_path / "nowhere.py"
102 result = _locate_symbol(missing, "nowhere.py::foo")
103 assert result is None
104
105 def test_finds_method(self, tmp_path: pathlib.Path) -> None:
106 src = tmp_path / "mod.py"
107 src.write_text(textwrap.dedent("""\
108 class MyClass:
109 def my_method(self) -> None:
110 pass
111 """))
112 result = _locate_symbol(src, "mod.py::MyClass.my_method")
113 assert result is not None
114 start, end = result
115 assert start >= 2
116
117 def test_finds_class(self, tmp_path: pathlib.Path) -> None:
118 src = tmp_path / "mod.py"
119 src.write_text("class Foo:\n x: int = 1\n")
120 result = _locate_symbol(src, "mod.py::Foo")
121 assert result is not None
122
123 def test_multiline_function(self, tmp_path: pathlib.Path) -> None:
124 src = tmp_path / "mod.py"
125 src.write_text(textwrap.dedent("""\
126 def big_func(
127 a: int,
128 b: int,
129 ) -> int:
130 result = a + b
131 return result
132 """))
133 result = _locate_symbol(src, "mod.py::big_func")
134 assert result is not None
135 start, end = result
136 assert end > start # spans multiple lines
137
138
139 # ---------------------------------------------------------------------------
140 # Unit — _read_new_body
141 # ---------------------------------------------------------------------------
142
143
144 class TestReadNewBody:
145 def test_reads_from_file(self, tmp_path: pathlib.Path) -> None:
146 body_file = tmp_path / "new_body.py"
147 body_file.write_text("def foo(): return 42\n")
148 result = _read_new_body(str(body_file))
149 assert result == "def foo(): return 42\n"
150
151 def test_returns_none_for_missing_file(self) -> None:
152 result = _read_new_body("/nonexistent/path/body.py")
153 assert result is None
154
155 def test_reads_from_stdin_marker(
156 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
157 ) -> None:
158 import io
159 import sys
160 monkeypatch.setattr(sys, "stdin", io.StringIO("def bar(): pass\n"))
161 result = _read_new_body("-")
162 assert result == "def bar(): pass\n"
163
164 def test_reads_empty_file(self, tmp_path: pathlib.Path) -> None:
165 body_file = tmp_path / "empty.py"
166 body_file.write_text("")
167 result = _read_new_body(str(body_file))
168 assert result == ""
169
170 def test_reads_unicode_content(self, tmp_path: pathlib.Path) -> None:
171 body_file = tmp_path / "unicode.py"
172 body_file.write_text("def café() -> str:\n return 'café'\n")
173 result = _read_new_body(str(body_file))
174 assert result is not None
175 assert "café" in result
176
177
178 # ---------------------------------------------------------------------------
179 # Integration — basic patch
180 # ---------------------------------------------------------------------------
181
182
183 class TestPatchBasic:
184 def test_patch_replaces_symbol(self, repo: pathlib.Path) -> None:
185 body = repo / "new_validate.py"
186 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
187 result = runner.invoke(cli, [
188 "code", "patch",
189 "--body", str(body),
190 "billing.py::validate_amount",
191 ])
192 assert result.exit_code == 0, result.output
193 # File content updated.
194 src = (repo / "billing.py").read_text()
195 assert "amount >= 0" in src
196
197 def test_patch_leaves_other_symbols_intact(self, repo: pathlib.Path) -> None:
198 body = repo / "new_validate.py"
199 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
200 runner.invoke(cli, ["code", "patch", "--body", str(body), "billing.py::validate_amount"])
201 src = (repo / "billing.py").read_text()
202 # Other functions must still be present.
203 assert "format_receipt" in src
204 assert "compute_total" in src
205
206 def test_patch_success_message(self, repo: pathlib.Path) -> None:
207 body = repo / "new_validate.py"
208 body.write_text("def validate_amount(amount: float) -> bool:\n return amount >= 0\n")
209 result = runner.invoke(cli, [
210 "code", "patch", "--body", str(body), "billing.py::validate_amount",
211 ])
212 assert "Patched" in result.output or result.exit_code == 0
213
214 def test_patch_missing_symbol_exits_one(self, repo: pathlib.Path) -> None:
215 body = repo / "new.py"
216 body.write_text("def zzz_nonexistent(): pass\n")
217 result = runner.invoke(cli, [
218 "code", "patch", "--body", str(body), "billing.py::zzz_nonexistent",
219 ])
220 assert result.exit_code == 1
221
222 def test_patch_bad_address_no_separator_exits_one(self, repo: pathlib.Path) -> None:
223 body = repo / "new.py"
224 body.write_text("def foo(): pass\n")
225 result = runner.invoke(cli, [
226 "code", "patch", "--body", str(body), "billing_validate_amount",
227 ])
228 assert result.exit_code == 1
229
230 def test_patch_file_not_found_exits_one(self, repo: pathlib.Path) -> None:
231 body = repo / "new.py"
232 body.write_text("def foo(): pass\n")
233 result = runner.invoke(cli, [
234 "code", "patch", "--body", str(body), "nonexistent_file.py::foo",
235 ])
236 assert result.exit_code == 1
237
238 def test_patch_body_file_missing_exits_one(self, repo: pathlib.Path) -> None:
239 result = runner.invoke(cli, [
240 "code", "patch",
241 "--body", str(repo / "does_not_exist.py"),
242 "billing.py::validate_amount",
243 ])
244 assert result.exit_code == 1
245
246 def test_patch_without_trailing_newline_adds_one(self, repo: pathlib.Path) -> None:
247 body = repo / "new_validate.py"
248 body.write_text("def validate_amount(amount: float) -> bool:\n return True")
249 result = runner.invoke(cli, [
250 "code", "patch", "--body", str(body), "billing.py::validate_amount",
251 ])
252 assert result.exit_code == 0
253 src = (repo / "billing.py").read_text()
254 # The patched section should be followed by a newline.
255 assert src.endswith("\n")
256
257
258 # ---------------------------------------------------------------------------
259 # Integration — --dry-run
260 # ---------------------------------------------------------------------------
261
262
263 class TestPatchDryRun:
264 def test_dry_run_does_not_write(self, repo: pathlib.Path) -> None:
265 original = (repo / "billing.py").read_text()
266 body = repo / "new.py"
267 body.write_text("def validate_amount(x: float) -> bool:\n return True\n")
268 result = runner.invoke(cli, [
269 "code", "patch", "--dry-run", "--body", str(body), "billing.py::validate_amount",
270 ])
271 assert result.exit_code == 0
272 assert (repo / "billing.py").read_text() == original
273
274 def test_dry_run_reports_intent(self, repo: pathlib.Path) -> None:
275 body = repo / "new.py"
276 body.write_text("def validate_amount(x: float) -> bool:\n return True\n")
277 result = runner.invoke(cli, [
278 "code", "patch", "--dry-run", "--body", str(body), "billing.py::validate_amount",
279 ])
280 assert "dry-run" in result.output.lower() or "no changes" in result.output.lower()
281
282
283 # ---------------------------------------------------------------------------
284 # Integration — --json
285 # ---------------------------------------------------------------------------
286
287
288 class TestPatchJson:
289 def test_json_dry_run_schema(self, repo: pathlib.Path) -> None:
290 body = repo / "new.py"
291 body.write_text("def validate_amount(x: float) -> bool:\n return True\n")
292 result = runner.invoke(cli, [
293 "code", "patch", "--dry-run", "--json",
294 "--body", str(body), "billing.py::validate_amount",
295 ])
296 assert result.exit_code == 0, result.output
297 data = json.loads(result.output)
298 assert data["address"] == "billing.py::validate_amount"
299 assert data["dry_run"] is True
300 assert "lines_replaced" in data
301 assert "new_lines" in data
302
303 def test_json_live_patch_schema(self, repo: pathlib.Path) -> None:
304 body = repo / "new.py"
305 body.write_text("def validate_amount(x: float) -> bool:\n return True\n")
306 result = runner.invoke(cli, [
307 "code", "patch", "--json",
308 "--body", str(body), "billing.py::validate_amount",
309 ])
310 assert result.exit_code == 0, result.output
311 data = json.loads(result.output)
312 assert data["dry_run"] is False
313 assert data["file"] == "billing.py"
314
315 def test_json_traversal_error(self, repo: pathlib.Path) -> None:
316 body = repo / "new.py"
317 body.write_text("def foo(): pass\n")
318 result = runner.invoke(cli, [
319 "code", "patch", "--json",
320 "--body", str(body), "../../etc/passwd::foo",
321 ])
322 assert result.exit_code == 1
323
324
325 # ---------------------------------------------------------------------------
326 # Integration — stdin body
327 # ---------------------------------------------------------------------------
328
329
330 class TestPatchStdin:
331 def test_stdin_replaces_symbol(self, repo: pathlib.Path) -> None:
332 new_body = "def validate_amount(amount: float) -> bool:\n return amount != 0\n"
333 result = runner.invoke(
334 cli,
335 ["code", "patch", "--body", "-", "billing.py::validate_amount"],
336 input=new_body,
337 )
338 assert result.exit_code == 0, result.output
339 src = (repo / "billing.py").read_text()
340 assert "amount != 0" in src
341
342
343 # ---------------------------------------------------------------------------
344 # Integration — syntax validation
345 # ---------------------------------------------------------------------------
346
347
348 class TestPatchSyntaxValidation:
349 def test_syntax_error_in_replacement_rejected(self, repo: pathlib.Path) -> None:
350 bad = repo / "bad.py"
351 bad.write_text("def validate_amount(amount: float) -> bool:\n return ((\n")
352 result = runner.invoke(cli, [
353 "code", "patch", "--body", str(bad), "billing.py::validate_amount",
354 ])
355 assert result.exit_code == 1
356 # The original must be untouched.
357 src = (repo / "billing.py").read_text()
358 assert "validate_amount" in src
359
360 def test_original_unchanged_after_rejection(self, repo: pathlib.Path) -> None:
361 original = (repo / "billing.py").read_text()
362 bad = repo / "bad.py"
363 bad.write_text("def validate_amount(:\n")
364 runner.invoke(cli, ["code", "patch", "--body", str(bad), "billing.py::validate_amount"])
365 assert (repo / "billing.py").read_text() == original
366
367
368 # ---------------------------------------------------------------------------
369 # Security — path traversal
370 # ---------------------------------------------------------------------------
371
372
373 class TestPatchSecurity:
374 def test_dotdot_traversal_rejected(self, repo: pathlib.Path) -> None:
375 body = repo / "body.py"
376 body.write_text("def foo(): pass\n")
377 result = runner.invoke(cli, [
378 "code", "patch", "--body", str(body), "../../etc/passwd::foo",
379 ])
380 assert result.exit_code == 1
381 assert "etc/passwd" not in (repo / "etc").as_posix() or True # just checking exit
382
383 def test_absolute_path_in_address_rejected(self, repo: pathlib.Path) -> None:
384 body = repo / "body.py"
385 body.write_text("def foo(): pass\n")
386 result = runner.invoke(cli, [
387 "code", "patch", "--body", str(body), "/etc/passwd::foo",
388 ])
389 assert result.exit_code == 1
390
391 def test_unicode_body_round_trips(self, repo: pathlib.Path) -> None:
392 body = repo / "unicode.py"
393 body.write_text(
394 "def validate_amount(amount: float) -> bool:\n"
395 " # Vérifie le montant\n"
396 " return amount > 0\n"
397 )
398 result = runner.invoke(cli, [
399 "code", "patch", "--body", str(body), "billing.py::validate_amount",
400 ])
401 assert result.exit_code == 0
402 src = (repo / "billing.py").read_text()
403 assert "Vérifie" in src
404
405 def test_requires_repo(
406 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
407 ) -> None:
408 monkeypatch.chdir(tmp_path)
409 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
410 body = tmp_path / "body.py"
411 body.write_text("def foo(): pass\n")
412 result = runner.invoke(cli, [
413 "code", "patch", "--body", str(body), "foo.py::foo",
414 ])
415 assert result.exit_code != 0
416
417
418 # ---------------------------------------------------------------------------
419 # Stress — repeated patches and large files
420 # ---------------------------------------------------------------------------
421
422
423 class TestPatchStress:
424 @pytest.fixture
425 def large_repo(
426 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
427 ) -> pathlib.Path:
428 """Repo with a 100-function Python file."""
429 monkeypatch.chdir(tmp_path)
430 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
431 runner.invoke(cli, ["init", "--domain", "code"])
432
433 lines: list[str] = []
434 for i in range(100):
435 lines.append(f"def func_{i:03d}(x: int) -> int:")
436 lines.append(f" return x + {i}")
437 lines.append("")
438 (tmp_path / "large.py").write_text("\n".join(lines))
439
440 r = runner.invoke(cli, ["commit", "-m", "large file"])
441 assert r.exit_code == 0, r.output
442 return tmp_path
443
444 def test_locate_100_symbols_under_1s(self, large_repo: pathlib.Path) -> None:
445 src = large_repo / "large.py"
446 start = time.monotonic()
447 for i in range(100):
448 address = f"large.py::func_{i:03d}"
449 result = _locate_symbol(src, address)
450 assert result is not None, f"symbol {address!r} not found"
451 elapsed = time.monotonic() - start
452 assert elapsed < 1.0, f"locating 100 symbols took {elapsed:.2f}s"
453
454 def test_20_sequential_patches_all_succeed(self, large_repo: pathlib.Path) -> None:
455 """Apply 20 patches in sequence; each must succeed and not corrupt the file."""
456 body_file = large_repo / "body.py"
457 for i in range(20):
458 body_file.write_text(
459 f"def func_{i:03d}(x: int) -> int:\n return x * {i + 1}\n"
460 )
461 result = runner.invoke(cli, [
462 "code", "patch",
463 "--body", str(body_file),
464 f"large.py::func_{i:03d}",
465 ])
466 assert result.exit_code == 0, f"patch {i} failed: {result.output}"
467
468 # After 20 patches, the file must still be valid Python.
469 src = (large_repo / "large.py").read_bytes()
470 import ast
471 ast.parse(src) # raises SyntaxError if file is corrupt
472
473 def test_dry_run_100_times_no_disk_write(self, large_repo: pathlib.Path) -> None:
474 original = (large_repo / "large.py").read_text()
475 body_file = large_repo / "body.py"
476 body_file.write_text("def func_000(x: int) -> int:\n return 0\n")
477 start = time.monotonic()
478 for _ in range(10):
479 runner.invoke(cli, [
480 "code", "patch", "--dry-run",
481 "--body", str(body_file),
482 "large.py::func_000",
483 ])
484 elapsed = time.monotonic() - start
485 assert elapsed < 5.0, f"10 dry-runs took {elapsed:.2f}s"
486 assert (large_repo / "large.py").read_text() == original
487
488
489 class TestRegisterFlags:
490 def _parse(self, *args: str) -> "argparse.Namespace":
491 import argparse
492 from muse.cli.commands.patch import register
493 p = argparse.ArgumentParser()
494 subs = p.add_subparsers()
495 register(subs)
496 return p.parse_args(["patch", "dummy::sym", "--body", "/dev/null", *args])
497
498 def test_json_short_flag(self) -> None:
499 args = self._parse("-j")
500 assert args.json_out is True
501
502 def test_json_long_flag(self) -> None:
503 args = self._parse("--json")
504 assert args.json_out is True
505
506 def test_default_no_json(self) -> None:
507 args = self._parse()
508 assert args.json_out is False
509
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 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago