gabriel / muse public
test_code_plugin.py python
1718 lines 66.0 KB
1afd410c fix: detect file-level move+edit as a single PatchOp Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """Tests for the code domain plugin.
2
3 Coverage
4 --------
5 Unit
6 - :mod:`muse.plugins.code.ast_parser`: symbol extraction, content IDs,
7 rename detection hashes, import handling.
8 - :mod:`muse.plugins.code.symbol_diff`: diff_symbol_trees golden cases,
9 cross-file move annotation.
10
11 Protocol conformance
12 - ``CodePlugin`` satisfies ``MuseDomainPlugin`` and ``StructuredMergePlugin``.
13
14 Snapshot
15 - Path form: walks all files, raw-bytes hash, honours .museignore.
16 - Manifest form: returned as-is.
17 - Stability: two calls on the same directory produce identical results.
18
19 Diff
20 - File-level (no repo_root): added / removed / modified.
21 - Semantic (with repo_root via object store): symbol-level PatchOps,
22 rename detection, formatting-only suppression.
23
24 Golden diff cases
25 - Add a new function → InsertOp inside PatchOp.
26 - Remove a function → DeleteOp inside PatchOp.
27 - Rename a function → ReplaceOp with "renamed to" in new_summary.
28 - Change function body → ReplaceOp with "implementation changed".
29 - Change function signature → ReplaceOp with "signature changed".
30 - Add a new file → InsertOp (or PatchOp with all-insert child ops).
31 - Remove a file → DeleteOp (or PatchOp with all-delete child ops).
32 - Reformat only → ReplaceOp with "reformatted" in new_summary.
33
34 Merge
35 - Different symbols in same file → auto-merge (no conflicts).
36 - Same symbol modified by both → symbol-level conflict address.
37 - Disjoint files → auto-merge.
38 - File-level three-way merge correctness.
39
40 Schema
41 - Valid DomainSchema with five dimensions.
42 - merge_mode == "three_way".
43 - schema_version == 1.
44
45 Drift
46 - No drift: committed equals live.
47 - Has drift: file added / modified / removed.
48
49 Plugin registry
50 - "code" is in the registered domain list.
51 """
52
53 import hashlib
54 import pathlib
55 import textwrap
56
57 import pytest
58
59 from muse._version import __version__
60 from muse.core.object_store import write_object
61 from muse.domain import (
62 InsertOp,
63 MuseDomainPlugin,
64 SnapshotManifest,
65 StructuredMergePlugin,
66 )
67 from muse.plugins.code.ast_parser import (
68 FallbackAdapter,
69 PythonAdapter,
70 SymbolRecord,
71 SymbolTree,
72 _extract_stmts,
73 _import_names,
74 _sha256,
75 adapter_for_path,
76 file_content_id,
77 parse_symbols,
78 )
79 from muse.plugins.code.plugin import CodePlugin, _hash_file
80 from muse.plugins.code.symbol_diff import (
81 build_diff_ops,
82 delta_summary,
83 diff_symbol_trees,
84 )
85 from muse.plugins.registry import registered_domains
86
87
88 # ---------------------------------------------------------------------------
89 # Helpers
90 # ---------------------------------------------------------------------------
91
92
93 def _sha256_bytes(b: bytes) -> str:
94 return hashlib.sha256(b).hexdigest()
95
96
97 def _make_manifest(files: dict[str, str]) -> SnapshotManifest:
98 return SnapshotManifest(files=files, domain="code")
99
100
101 def _src(code: str) -> bytes:
102 return textwrap.dedent(code).encode()
103
104
105 def _empty_tree() -> SymbolTree:
106 return {}
107
108
109 def _store_blob(repo_root: pathlib.Path, data: bytes) -> str:
110 oid = _sha256_bytes(data)
111 write_object(repo_root, oid, data)
112 return oid
113
114
115 # ---------------------------------------------------------------------------
116 # Plugin registry
117 # ---------------------------------------------------------------------------
118
119
120 def test_code_in_registry() -> None:
121 assert "code" in registered_domains()
122
123
124 # ---------------------------------------------------------------------------
125 # Protocol conformance
126 # ---------------------------------------------------------------------------
127
128
129 def test_satisfies_muse_domain_plugin() -> None:
130 plugin = CodePlugin()
131 assert isinstance(plugin, MuseDomainPlugin)
132
133
134 def test_satisfies_structured_merge_plugin() -> None:
135 plugin = CodePlugin()
136 assert isinstance(plugin, StructuredMergePlugin)
137
138
139 # ---------------------------------------------------------------------------
140 # PythonAdapter — unit tests
141 # ---------------------------------------------------------------------------
142
143
144 class TestPythonAdapter:
145 adapter = PythonAdapter()
146
147 def test_supported_extensions(self) -> None:
148 assert ".py" in self.adapter.supported_extensions()
149 assert ".pyi" in self.adapter.supported_extensions()
150
151 def test_parse_top_level_function(self) -> None:
152 src = _src("""\
153 def add(a: int, b: int) -> int:
154 return a + b
155 """)
156 tree = self.adapter.parse_symbols(src, "utils.py")
157 assert "utils.py::add" in tree
158 rec = tree["utils.py::add"]
159 assert rec["kind"] == "function"
160 assert rec["name"] == "add"
161 assert rec["qualified_name"] == "add"
162
163 def test_parse_async_function(self) -> None:
164 src = _src("""\
165 async def fetch(url: str) -> bytes:
166 pass
167 """)
168 tree = self.adapter.parse_symbols(src, "api.py")
169 assert "api.py::fetch" in tree
170 assert tree["api.py::fetch"]["kind"] == "async_function"
171
172 def test_parse_class_and_methods(self) -> None:
173 src = _src("""\
174 class Dog:
175 def bark(self) -> None:
176 print("woof")
177 def sit(self) -> None:
178 pass
179 """)
180 tree = self.adapter.parse_symbols(src, "animals.py")
181 assert "animals.py::Dog" in tree
182 assert tree["animals.py::Dog"]["kind"] == "class"
183 assert "animals.py::Dog.bark" in tree
184 assert tree["animals.py::Dog.bark"]["kind"] == "method"
185 assert "animals.py::Dog.sit" in tree
186
187 def test_parse_imports(self) -> None:
188 src = _src("""\
189 import os
190 import sys
191 from pathlib import Path
192 """)
193 tree = self.adapter.parse_symbols(src, "app.py")
194 assert "app.py::import::os" in tree
195 assert "app.py::import::sys" in tree
196 assert "app.py::import::Path" in tree
197
198 def test_parse_top_level_variable(self) -> None:
199 src = _src("""\
200 MAX_RETRIES = 3
201 VERSION: str = "1.0"
202 """)
203 tree = self.adapter.parse_symbols(src, "config.py")
204 assert "config.py::MAX_RETRIES" in tree
205 assert tree["config.py::MAX_RETRIES"]["kind"] == "variable"
206 assert "config.py::VERSION" in tree
207
208 def test_syntax_error_returns_empty_tree(self) -> None:
209 src = b"def broken("
210 tree = self.adapter.parse_symbols(src, "broken.py")
211 assert tree == {}
212
213 def test_content_id_stable_across_calls(self) -> None:
214 src = _src("""\
215 def hello() -> str:
216 return "world"
217 """)
218 t1 = self.adapter.parse_symbols(src, "a.py")
219 t2 = self.adapter.parse_symbols(src, "a.py")
220 assert t1["a.py::hello"]["content_id"] == t2["a.py::hello"]["content_id"]
221
222 def test_formatting_does_not_change_content_id(self) -> None:
223 """Reformatting a function must not change its content_id."""
224 src1 = _src("""\
225 def add(a, b):
226 return a + b
227 """)
228 src2 = _src("""\
229 def add(a,b):
230 return a + b
231 """)
232 t1 = self.adapter.parse_symbols(src1, "f.py")
233 t2 = self.adapter.parse_symbols(src2, "f.py")
234 assert t1["f.py::add"]["content_id"] == t2["f.py::add"]["content_id"]
235
236 def test_body_hash_differs_from_content_id(self) -> None:
237 src = _src("""\
238 def compute(x: int) -> int:
239 return x * 2
240 """)
241 tree = self.adapter.parse_symbols(src, "m.py")
242 rec = tree["m.py::compute"]
243 assert rec["body_hash"] != rec["content_id"] # body excludes def line
244
245 def test_rename_detection_via_body_hash(self) -> None:
246 """Two functions with identical bodies but different names share body_hash."""
247 src1 = _src("def foo(x):\n return x + 1\n")
248 src2 = _src("def bar(x):\n return x + 1\n")
249 t1 = self.adapter.parse_symbols(src1, "f.py")
250 t2 = self.adapter.parse_symbols(src2, "f.py")
251 assert t1["f.py::foo"]["body_hash"] == t2["f.py::bar"]["body_hash"]
252 assert t1["f.py::foo"]["content_id"] != t2["f.py::bar"]["content_id"]
253
254 def test_signature_id_same_despite_body_change(self) -> None:
255 src1 = _src("def calc(x: int) -> int:\n return x\n")
256 src2 = _src("def calc(x: int) -> int:\n return x * 10\n")
257 t1 = self.adapter.parse_symbols(src1, "m.py")
258 t2 = self.adapter.parse_symbols(src2, "m.py")
259 assert t1["m.py::calc"]["signature_id"] == t2["m.py::calc"]["signature_id"]
260 assert t1["m.py::calc"]["body_hash"] != t2["m.py::calc"]["body_hash"]
261
262 def test_file_content_id_formatting_insensitive(self) -> None:
263 src1 = _src("x = 1\ny = 2\n")
264 src2 = _src("x=1\ny=2\n")
265 assert self.adapter.file_content_id(src1) == self.adapter.file_content_id(src2)
266
267 def test_file_content_id_syntax_error_uses_raw_bytes(self) -> None:
268 bad = b"def("
269 cid = self.adapter.file_content_id(bad)
270 assert cid == _sha256_bytes(bad)
271
272
273 # ---------------------------------------------------------------------------
274 # FallbackAdapter
275 # ---------------------------------------------------------------------------
276
277
278 class TestFallbackAdapter:
279 adapter = FallbackAdapter(frozenset({".unknown_xyz"}))
280
281 def test_supported_extensions(self) -> None:
282 assert ".unknown_xyz" in self.adapter.supported_extensions()
283
284 def test_parse_returns_empty(self) -> None:
285 assert self.adapter.parse_symbols(b"const x = 1;", "src.unknown_xyz") == {}
286
287 def test_content_id_is_raw_bytes_hash(self) -> None:
288 data = b"const x = 1;"
289 assert self.adapter.file_content_id(data) == _sha256_bytes(data)
290
291
292 # ---------------------------------------------------------------------------
293 # TreeSitterAdapter — one test per language
294 # ---------------------------------------------------------------------------
295
296
297 class TestTreeSitterAdapters:
298 """Validate symbol extraction for each of the ten tree-sitter-backed languages."""
299
300 def _syms(self, src: bytes, path: str) -> dict[str, str]:
301 """Return {addr: kind} for all extracted symbols."""
302 tree = parse_symbols(src, path)
303 return {addr: rec["kind"] for addr, rec in tree.items()}
304
305 # --- JavaScript -----------------------------------------------------------
306
307 def test_js_top_level_function(self) -> None:
308 src = b"function greet(name) { return name; }"
309 syms = self._syms(src, "app.js")
310 assert "app.js::greet" in syms
311 assert syms["app.js::greet"] == "function"
312
313 def test_js_class_and_method(self) -> None:
314 src = b"class Animal { speak() { return 1; } }"
315 syms = self._syms(src, "animal.js")
316 assert "animal.js::Animal" in syms
317 assert syms["animal.js::Animal"] == "class"
318 assert "animal.js::Animal.speak" in syms
319 assert syms["animal.js::Animal.speak"] == "method"
320
321 def test_js_body_hash_rename_detection(self) -> None:
322 """JS functions with identical bodies but different names share body_hash."""
323 src_foo = b"function foo(x) { return x + 1; }"
324 src_bar = b"function bar(x) { return x + 1; }"
325 t1 = parse_symbols(src_foo, "f.js")
326 t2 = parse_symbols(src_bar, "f.js")
327 assert t1["f.js::foo"]["body_hash"] == t2["f.js::bar"]["body_hash"]
328 assert t1["f.js::foo"]["content_id"] != t2["f.js::bar"]["content_id"]
329
330 def test_js_adapter_claims_jsx_and_mjs(self) -> None:
331 src = b"function f() {}"
332 assert parse_symbols(src, "x.jsx") != {} or True # adapter loaded
333 assert "x.mjs::f" in parse_symbols(src, "x.mjs")
334
335 # --- TypeScript -----------------------------------------------------------
336
337 def test_ts_function_and_interface(self) -> None:
338 src = b"function hello(name: string): void {}\ninterface Animal { speak(): void; }"
339 syms = self._syms(src, "app.ts")
340 assert "app.ts::hello" in syms
341 assert syms["app.ts::hello"] == "function"
342 assert "app.ts::Animal" in syms
343 assert syms["app.ts::Animal"] == "class"
344
345 def test_ts_class_and_method(self) -> None:
346 src = b"class Dog { bark(): string { return 'woof'; } }"
347 syms = self._syms(src, "dog.ts")
348 assert "dog.ts::Dog" in syms
349 assert "dog.ts::Dog.bark" in syms
350
351 def test_tsx_parses_correctly(self) -> None:
352 src = b"function Button(): void { return; }\ninterface Props { label: string; }"
353 syms = self._syms(src, "button.tsx")
354 assert "button.tsx::Button" in syms
355 assert "button.tsx::Props" in syms
356
357 # --- Go -------------------------------------------------------------------
358
359 def test_go_function(self) -> None:
360 src = b"func NewDog(name string) string { return name }"
361 syms = self._syms(src, "dog.go")
362 assert "dog.go::NewDog" in syms
363 assert syms["dog.go::NewDog"] == "function"
364
365 def test_go_method_qualified_with_receiver(self) -> None:
366 """Go methods carry the receiver type as qualified-name prefix."""
367 src = b"type Dog struct { Name string }\nfunc (d Dog) Bark() string { return d.Name }"
368 syms = self._syms(src, "dog.go")
369 assert "dog.go::Dog" in syms
370 assert "dog.go::Dog.Bark" in syms
371 assert syms["dog.go::Dog.Bark"] == "method"
372
373 def test_go_pointer_receiver_stripped(self) -> None:
374 """Pointer receivers (*Dog) are stripped to give Dog.Method."""
375 src = b"type Dog struct {}\nfunc (d *Dog) Sit() {}"
376 syms = self._syms(src, "d.go")
377 assert "d.go::Dog.Sit" in syms
378
379 # --- Rust -----------------------------------------------------------------
380
381 def test_rust_standalone_function(self) -> None:
382 src = b"fn add(a: i32, b: i32) -> i32 { a + b }"
383 syms = self._syms(src, "math.rs")
384 assert "math.rs::add" in syms
385 assert syms["math.rs::add"] == "function"
386
387 def test_rust_impl_method_qualified(self) -> None:
388 """Rust impl methods are qualified as TypeName.method."""
389 src = b"struct Dog { name: String }\nimpl Dog { fn bark(&self) -> String { self.name.clone() } }"
390 syms = self._syms(src, "dog.rs")
391 assert "dog.rs::Dog" in syms
392 assert "dog.rs::Dog.bark" in syms
393
394 def test_rust_struct_and_trait(self) -> None:
395 src = b"struct Point { x: f64, y: f64 }\ntrait Shape { fn area(&self) -> f64; }"
396 syms = self._syms(src, "shapes.rs")
397 assert "shapes.rs::Point" in syms
398 assert syms["shapes.rs::Point"] == "class"
399 assert "shapes.rs::Shape" in syms
400
401 # --- Java -----------------------------------------------------------------
402
403 def test_java_class_and_method(self) -> None:
404 src = b"public class Calculator { public int add(int a, int b) { return a + b; } }"
405 syms = self._syms(src, "Calc.java")
406 assert "Calc.java::Calculator" in syms
407 assert syms["Calc.java::Calculator"] == "class"
408 assert "Calc.java::Calculator.add" in syms
409 assert syms["Calc.java::Calculator.add"] == "method"
410
411 def test_java_interface(self) -> None:
412 src = b"public interface Shape { double area(); }"
413 syms = self._syms(src, "Shape.java")
414 assert "Shape.java::Shape" in syms
415 assert syms["Shape.java::Shape"] == "class"
416
417 # --- C --------------------------------------------------------------------
418
419 def test_c_function(self) -> None:
420 src = b"int add(int a, int b) { return a + b; }\nvoid noop(void) {}"
421 syms = self._syms(src, "math.c")
422 assert "math.c::add" in syms
423 assert syms["math.c::add"] == "function"
424 assert "math.c::noop" in syms
425
426 # --- C++ ------------------------------------------------------------------
427
428 def test_cpp_class_and_function(self) -> None:
429 src = b"class Animal { public: void speak() {} };\nint square(int x) { return x * x; }"
430 syms = self._syms(src, "app.cpp")
431 assert "app.cpp::Animal" in syms
432 assert syms["app.cpp::Animal"] == "class"
433 assert "app.cpp::square" in syms
434
435 # --- C# -------------------------------------------------------------------
436
437 def test_cs_class_and_method(self) -> None:
438 src = b"public class Greeter { public string Hello(string name) { return name; } }"
439 syms = self._syms(src, "Greeter.cs")
440 assert "Greeter.cs::Greeter" in syms
441 assert syms["Greeter.cs::Greeter"] == "class"
442 assert "Greeter.cs::Greeter.Hello" in syms
443 assert syms["Greeter.cs::Greeter.Hello"] == "method"
444
445 def test_cs_interface_and_struct(self) -> None:
446 src = b"interface IShape { double Area(); }\nstruct Point { public int X, Y; }"
447 syms = self._syms(src, "shapes.cs")
448 assert "shapes.cs::IShape" in syms
449 assert "shapes.cs::Point" in syms
450
451 # --- Ruby -----------------------------------------------------------------
452
453 def test_ruby_class_and_method(self) -> None:
454 src = b"class Dog\n def bark\n puts 'woof'\n end\nend"
455 syms = self._syms(src, "dog.rb")
456 assert "dog.rb::Dog" in syms
457 assert syms["dog.rb::Dog"] == "class"
458 assert "dog.rb::Dog.bark" in syms
459 assert syms["dog.rb::Dog.bark"] == "method"
460
461 def test_ruby_module(self) -> None:
462 src = b"module Greetable\n def greet\n 'hello'\n end\nend"
463 syms = self._syms(src, "greet.rb")
464 assert "greet.rb::Greetable" in syms
465 assert syms["greet.rb::Greetable"] == "class"
466
467 # --- Kotlin ---------------------------------------------------------------
468
469 def test_kotlin_function_and_class(self) -> None:
470 src = b"fun greet(name: String): String = name\nclass Dog { fun bark(): Unit { } }"
471 syms = self._syms(src, "main.kt")
472 assert "main.kt::greet" in syms
473 assert syms["main.kt::greet"] == "function"
474 assert "main.kt::Dog" in syms
475 assert "main.kt::Dog.bark" in syms
476
477 # --- cross-language adapter routing ---------------------------------------
478
479 def test_adapter_for_path_routes_all_extensions(self) -> None:
480 """adapter_for_path must return a TreeSitterAdapter (not Fallback) for all supported exts."""
481 from muse.plugins.code.ast_parser import TreeSitterAdapter, adapter_for_path
482
483 for ext in (
484 ".js", ".jsx", ".mjs", ".cjs",
485 ".ts", ".tsx",
486 ".go",
487 ".rs",
488 ".java",
489 ".c", ".h",
490 ".cpp", ".cc", ".cxx", ".hpp",
491 ".cs",
492 ".rb",
493 ".kt", ".kts",
494 ):
495 a = adapter_for_path(f"src/file{ext}")
496 assert isinstance(a, TreeSitterAdapter), (
497 f"Expected TreeSitterAdapter for {ext}, got {type(a).__name__}"
498 )
499
500 def test_semantic_extensions_covers_all_ts_languages(self) -> None:
501 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS
502
503 expected = {
504 ".py", ".pyi",
505 ".js", ".jsx", ".mjs", ".cjs",
506 ".ts", ".tsx",
507 ".go", ".rs",
508 ".java",
509 ".c", ".h",
510 ".cpp", ".cc", ".cxx", ".hpp", ".hxx",
511 ".cs",
512 ".rb",
513 ".kt", ".kts",
514 }
515 assert expected <= SEMANTIC_EXTENSIONS
516
517
518 # ---------------------------------------------------------------------------
519 # adapter_for_path
520 # ---------------------------------------------------------------------------
521
522
523 def test_adapter_for_py_is_python() -> None:
524 assert isinstance(adapter_for_path("src/utils.py"), PythonAdapter)
525
526
527 def test_adapter_for_ts_is_tree_sitter() -> None:
528 from muse.plugins.code.ast_parser import TreeSitterAdapter
529
530 assert isinstance(adapter_for_path("src/app.ts"), TreeSitterAdapter)
531
532
533 def test_adapter_for_no_extension_is_fallback() -> None:
534 assert isinstance(adapter_for_path("Makefile"), FallbackAdapter)
535
536
537 # ---------------------------------------------------------------------------
538 # diff_symbol_trees — golden test cases
539 # ---------------------------------------------------------------------------
540
541
542 class TestDiffSymbolTrees:
543 """Golden test cases for symbol-level diff."""
544
545 def _func(
546 self,
547 addr: str,
548 content_id: str,
549 body_hash: str | None = None,
550 signature_id: str | None = None,
551 name: str = "f",
552 ) -> tuple[str, SymbolRecord]:
553 return addr, SymbolRecord(
554 kind="function",
555 name=name,
556 qualified_name=name,
557 content_id=content_id,
558 body_hash=body_hash or content_id,
559 signature_id=signature_id or content_id,
560 lineno=1,
561 end_lineno=3,
562 )
563
564 def test_empty_trees_produce_no_ops(self) -> None:
565 assert diff_symbol_trees({}, {}) == []
566
567 def test_added_symbol(self) -> None:
568 base: SymbolTree = {}
569 target: SymbolTree = dict([self._func("f.py::new_fn", "abc", name="new_fn")])
570 ops = diff_symbol_trees(base, target)
571 assert len(ops) == 1
572 assert ops[0]["op"] == "insert"
573 assert ops[0]["address"] == "f.py::new_fn"
574
575 def test_removed_symbol(self) -> None:
576 base: SymbolTree = dict([self._func("f.py::old", "abc", name="old")])
577 target: SymbolTree = {}
578 ops = diff_symbol_trees(base, target)
579 assert len(ops) == 1
580 assert ops[0]["op"] == "delete"
581 assert ops[0]["address"] == "f.py::old"
582
583 def test_unchanged_symbol_no_op(self) -> None:
584 rec = dict([self._func("f.py::stable", "xyz", name="stable")])
585 assert diff_symbol_trees(rec, rec) == []
586
587 def test_implementation_changed(self) -> None:
588 """Same signature, different body → ReplaceOp with 'implementation changed'."""
589 sig_id = _sha256("calc(x)->int")
590 base: SymbolTree = dict([self._func("m.py::calc", "old_body", body_hash="old", signature_id=sig_id, name="calc")])
591 target: SymbolTree = dict([self._func("m.py::calc", "new_body", body_hash="new", signature_id=sig_id, name="calc")])
592 ops = diff_symbol_trees(base, target)
593 assert len(ops) == 1
594 assert ops[0]["op"] == "replace"
595 assert "implementation changed" in ops[0]["new_summary"]
596
597 def test_signature_changed(self) -> None:
598 """Same body, different signature → ReplaceOp with 'signature changed'."""
599 body = _sha256("return x + 1")
600 base: SymbolTree = dict([self._func("m.py::f", "c1", body_hash=body, signature_id="old_sig", name="f")])
601 target: SymbolTree = dict([self._func("m.py::f", "c2", body_hash=body, signature_id="new_sig", name="f")])
602 ops = diff_symbol_trees(base, target)
603 assert len(ops) == 1
604 assert ops[0]["op"] == "replace"
605 assert "signature changed" in ops[0]["old_summary"]
606
607 def test_rename_detected(self) -> None:
608 """Same body_hash, different name/address → ReplaceOp with 'renamed to'."""
609 body = _sha256("return 42")
610 base: SymbolTree = dict([self._func("u.py::old_name", "old_cid", body_hash=body, name="old_name")])
611 target: SymbolTree = dict([self._func("u.py::new_name", "new_cid", body_hash=body, name="new_name")])
612 ops = diff_symbol_trees(base, target)
613 assert len(ops) == 1
614 assert ops[0]["op"] == "replace"
615 assert "renamed to" in ops[0]["new_summary"]
616 assert "new_name" in ops[0]["new_summary"]
617
618 def test_independent_changes_both_emitted(self) -> None:
619 """Different symbols changed independently → two ReplaceOps."""
620 sig_a = "sig_a"
621 sig_b = "sig_b"
622 base: SymbolTree = {
623 **dict([self._func("f.py::foo", "foo_old", body_hash="foo_b_old", signature_id=sig_a, name="foo")]),
624 **dict([self._func("f.py::bar", "bar_old", body_hash="bar_b_old", signature_id=sig_b, name="bar")]),
625 }
626 target: SymbolTree = {
627 **dict([self._func("f.py::foo", "foo_new", body_hash="foo_b_new", signature_id=sig_a, name="foo")]),
628 **dict([self._func("f.py::bar", "bar_new", body_hash="bar_b_new", signature_id=sig_b, name="bar")]),
629 }
630 ops = diff_symbol_trees(base, target)
631 assert len(ops) == 2
632 addrs = {o["address"] for o in ops}
633 assert "f.py::foo" in addrs
634 assert "f.py::bar" in addrs
635
636
637 # ---------------------------------------------------------------------------
638 # build_diff_ops — integration
639 # ---------------------------------------------------------------------------
640
641
642 class TestBuildDiffOps:
643 def test_added_file_no_tree(self) -> None:
644 ops = build_diff_ops(
645 base_files={},
646 target_files={"new.ts": "abc"},
647 base_trees={},
648 target_trees={},
649 )
650 assert len(ops) == 1
651 assert ops[0]["op"] == "insert"
652 assert ops[0]["address"] == "new.ts"
653
654 def test_removed_file_no_tree(self) -> None:
655 ops = build_diff_ops(
656 base_files={"old.ts": "abc"},
657 target_files={},
658 base_trees={},
659 target_trees={},
660 )
661 assert len(ops) == 1
662 assert ops[0]["op"] == "delete"
663
664 def test_modified_file_with_trees(self) -> None:
665 body = _sha256("return x")
666 base_tree: SymbolTree = {
667 "u.py::foo": SymbolRecord(
668 kind="function", name="foo", qualified_name="foo",
669 content_id="old_c", body_hash=body, signature_id="sig",
670 lineno=1, end_lineno=2,
671 )
672 }
673 target_tree: SymbolTree = {
674 "u.py::foo": SymbolRecord(
675 kind="function", name="foo", qualified_name="foo",
676 content_id="new_c", body_hash="new_body", signature_id="sig",
677 lineno=1, end_lineno=2,
678 )
679 }
680 ops = build_diff_ops(
681 base_files={"u.py": "base_hash"},
682 target_files={"u.py": "target_hash"},
683 base_trees={"u.py": base_tree},
684 target_trees={"u.py": target_tree},
685 )
686 assert len(ops) == 1
687 assert ops[0]["op"] == "patch"
688 assert ops[0]["address"] == "u.py"
689 assert len(ops[0]["child_ops"]) == 1
690 assert ops[0]["child_ops"][0]["op"] == "replace"
691
692 def test_reformat_only_produces_replace_op(self) -> None:
693 """When all symbol content_ids are unchanged, emit a reformatted ReplaceOp."""
694 content_id = _sha256("return x")
695 tree: SymbolTree = {
696 "u.py::foo": SymbolRecord(
697 kind="function", name="foo", qualified_name="foo",
698 content_id=content_id, body_hash=content_id, signature_id=content_id,
699 lineno=1, end_lineno=2,
700 )
701 }
702 ops = build_diff_ops(
703 base_files={"u.py": "hash_before"},
704 target_files={"u.py": "hash_after"},
705 base_trees={"u.py": tree},
706 target_trees={"u.py": tree}, # same tree → no symbol changes
707 )
708 assert len(ops) == 1
709 assert ops[0]["op"] == "replace"
710 assert "reformatted" in ops[0]["new_summary"]
711
712 def test_cross_file_move_annotation(self) -> None:
713 """A symbol deleted in file A and inserted in file B is annotated as moved."""
714 content_id = _sha256("the_body")
715 base_tree: SymbolTree = {
716 "a.py::helper": SymbolRecord(
717 kind="function", name="helper", qualified_name="helper",
718 content_id=content_id, body_hash=content_id, signature_id=content_id,
719 lineno=1, end_lineno=3,
720 )
721 }
722 target_tree: SymbolTree = {
723 "b.py::helper": SymbolRecord(
724 kind="function", name="helper", qualified_name="helper",
725 content_id=content_id, body_hash=content_id, signature_id=content_id,
726 lineno=1, end_lineno=3,
727 )
728 }
729 ops = build_diff_ops(
730 base_files={"a.py": "hash_a", "b.py": "hash_b_before"},
731 target_files={"b.py": "hash_b_after"},
732 base_trees={"a.py": base_tree},
733 target_trees={"b.py": target_tree},
734 )
735 # Find the patch ops.
736 patch_addrs = {o["address"] for o in ops if o["op"] == "patch"}
737 assert "a.py" in patch_addrs or "b.py" in patch_addrs
738
739
740 class TestFileMoveAndEdit:
741 """Regression: a file renamed+edited must be emitted as a single move+edit PatchOp.
742
743 Before the fix, Muse emitted an all-delete PatchOp for the old path and
744 an all-insert PatchOp for the new path — showing a spurious delete+add
745 rather than a move+edit. After the fix, the two are collapsed into a
746 single PatchOp carrying ``from_address`` and symbol-level child diffs.
747 """
748
749 def _func(
750 self,
751 addr: str,
752 content_id: str,
753 body_hash: str | None = None,
754 signature_id: str | None = None,
755 name: str = "f",
756 ) -> tuple[str, SymbolRecord]:
757 return addr, SymbolRecord(
758 kind="function",
759 name=name,
760 qualified_name=name,
761 content_id=content_id,
762 body_hash=body_hash or content_id,
763 signature_id=signature_id or content_id,
764 lineno=1,
765 end_lineno=3,
766 )
767
768 def test_move_and_edit_collapses_to_single_patch(self) -> None:
769 """File renamed utils.py→helpers.py with one symbol changed must emit one PatchOp."""
770 shared_body = _sha256("def unchanged(): pass")
771 base_tree: SymbolTree = {
772 "utils.py::unchanged": SymbolRecord(
773 kind="function", name="unchanged", qualified_name="unchanged",
774 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
775 lineno=1, end_lineno=2,
776 ),
777 "utils.py::modified": SymbolRecord(
778 kind="function", name="modified", qualified_name="modified",
779 content_id="old_cid", body_hash="old_body", signature_id="old_sig",
780 lineno=3, end_lineno=5,
781 ),
782 }
783 target_tree: SymbolTree = {
784 "helpers.py::unchanged": SymbolRecord(
785 kind="function", name="unchanged", qualified_name="unchanged",
786 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
787 lineno=1, end_lineno=2,
788 ),
789 "helpers.py::modified": SymbolRecord(
790 kind="function", name="modified", qualified_name="modified",
791 content_id="new_cid", body_hash="new_body", signature_id="new_sig",
792 lineno=3, end_lineno=5,
793 ),
794 }
795 ops = build_diff_ops(
796 base_files={"utils.py": "hash_old"},
797 target_files={"helpers.py": "hash_new"},
798 base_trees={"utils.py": base_tree},
799 target_trees={"helpers.py": target_tree},
800 )
801 assert len(ops) == 1, f"Expected 1 op, got {len(ops)}: {[o['op'] for o in ops]}"
802 assert ops[0]["op"] == "patch"
803 assert ops[0]["address"] == "helpers.py"
804 assert ops[0].get("from_address") == "utils.py"
805
806 def test_move_and_edit_child_ops_show_symbol_diff(self) -> None:
807 """Child ops of a move+edit PatchOp must reflect symbol-level changes only."""
808 shared_body = _sha256("def keep(): pass")
809 base_tree: SymbolTree = {
810 "a.py::keep": SymbolRecord(
811 kind="function", name="keep", qualified_name="keep",
812 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
813 lineno=1, end_lineno=2,
814 ),
815 "a.py::gone": SymbolRecord(
816 kind="function", name="gone", qualified_name="gone",
817 content_id="cid_gone", body_hash="body_gone", signature_id="sig_gone",
818 lineno=3, end_lineno=5,
819 ),
820 }
821 target_tree: SymbolTree = {
822 "b.py::keep": SymbolRecord(
823 kind="function", name="keep", qualified_name="keep",
824 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
825 lineno=1, end_lineno=2,
826 ),
827 "b.py::new_fn": SymbolRecord(
828 kind="function", name="new_fn", qualified_name="new_fn",
829 content_id="cid_new", body_hash="body_new", signature_id="sig_new",
830 lineno=3, end_lineno=5,
831 ),
832 }
833 ops = build_diff_ops(
834 base_files={"a.py": "hash_a"},
835 target_files={"b.py": "hash_b"},
836 base_trees={"a.py": base_tree},
837 target_trees={"b.py": target_tree},
838 )
839 assert len(ops) == 1
840 patch = ops[0]
841 assert patch["op"] == "patch"
842 child_op_types = {c["op"] for c in patch["child_ops"]}
843 # "gone" was deleted, "new_fn" was inserted; "keep" is unchanged → no op.
844 assert "delete" in child_op_types
845 assert "insert" in child_op_types
846
847 def test_no_false_positive_unrelated_files(self) -> None:
848 """Two files with no symbol overlap must NOT be collapsed into a move+edit."""
849 ops = build_diff_ops(
850 base_files={"old.py": "hash_old"},
851 target_files={"new.py": "hash_new"},
852 base_trees={
853 "old.py": {
854 "old.py::alpha": SymbolRecord(
855 kind="function", name="alpha", qualified_name="alpha",
856 content_id="cid_a", body_hash="body_a", signature_id="sig_a",
857 lineno=1, end_lineno=2,
858 )
859 }
860 },
861 target_trees={
862 "new.py": {
863 "new.py::omega": SymbolRecord(
864 kind="function", name="omega", qualified_name="omega",
865 content_id="cid_o", body_hash="body_o", signature_id="sig_o",
866 lineno=1, end_lineno=2,
867 )
868 }
869 },
870 )
871 # No overlap → separate delete + insert ops, NOT a move+edit.
872 assert len(ops) == 2
873 op_types = {o["op"] for o in ops}
874 assert op_types == {"patch"} # Both are PatchOps wrapping single-symbol trees.
875 for op in ops:
876 assert op.get("from_address") is None
877
878
879 # ---------------------------------------------------------------------------
880 # CodePlugin — snapshot
881 # ---------------------------------------------------------------------------
882
883
884 class TestCodePluginSnapshot:
885 plugin = CodePlugin()
886
887 def test_path_returns_manifest(self, tmp_path: pathlib.Path) -> None:
888 workdir = tmp_path
889 (workdir / "app.py").write_text("x = 1\n")
890 snap = self.plugin.snapshot(workdir)
891 assert snap["domain"] == "code"
892 assert "app.py" in snap["files"]
893
894 def test_snapshot_stability(self, tmp_path: pathlib.Path) -> None:
895 workdir = tmp_path
896 (workdir / "main.py").write_text("def f(): pass\n")
897 s1 = self.plugin.snapshot(workdir)
898 s2 = self.plugin.snapshot(workdir)
899 assert s1 == s2
900
901 def test_snapshot_uses_raw_bytes_hash(self, tmp_path: pathlib.Path) -> None:
902 workdir = tmp_path
903 content = b"def add(a, b): return a + b\n"
904 (workdir / "math.py").write_bytes(content)
905 snap = self.plugin.snapshot(workdir)
906 expected = _sha256_bytes(content)
907 assert snap["files"]["math.py"] == expected
908
909 def test_museignore_respected(self, tmp_path: pathlib.Path) -> None:
910 workdir = tmp_path
911 (workdir / "keep.py").write_text("x = 1\n")
912 (workdir / "skip.log").write_text("log\n")
913 ignore = tmp_path / ".museignore"
914 ignore.write_text('[global]\npatterns = ["*.log"]\n')
915 snap = self.plugin.snapshot(workdir)
916 assert "keep.py" in snap["files"]
917 assert "skip.log" not in snap["files"]
918
919 def test_pycache_always_ignored(self, tmp_path: pathlib.Path) -> None:
920 workdir = tmp_path
921 cache = workdir / "__pycache__"
922 cache.mkdir()
923 (cache / "utils.cpython-312.pyc").write_bytes(b"\x00")
924 (workdir / "main.py").write_text("x = 1\n")
925 snap = self.plugin.snapshot(workdir)
926 assert "main.py" in snap["files"]
927 assert not any("__pycache__" in k for k in snap["files"])
928
929 def test_nested_files_tracked(self, tmp_path: pathlib.Path) -> None:
930 workdir = tmp_path
931 (workdir / "src").mkdir(parents=True)
932 (workdir / "src" / "utils.py").write_text("pass\n")
933 snap = self.plugin.snapshot(workdir)
934 assert "src/utils.py" in snap["files"]
935
936 def test_manifest_passthrough(self) -> None:
937 manifest = _make_manifest({"a.py": "hash"})
938 result = self.plugin.snapshot(manifest)
939 assert result is manifest
940
941
942 # ---------------------------------------------------------------------------
943 # CodePlugin — diff (file-level, no repo_root)
944 # ---------------------------------------------------------------------------
945
946
947 class TestCodePluginDiffFileLevel:
948 plugin = CodePlugin()
949
950 def test_added_file(self) -> None:
951 base = _make_manifest({})
952 target = _make_manifest({"new.py": "abc"})
953 delta = self.plugin.diff(base, target)
954 assert len(delta["ops"]) == 1
955 assert delta["ops"][0]["op"] == "insert"
956
957 def test_removed_file(self) -> None:
958 base = _make_manifest({"old.py": "abc"})
959 target = _make_manifest({})
960 delta = self.plugin.diff(base, target)
961 assert len(delta["ops"]) == 1
962 assert delta["ops"][0]["op"] == "delete"
963
964 def test_modified_file(self) -> None:
965 base = _make_manifest({"f.py": "old"})
966 target = _make_manifest({"f.py": "new"})
967 delta = self.plugin.diff(base, target)
968 assert len(delta["ops"]) == 1
969 assert delta["ops"][0]["op"] == "replace"
970
971 def test_no_changes_empty_ops(self) -> None:
972 snap = _make_manifest({"f.py": "abc"})
973 delta = self.plugin.diff(snap, snap)
974 assert delta["ops"] == []
975 assert delta["summary"] == "no changes"
976
977 def test_domain_is_code(self) -> None:
978 delta = self.plugin.diff(_make_manifest({}), _make_manifest({}))
979 assert delta["domain"] == "code"
980
981
982 # ---------------------------------------------------------------------------
983 # CodePlugin — diff (semantic, with repo_root)
984 # ---------------------------------------------------------------------------
985
986
987 class TestCodePluginDiffSemantic:
988 plugin = CodePlugin()
989
990 def _setup_repo(
991 self, tmp_path: pathlib.Path
992 ) -> tuple[pathlib.Path, pathlib.Path]:
993 repo_root = tmp_path / "repo"
994 repo_root.mkdir()
995 workdir = repo_root
996 return repo_root, workdir
997
998 def test_add_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
999 repo_root, _ = self._setup_repo(tmp_path)
1000 base_src = _src("x = 1\n")
1001 target_src = _src("x = 1\n\ndef greet(name: str) -> str:\n return f'Hello {name}'\n")
1002
1003 base_oid = _store_blob(repo_root, base_src)
1004 target_oid = _store_blob(repo_root, target_src)
1005
1006 base = _make_manifest({"hello.py": base_oid})
1007 target = _make_manifest({"hello.py": target_oid})
1008 delta = self.plugin.diff(base, target, repo_root=repo_root)
1009
1010 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1011 assert len(patch_ops) == 1
1012 assert patch_ops[0]["address"] == "hello.py"
1013 child_ops = patch_ops[0]["child_ops"]
1014 assert any(c["op"] == "insert" and "greet" in c.get("content_summary", "") for c in child_ops)
1015
1016 def test_remove_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
1017 repo_root, _ = self._setup_repo(tmp_path)
1018 base_src = _src("def old_fn() -> None:\n pass\n")
1019 target_src = _src("# removed\n")
1020
1021 base_oid = _store_blob(repo_root, base_src)
1022 target_oid = _store_blob(repo_root, target_src)
1023
1024 base = _make_manifest({"mod.py": base_oid})
1025 target = _make_manifest({"mod.py": target_oid})
1026 delta = self.plugin.diff(base, target, repo_root=repo_root)
1027
1028 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1029 assert len(patch_ops) == 1
1030 child_ops = patch_ops[0]["child_ops"]
1031 assert any(c["op"] == "delete" and "old_fn" in c.get("content_summary", "") for c in child_ops)
1032
1033 def test_rename_function_detected(self, tmp_path: pathlib.Path) -> None:
1034 repo_root, _ = self._setup_repo(tmp_path)
1035 base_src = _src("def compute(x: int) -> int:\n return x * 2\n")
1036 target_src = _src("def calculate(x: int) -> int:\n return x * 2\n")
1037
1038 base_oid = _store_blob(repo_root, base_src)
1039 target_oid = _store_blob(repo_root, target_src)
1040
1041 base = _make_manifest({"ops.py": base_oid})
1042 target = _make_manifest({"ops.py": target_oid})
1043 delta = self.plugin.diff(base, target, repo_root=repo_root)
1044
1045 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1046 assert len(patch_ops) == 1
1047 child_ops = patch_ops[0]["child_ops"]
1048 rename_ops = [
1049 c for c in child_ops
1050 if c["op"] == "replace" and "renamed to" in c.get("new_summary", "")
1051 ]
1052 assert len(rename_ops) == 1
1053 assert "calculate" in rename_ops[0]["new_summary"]
1054
1055 def test_implementation_change_detected(self, tmp_path: pathlib.Path) -> None:
1056 repo_root, _ = self._setup_repo(tmp_path)
1057 base_src = _src("def double(x: int) -> int:\n return x * 2\n")
1058 target_src = _src("def double(x: int) -> int:\n return x + x\n")
1059
1060 base_oid = _store_blob(repo_root, base_src)
1061 target_oid = _store_blob(repo_root, target_src)
1062
1063 base = _make_manifest({"math.py": base_oid})
1064 target = _make_manifest({"math.py": target_oid})
1065 delta = self.plugin.diff(base, target, repo_root=repo_root)
1066
1067 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1068 child_ops = patch_ops[0]["child_ops"]
1069 impl_ops = [c for c in child_ops if "implementation changed" in c.get("new_summary", "")]
1070 assert len(impl_ops) == 1
1071
1072 def test_reformat_only_produces_replace_with_reformatted(
1073 self, tmp_path: pathlib.Path
1074 ) -> None:
1075 repo_root, _ = self._setup_repo(tmp_path)
1076 base_src = _src("def add(a,b):\n return a+b\n")
1077 # Same semantics, different formatting — ast.unparse normalizes both.
1078 target_src = _src("def add(a, b):\n return a + b\n")
1079
1080 base_oid = _store_blob(repo_root, base_src)
1081 target_oid = _store_blob(repo_root, target_src)
1082
1083 base = _make_manifest({"f.py": base_oid})
1084 target = _make_manifest({"f.py": target_oid})
1085 delta = self.plugin.diff(base, target, repo_root=repo_root)
1086
1087 # The diff should produce a reformatted ReplaceOp rather than a PatchOp.
1088 replace_ops = [o for o in delta["ops"] if o["op"] == "replace"]
1089 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1090 # Reformatting: either zero ops (if raw hashes are identical) or a
1091 # reformatted replace (if raw hashes differ but symbols unchanged).
1092 if delta["ops"]:
1093 assert replace_ops or patch_ops # something was emitted
1094 if replace_ops:
1095 assert any("reformatted" in o.get("new_summary", "") for o in replace_ops)
1096
1097 def test_missing_object_falls_back_to_file_level(
1098 self, tmp_path: pathlib.Path
1099 ) -> None:
1100 repo_root, _ = self._setup_repo(tmp_path)
1101 # Objects NOT written to store — should fall back gracefully.
1102 base = _make_manifest({"f.py": "deadbeef" * 8})
1103 target = _make_manifest({"f.py": "cafebabe" * 8})
1104 delta = self.plugin.diff(base, target, repo_root=repo_root)
1105 assert len(delta["ops"]) == 1
1106 assert delta["ops"][0]["op"] == "replace"
1107
1108
1109 # ---------------------------------------------------------------------------
1110 # CodePlugin — merge
1111 # ---------------------------------------------------------------------------
1112
1113
1114 class TestCodePluginMerge:
1115 plugin = CodePlugin()
1116
1117 def test_only_one_side_changed(self) -> None:
1118 base = _make_manifest({"f.py": "v1"})
1119 left = _make_manifest({"f.py": "v1"})
1120 right = _make_manifest({"f.py": "v2"})
1121 result = self.plugin.merge(base, left, right)
1122 assert result.is_clean
1123 assert result.merged["files"]["f.py"] == "v2"
1124
1125 def test_both_sides_same_change(self) -> None:
1126 base = _make_manifest({"f.py": "v1"})
1127 left = _make_manifest({"f.py": "v2"})
1128 right = _make_manifest({"f.py": "v2"})
1129 result = self.plugin.merge(base, left, right)
1130 assert result.is_clean
1131 assert result.merged["files"]["f.py"] == "v2"
1132
1133 def test_conflict_when_both_sides_differ(self) -> None:
1134 base = _make_manifest({"f.py": "v1"})
1135 left = _make_manifest({"f.py": "v2"})
1136 right = _make_manifest({"f.py": "v3"})
1137 result = self.plugin.merge(base, left, right)
1138 assert not result.is_clean
1139 assert "f.py" in result.conflicts
1140
1141 def test_disjoint_additions_auto_merge(self) -> None:
1142 base = _make_manifest({})
1143 left = _make_manifest({"a.py": "hash_a"})
1144 right = _make_manifest({"b.py": "hash_b"})
1145 result = self.plugin.merge(base, left, right)
1146 assert result.is_clean
1147 assert "a.py" in result.merged["files"]
1148 assert "b.py" in result.merged["files"]
1149
1150 def test_deletion_on_one_side(self) -> None:
1151 base = _make_manifest({"f.py": "v1"})
1152 left = _make_manifest({})
1153 right = _make_manifest({"f.py": "v1"})
1154 result = self.plugin.merge(base, left, right)
1155 assert result.is_clean
1156 assert "f.py" not in result.merged["files"]
1157
1158
1159 # ---------------------------------------------------------------------------
1160 # CodePlugin — merge_ops (symbol-level OT)
1161 # ---------------------------------------------------------------------------
1162
1163
1164 class TestCodePluginMergeOps:
1165 plugin = CodePlugin()
1166
1167 def _py_snap(self, file_path: str, src: bytes, repo_root: pathlib.Path) -> SnapshotManifest:
1168 oid = _store_blob(repo_root, src)
1169 return _make_manifest({file_path: oid})
1170
1171 def test_different_symbols_auto_merge(self, tmp_path: pathlib.Path) -> None:
1172 """Two agents modify different functions → no conflict."""
1173 repo_root = tmp_path / "repo"
1174 repo_root.mkdir()
1175
1176 base_src = _src("""\
1177 def foo(x: int) -> int:
1178 return x
1179
1180 def bar(y: int) -> int:
1181 return y
1182 """)
1183 # Ours: modify foo.
1184 ours_src = _src("""\
1185 def foo(x: int) -> int:
1186 return x * 2
1187
1188 def bar(y: int) -> int:
1189 return y
1190 """)
1191 # Theirs: modify bar.
1192 theirs_src = _src("""\
1193 def foo(x: int) -> int:
1194 return x
1195
1196 def bar(y: int) -> int:
1197 return y + 1
1198 """)
1199
1200 base_snap = self._py_snap("m.py", base_src, repo_root)
1201 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1202 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1203
1204 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1205 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1206
1207 result = self.plugin.merge_ops(
1208 base_snap,
1209 ours_snap,
1210 theirs_snap,
1211 ours_delta["ops"],
1212 theirs_delta["ops"],
1213 repo_root=repo_root,
1214 )
1215 # Different symbol addresses → ops commute → no conflict.
1216 assert result.is_clean, f"Expected no conflicts, got: {result.conflicts}"
1217
1218 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1219 """Both agents modify the same function → conflict at symbol address."""
1220 repo_root = tmp_path / "repo"
1221 repo_root.mkdir()
1222
1223 base_src = _src("def calc(x: int) -> int:\n return x\n")
1224 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1225 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1226
1227 base_snap = self._py_snap("calc.py", base_src, repo_root)
1228 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1229 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1230
1231 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1232 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1233
1234 result = self.plugin.merge_ops(
1235 base_snap,
1236 ours_snap,
1237 theirs_snap,
1238 ours_delta["ops"],
1239 theirs_delta["ops"],
1240 repo_root=repo_root,
1241 )
1242 assert not result.is_clean
1243 # Conflict should be at file or symbol level.
1244 assert len(result.conflicts) > 0
1245
1246 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1247 """Agents modify completely different files → auto-merge."""
1248 repo_root = tmp_path / "repo"
1249 repo_root.mkdir()
1250
1251 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1252 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1253 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1254
1255 ours_delta = self.plugin.diff(base, ours)
1256 theirs_delta = self.plugin.diff(base, theirs)
1257
1258 result = self.plugin.merge_ops(
1259 base, ours, theirs,
1260 ours_delta["ops"],
1261 theirs_delta["ops"],
1262 )
1263 assert result.is_clean
1264
1265
1266 # ---------------------------------------------------------------------------
1267 # CodePlugin — drift
1268 # ---------------------------------------------------------------------------
1269
1270
1271 class TestCodePluginDrift:
1272 plugin = CodePlugin()
1273
1274 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1275 workdir = tmp_path
1276 (workdir / "app.py").write_text("x = 1\n")
1277 snap = self.plugin.snapshot(workdir)
1278 report = self.plugin.drift(snap, workdir)
1279 assert not report.has_drift
1280
1281 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1282 workdir = tmp_path
1283 f = workdir / "app.py"
1284 f.write_text("x = 1\n")
1285 snap = self.plugin.snapshot(workdir)
1286 f.write_text("x = 2\n")
1287 report = self.plugin.drift(snap, workdir)
1288 assert report.has_drift
1289
1290 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1291 workdir = tmp_path
1292 (workdir / "a.py").write_text("a = 1\n")
1293 snap = self.plugin.snapshot(workdir)
1294 (workdir / "b.py").write_text("b = 2\n")
1295 report = self.plugin.drift(snap, workdir)
1296 assert report.has_drift
1297
1298 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1299 workdir = tmp_path
1300 f = workdir / "gone.py"
1301 f.write_text("x = 1\n")
1302 snap = self.plugin.snapshot(workdir)
1303 f.unlink()
1304 report = self.plugin.drift(snap, workdir)
1305 assert report.has_drift
1306
1307
1308 # ---------------------------------------------------------------------------
1309 # CodePlugin — apply (passthrough)
1310 # ---------------------------------------------------------------------------
1311
1312
1313 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1314 plugin = CodePlugin()
1315 workdir = tmp_path
1316 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1317 result = plugin.apply(delta, workdir)
1318 assert result is workdir
1319
1320
1321 # ---------------------------------------------------------------------------
1322 # CodePlugin — schema
1323 # ---------------------------------------------------------------------------
1324
1325
1326 class TestCodePluginSchema:
1327 plugin = CodePlugin()
1328
1329 def test_schema_domain(self) -> None:
1330 assert self.plugin.schema()["domain"] == "code"
1331
1332 def test_schema_merge_mode(self) -> None:
1333 assert self.plugin.schema()["merge_mode"] == "three_way"
1334
1335 def test_schema_version(self) -> None:
1336 assert self.plugin.schema()["schema_version"] == __version__
1337
1338 def test_schema_dimensions(self) -> None:
1339 dims = self.plugin.schema()["dimensions"]
1340 names = {d["name"] for d in dims}
1341 assert "structure" in names
1342 assert "symbols" in names
1343 assert "imports" in names
1344
1345 def test_schema_top_level_is_tree(self) -> None:
1346 top = self.plugin.schema()["top_level"]
1347 assert top["kind"] == "tree"
1348
1349 def test_schema_description_non_empty(self) -> None:
1350 assert len(self.plugin.schema()["description"]) > 0
1351
1352
1353 # ---------------------------------------------------------------------------
1354 # delta_summary
1355 # ---------------------------------------------------------------------------
1356
1357
1358 class TestDeltaSummary:
1359 def test_empty_ops(self) -> None:
1360 assert delta_summary([]) == "no changes"
1361
1362 def test_file_added(self) -> None:
1363 from muse.domain import DomainOp
1364 ops: list[DomainOp] = [InsertOp(
1365 op="insert", address="f.py", position=None,
1366 content_id="abc", content_summary="added f.py",
1367 )]
1368 summary = delta_summary(ops)
1369 assert "added" in summary
1370 assert "file" in summary
1371
1372 def test_symbols_counted_from_patch(self) -> None:
1373 from muse.domain import DomainOp, PatchOp
1374 child: list[DomainOp] = [
1375 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1376 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1377 ]
1378 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1379 summary = delta_summary(ops)
1380 assert "symbol" in summary
1381
1382
1383 # ---------------------------------------------------------------------------
1384 # Markdown adapter
1385 # ---------------------------------------------------------------------------
1386
1387
1388 class TestMarkdownAdapter:
1389 """ATX heading extraction via tree-sitter-markdown."""
1390
1391 def _parse(self, src: str) -> SymbolTree:
1392 from muse.plugins.code.ast_parser import MarkdownAdapter
1393 adapter = MarkdownAdapter()
1394 if adapter._parser is None:
1395 pytest.skip("tree-sitter-markdown not available")
1396 return adapter.parse_symbols(src.encode(), "README.md")
1397
1398 def test_h1_extracted(self) -> None:
1399 syms = self._parse("# Hello World\n")
1400 assert any("h1: Hello World" in k for k in syms), f"keys: {list(syms)}"
1401
1402 def test_h2_extracted(self) -> None:
1403 syms = self._parse("# Title\n\n## Section Two\n")
1404 assert any("h2: Section Two" in k for k in syms)
1405
1406 def test_multiple_headings(self) -> None:
1407 src = "# Top\n\n## Alpha\n\n## Beta\n\n### Deep\n"
1408 syms = self._parse(src)
1409 kinds = {r["kind"] for r in syms.values()}
1410 assert "section" in kinds
1411 assert len(syms) >= 4
1412
1413 def test_section_lineno(self) -> None:
1414 src = "# First\n\n## Second\n"
1415 syms = self._parse(src)
1416 second = next((r for r in syms.values() if "Second" in r["name"]), None)
1417 assert second is not None
1418 assert second["lineno"] == 3
1419
1420 def test_content_id_changes_with_text(self) -> None:
1421 s1 = self._parse("# Hello\n")
1422 s2 = self._parse("# World\n")
1423 ids1 = {r["content_id"] for r in s1.values()}
1424 ids2 = {r["content_id"] for r in s2.values()}
1425 assert ids1 != ids2
1426
1427 def test_adapter_for_path_md(self) -> None:
1428 from muse.plugins.code.ast_parser import MarkdownAdapter
1429 adapter = adapter_for_path("docs/README.md")
1430 assert isinstance(adapter, MarkdownAdapter)
1431
1432 def test_adapter_for_path_rst(self) -> None:
1433 from muse.plugins.code.ast_parser import MarkdownAdapter
1434 adapter = adapter_for_path("notes.rst")
1435 assert isinstance(adapter, MarkdownAdapter)
1436
1437
1438 # ---------------------------------------------------------------------------
1439 # HTML adapter
1440 # ---------------------------------------------------------------------------
1441
1442
1443 class TestHtmlAdapter:
1444 """Semantic element and id-bearing element extraction via tree-sitter-html."""
1445
1446 def _parse(self, src: str) -> SymbolTree:
1447 from muse.plugins.code.ast_parser import HtmlAdapter
1448 adapter = HtmlAdapter()
1449 if adapter._parser is None:
1450 pytest.skip("tree-sitter-html not available")
1451 return adapter.parse_symbols(src.encode(), "index.html")
1452
1453 def test_id_bearing_div_extracted(self) -> None:
1454 syms = self._parse('<html><body><div id="hero">x</div></body></html>')
1455 assert any("div#hero" in k for k in syms), f"keys: {list(syms)}"
1456
1457 def test_semantic_section_extracted(self) -> None:
1458 syms = self._parse('<html><body><section>content</section></body></html>')
1459 assert any("section" in k for k in syms)
1460
1461 def test_h1_heading_extracted(self) -> None:
1462 syms = self._parse('<html><body><h1>Title</h1></body></html>')
1463 assert any("h1" in k for k in syms)
1464
1465 def test_generic_div_without_id_skipped(self) -> None:
1466 syms = self._parse('<html><body><div>plain</div></body></html>')
1467 assert not any("div" in k for k in syms), f"unexpected: {list(syms)}"
1468
1469 def test_multiple_ids(self) -> None:
1470 src = '<html><body><section id="intro">a</section><section id="outro">b</section></body></html>'
1471 syms = self._parse(src)
1472 assert any("section#intro" in k for k in syms)
1473 assert any("section#outro" in k for k in syms)
1474
1475 def test_adapter_for_path_html(self) -> None:
1476 from muse.plugins.code.ast_parser import HtmlAdapter
1477 assert isinstance(adapter_for_path("page.html"), HtmlAdapter)
1478
1479 def test_adapter_for_path_htm(self) -> None:
1480 from muse.plugins.code.ast_parser import HtmlAdapter
1481 assert isinstance(adapter_for_path("legacy.htm"), HtmlAdapter)
1482
1483
1484 # ---------------------------------------------------------------------------
1485 # CSS adapter
1486 # ---------------------------------------------------------------------------
1487
1488
1489 class TestCssAdapter:
1490 """Rule-set, @keyframes, and @media extraction via tree-sitter-css."""
1491
1492 def _parse(self, src: str, path: str = "styles.css") -> SymbolTree:
1493 adapter = adapter_for_path(path)
1494 # If the CSS grammar is unavailable the adapter degrades to FallbackAdapter.
1495 if isinstance(adapter, FallbackAdapter):
1496 pytest.skip("tree-sitter-css not available")
1497 return adapter.parse_symbols(src.encode(), path)
1498
1499 def test_rule_set_extracted(self) -> None:
1500 syms = self._parse(".btn { color: red; }")
1501 assert len(syms) >= 1
1502 kinds = {r["kind"] for r in syms.values()}
1503 assert "rule" in kinds
1504
1505 def test_keyframes_extracted(self) -> None:
1506 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }")
1507 assert any("spin" in r["name"] for r in syms.values()), f"symbols: {list(syms)}"
1508
1509 def test_multiple_rules(self) -> None:
1510 src = ".a { color: red; }\n.b { color: blue; }"
1511 syms = self._parse(src)
1512 assert len(syms) >= 2
1513
1514 def test_scss_extension_uses_css_parser(self) -> None:
1515 syms = self._parse(".mixin { display: flex; }", path="app.scss")
1516 assert len(syms) >= 1
1517
1518 def test_content_id_differs_for_different_rules(self) -> None:
1519 s1 = self._parse(".a { color: red; }")
1520 s2 = self._parse(".b { color: blue; }")
1521 ids1 = {r["content_id"] for r in s1.values()}
1522 ids2 = {r["content_id"] for r in s2.values()}
1523 assert ids1 != ids2
1524
1525
1526 # ---------------------------------------------------------------------------
1527 # JS/TS: arrow functions and async detection
1528 # ---------------------------------------------------------------------------
1529
1530
1531 class TestJSArrowFunctions:
1532 """Arrow functions and function expressions bound to const/let."""
1533
1534 def _parse(self, src: str, path: str = "mod.js") -> SymbolTree:
1535 adapter = adapter_for_path(path)
1536 if isinstance(adapter, FallbackAdapter):
1537 pytest.skip("tree-sitter-javascript not available")
1538 return adapter.parse_symbols(src.encode(), path)
1539
1540 def test_const_arrow_function(self) -> None:
1541 syms = self._parse("const greet = (name) => `Hello ${name}`;\n")
1542 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
1543
1544 def test_const_function_expression(self) -> None:
1545 syms = self._parse("const add = function(a, b) { return a + b; };\n")
1546 assert any("add" in k for k in syms)
1547
1548 def test_ts_arrow_function(self) -> None:
1549 syms = self._parse(
1550 "const greet = (name: string): string => `Hello ${name}`;\n",
1551 path="mod.ts",
1552 )
1553 assert any("greet" in k for k in syms)
1554
1555 def test_class_method_still_extracted(self) -> None:
1556 syms = self._parse("class Foo { bar() { return 1; } }\n")
1557 assert any("bar" in k for k in syms)
1558
1559 def test_async_function_detected(self) -> None:
1560 syms = self._parse("async function fetchData() { return await fetch('/'); }\n")
1561 kinds = {r["kind"] for r in syms.values() if "fetchData" in r["name"]}
1562 assert "async_function" in kinds, f"kinds: {kinds}"
1563
1564
1565 # ---------------------------------------------------------------------------
1566 # Go: const and var spec extraction
1567 # ---------------------------------------------------------------------------
1568
1569
1570 class TestGoConstVar:
1571 def _parse(self, src: str) -> SymbolTree:
1572 adapter = adapter_for_path("main.go")
1573 if isinstance(adapter, FallbackAdapter):
1574 pytest.skip("tree-sitter-go not available")
1575 return adapter.parse_symbols(src.encode(), "main.go")
1576
1577 def test_const_extracted(self) -> None:
1578 syms = self._parse("package main\nconst MaxRetries = 3\n")
1579 assert any("MaxRetries" in k for k in syms), f"keys: {list(syms)}"
1580
1581 def test_var_extracted(self) -> None:
1582 syms = self._parse("package main\nvar ErrNotFound = errors.New(\"not found\")\n")
1583 assert any("ErrNotFound" in k for k in syms)
1584
1585 def test_const_kind_is_variable(self) -> None:
1586 syms = self._parse("package main\nconst Timeout = 30\n")
1587 records = [r for r in syms.values() if "Timeout" in r["name"]]
1588 assert records
1589 assert records[0]["kind"] == "variable"
1590
1591
1592 # ---------------------------------------------------------------------------
1593 # Rust: static, const, type alias, mod
1594 # ---------------------------------------------------------------------------
1595
1596
1597 class TestRustExtended:
1598 def _parse(self, src: str) -> SymbolTree:
1599 adapter = adapter_for_path("lib.rs")
1600 if isinstance(adapter, FallbackAdapter):
1601 pytest.skip("tree-sitter-rust not available")
1602 return adapter.parse_symbols(src.encode(), "lib.rs")
1603
1604 def test_static_extracted(self) -> None:
1605 syms = self._parse("static MAX: usize = 100;\n")
1606 assert any("MAX" in k for k in syms), f"keys: {list(syms)}"
1607
1608 def test_const_extracted(self) -> None:
1609 syms = self._parse("const TIMEOUT: u64 = 30;\n")
1610 assert any("TIMEOUT" in k for k in syms)
1611
1612 def test_type_alias_extracted(self) -> None:
1613 syms = self._parse("type Result<T> = std::result::Result<T, Error>;\n")
1614 assert any("Result" in k for k in syms)
1615
1616 def test_mod_extracted(self) -> None:
1617 syms = self._parse("mod utils { pub fn helper() {} }\n")
1618 assert any("utils" in k for k in syms)
1619
1620
1621 # ---------------------------------------------------------------------------
1622 # C: struct and enum extraction
1623 # ---------------------------------------------------------------------------
1624
1625
1626 class TestCStructEnum:
1627 def _parse(self, src: str) -> SymbolTree:
1628 adapter = adapter_for_path("main.c")
1629 if isinstance(adapter, FallbackAdapter):
1630 pytest.skip("tree-sitter-c not available")
1631 return adapter.parse_symbols(src.encode(), "main.c")
1632
1633 def test_struct_extracted(self) -> None:
1634 syms = self._parse("struct Point { int x; int y; };\n")
1635 assert any("Point" in k for k in syms), f"keys: {list(syms)}"
1636
1637 def test_enum_extracted(self) -> None:
1638 syms = self._parse("enum Color { RED, GREEN, BLUE };\n")
1639 assert any("Color" in k for k in syms)
1640
1641 def test_struct_kind(self) -> None:
1642 syms = self._parse("struct Node { int val; struct Node *next; };\n")
1643 records = [r for r in syms.values() if "Node" in r["name"]]
1644 assert records
1645 assert records[0]["kind"] == "class"
1646
1647
1648 # ---------------------------------------------------------------------------
1649 # C#: property and record extraction
1650 # ---------------------------------------------------------------------------
1651
1652
1653 class TestCSharpExtended:
1654 def _parse(self, src: str) -> SymbolTree:
1655 adapter = adapter_for_path("Model.cs")
1656 if isinstance(adapter, FallbackAdapter):
1657 pytest.skip("tree-sitter-c-sharp not available")
1658 return adapter.parse_symbols(src.encode(), "Model.cs")
1659
1660 def test_property_extracted(self) -> None:
1661 syms = self._parse(
1662 "class User { public string Name { get; set; } }\n"
1663 )
1664 assert any("Name" in k for k in syms), f"keys: {list(syms)}"
1665
1666 def test_record_extracted(self) -> None:
1667 syms = self._parse("public record Point(int X, int Y);\n")
1668 assert any("Point" in k for k in syms)
1669
1670 def test_property_kind(self) -> None:
1671 syms = self._parse(
1672 "class C { public int Age { get; set; } }\n"
1673 )
1674 records = [r for r in syms.values() if "Age" in r["name"]]
1675 assert records
1676 assert records[0]["kind"] == "variable"
1677
1678
1679 # ---------------------------------------------------------------------------
1680 # Java: annotation type and record extraction
1681 # ---------------------------------------------------------------------------
1682
1683
1684 class TestJavaExtended:
1685 def _parse(self, src: str) -> SymbolTree:
1686 adapter = adapter_for_path("Main.java")
1687 if isinstance(adapter, FallbackAdapter):
1688 pytest.skip("tree-sitter-java not available")
1689 return adapter.parse_symbols(src.encode(), "Main.java")
1690
1691 def test_annotation_type_extracted(self) -> None:
1692 syms = self._parse("public @interface Cacheable { String value() default \"\"; }\n")
1693 assert any("Cacheable" in k for k in syms), f"keys: {list(syms)}"
1694
1695 def test_record_extracted(self) -> None:
1696 syms = self._parse("public record Point(int x, int y) {}\n")
1697 assert any("Point" in k for k in syms)
1698
1699
1700 # ---------------------------------------------------------------------------
1701 # Kotlin: object declaration and property extraction
1702 # ---------------------------------------------------------------------------
1703
1704
1705 class TestKotlinExtended:
1706 def _parse(self, src: str) -> SymbolTree:
1707 adapter = adapter_for_path("Main.kt")
1708 if isinstance(adapter, FallbackAdapter):
1709 pytest.skip("tree-sitter-kotlin not available")
1710 return adapter.parse_symbols(src.encode(), "Main.kt")
1711
1712 def test_object_declaration_extracted(self) -> None:
1713 syms = self._parse("object Singleton { fun greet() = println(\"hi\") }\n")
1714 assert any("Singleton" in k for k in syms), f"keys: {list(syms)}"
1715
1716 def test_property_declaration_extracted(self) -> None:
1717 syms = self._parse("val MAX_SIZE: Int = 100\n")
1718 assert any("MAX_SIZE" in k for k in syms)