gabriel / muse public
test_code_plugin.py python
1,922 lines 75.6 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 155 days 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: Manifest) -> 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) -> Manifest:
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_same_file_conflict(self, tmp_path: pathlib.Path) -> None:
1172 """Two agents modify different functions in the same file → file-level conflict.
1173
1174 The OT engine correctly identifies that the individual symbol edits commute
1175 (different addresses), but Muse cannot reconstruct the merged file blob
1176 without a text-merge pass. Silently returning "ours" would discard the
1177 other branch's changes, so merge_ops propagates the file-level conflict
1178 from the fallback merge() and surfaces it to the user for manual resolution.
1179 """
1180 repo_root = tmp_path / "repo"
1181 repo_root.mkdir()
1182
1183 base_src = _src("""\
1184 def foo(x: int) -> int:
1185 return x
1186
1187 def bar(y: int) -> int:
1188 return y
1189 """)
1190 # Ours: modify foo.
1191 ours_src = _src("""\
1192 def foo(x: int) -> int:
1193 return x * 2
1194
1195 def bar(y: int) -> int:
1196 return y
1197 """)
1198 # Theirs: modify bar.
1199 theirs_src = _src("""\
1200 def foo(x: int) -> int:
1201 return x
1202
1203 def bar(y: int) -> int:
1204 return y + 1
1205 """)
1206
1207 base_snap = self._py_snap("m.py", base_src, repo_root)
1208 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1209 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1210
1211 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1212 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1213
1214 result = self.plugin.merge_ops(
1215 base_snap,
1216 ours_snap,
1217 theirs_snap,
1218 ours_delta["ops"],
1219 theirs_delta["ops"],
1220 repo_root=repo_root,
1221 )
1222 # The OT check says ops commute, but the blobs differ on both sides.
1223 # Without text-merge, accepting silently would discard "theirs" changes
1224 # to bar(). merge_ops must propagate the file-level conflict instead.
1225 assert not result.is_clean, "Expected file-level conflict for same-file concurrent edits"
1226 assert "m.py" in result.conflicts
1227
1228 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1229 """Both agents modify the same function → conflict at symbol address."""
1230 repo_root = tmp_path / "repo"
1231 repo_root.mkdir()
1232
1233 base_src = _src("def calc(x: int) -> int:\n return x\n")
1234 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1235 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1236
1237 base_snap = self._py_snap("calc.py", base_src, repo_root)
1238 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1239 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1240
1241 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1242 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1243
1244 result = self.plugin.merge_ops(
1245 base_snap,
1246 ours_snap,
1247 theirs_snap,
1248 ours_delta["ops"],
1249 theirs_delta["ops"],
1250 repo_root=repo_root,
1251 )
1252 assert not result.is_clean
1253 # Conflict should be at file or symbol level.
1254 assert len(result.conflicts) > 0
1255
1256 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1257 """Agents modify completely different files → auto-merge."""
1258 repo_root = tmp_path / "repo"
1259 repo_root.mkdir()
1260
1261 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1262 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1263 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1264
1265 ours_delta = self.plugin.diff(base, ours)
1266 theirs_delta = self.plugin.diff(base, theirs)
1267
1268 result = self.plugin.merge_ops(
1269 base, ours, theirs,
1270 ours_delta["ops"],
1271 theirs_delta["ops"],
1272 )
1273 assert result.is_clean
1274
1275
1276 # ---------------------------------------------------------------------------
1277 # merge_ops conflict-propagation regression tests
1278 # ---------------------------------------------------------------------------
1279
1280
1281 class TestMergeOpsConflictPropagation:
1282 """Regression tests for the merge_ops conflict-propagation bug.
1283
1284 Before the fix, merge_ops silently used the "ours" blob when the OT check
1285 missed a conflict — either because of mixed op types (one side ReplaceOp,
1286 other side PatchOp) or because symbol-level ops commuted while the file
1287 blobs still differed. Both cases produced wrong merged content without
1288 flagging a conflict.
1289
1290 After the fix, merge_ops propagates file-level conflicts from the fallback
1291 merge() unless the path was already auto-resolved by a .museattributes
1292 strategy. See: muse/plugins/code/plugin.py::CodePlugin.merge_ops Step 4.
1293 """
1294
1295 plugin = CodePlugin()
1296
1297 # ------------------------------------------------------------------
1298 # Scenario 1: Completely different file versions — both sides changed
1299 # the entire content of a non-code file (e.g. AGENTS.md regression).
1300 # ------------------------------------------------------------------
1301
1302 def test_commuting_symbol_changes_same_file_is_conflict(
1303 self, tmp_path: pathlib.Path
1304 ) -> None:
1305 """Both branches modify different sections → OT says commute, but file-level conflict.
1306
1307 This is the exact scenario that caused the AGENTS.md regression:
1308 - merge base has Section A
1309 - ours modifies Section A (different content, same heading)
1310 - theirs adds Section B (new heading not in base or ours)
1311
1312 The OT check sees ReplaceOp("AGENTS.md::Project.Section A") vs
1313 InsertOp("AGENTS.md::Project.Section B") — different addresses → they commute.
1314 OT declares a clean merge, but the merged blob is just "ours" (Section A
1315 updated, Section B absent), silently discarding theirs' new section.
1316
1317 After the fix, merge_ops propagates the file-level conflict from the
1318 fallback merge() so the user is told to resolve it manually.
1319 """
1320 repo_root = tmp_path / "repo"
1321 repo_root.mkdir()
1322
1323 # Base: one section.
1324 base_content = b"# Project\n\n## Section A\n\nOriginal content.\n"
1325 # Ours: modified Section A (different text but same heading).
1326 ours_content = b"# Project\n\n## Section A\n\nOurs rewrote Section A.\n"
1327 # Theirs: Section A unchanged + added Section B.
1328 theirs_content = (
1329 b"# Project\n\n## Section A\n\nOriginal content.\n\n"
1330 b"## Section B\n\nTheirs added this new section.\n"
1331 )
1332
1333 base_oid = _store_blob(repo_root, base_content)
1334 ours_oid = _store_blob(repo_root, ours_content)
1335 theirs_oid = _store_blob(repo_root, theirs_content)
1336
1337 base_snap = _make_manifest({"AGENTS.md": base_oid})
1338 ours_snap = _make_manifest({"AGENTS.md": ours_oid})
1339 theirs_snap = _make_manifest({"AGENTS.md": theirs_oid})
1340
1341 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1342 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1343
1344 result = self.plugin.merge_ops(
1345 base_snap, ours_snap, theirs_snap,
1346 ours_delta["ops"], theirs_delta["ops"],
1347 repo_root=repo_root,
1348 )
1349 # OT sees commuting ops (different symbol addresses), but the merged file
1350 # blob would silently be "ours" — theirs' Section B would be dropped.
1351 # merge_ops must surface the file-level conflict.
1352 assert not result.is_clean, (
1353 "Commuting ops on same file — expected file-level conflict to be propagated, "
1354 f"got is_clean=True. Conflicts: {result.conflicts}. "
1355 "AGENTS.md :: Section B from 'theirs' would be silently discarded."
1356 )
1357 conflict_files = {c.split("::")[0] for c in result.conflicts}
1358 assert "AGENTS.md" in conflict_files, (
1359 f"Expected 'AGENTS.md' in conflict file paths, got: {result.conflicts}"
1360 )
1361
1362 # ------------------------------------------------------------------
1363 # Scenario 2: Mixed op types — one side ReplaceOp, other PatchOp.
1364 # ------------------------------------------------------------------
1365
1366 def test_mixed_op_types_is_conflict(self, tmp_path: pathlib.Path) -> None:
1367 """One side has ReplaceOp (no symbol tree), other has PatchOp → conflict.
1368
1369 If the file has no parseable symbols on one branch (e.g. a plain text
1370 file where one branch added a heading and the other didn't), the diff
1371 produces a ReplaceOp on the no-heading side and a PatchOp on the
1372 heading side. They never appear together in the OT conflict loops,
1373 so the OT check sees no conflict — but the blobs differ on both sides.
1374 """
1375 repo_root = tmp_path / "repo"
1376 repo_root.mkdir()
1377
1378 # Base: plain text, no Markdown headings → no symbol tree.
1379 base_content = b"version = 1\n"
1380 # Ours: still no heading → ReplaceOp at file level.
1381 ours_content = b"version = 1-hotfix\n"
1382 # Theirs: added a heading → PatchOp with symbol child.
1383 theirs_content = b"version = 2\n\n# comprehensive update\n"
1384
1385 base_oid = _store_blob(repo_root, base_content)
1386 ours_oid = _store_blob(repo_root, ours_content)
1387 theirs_oid = _store_blob(repo_root, theirs_content)
1388
1389 base_snap = _make_manifest({"config.txt": base_oid})
1390 ours_snap = _make_manifest({"config.txt": ours_oid})
1391 theirs_snap = _make_manifest({"config.txt": theirs_oid})
1392
1393 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1394 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1395
1396 result = self.plugin.merge_ops(
1397 base_snap, ours_snap, theirs_snap,
1398 ours_delta["ops"], theirs_delta["ops"],
1399 repo_root=repo_root,
1400 )
1401 assert not result.is_clean, (
1402 "Mixed op types (ReplaceOp ours, PatchOp theirs) for config.txt — "
1403 f"expected conflict, got is_clean=True. "
1404 f"Ours ops: {ours_delta['ops']}. Theirs ops: {theirs_delta['ops']}."
1405 )
1406 assert "config.txt" in result.conflicts
1407
1408 # ------------------------------------------------------------------
1409 # Scenario 3: Only one side changed the file → clean merge (no regression).
1410 # ------------------------------------------------------------------
1411
1412 def test_only_ours_changed_is_clean(self, tmp_path: pathlib.Path) -> None:
1413 """Only our branch changed a text file → theirs is base → clean merge."""
1414 repo_root = tmp_path / "repo"
1415 repo_root.mkdir()
1416
1417 base_content = b"# Docs\n\nOriginal.\n"
1418 ours_content = b"# Docs\n\nOurs update.\n"
1419
1420 base_oid = _store_blob(repo_root, base_content)
1421 ours_oid = _store_blob(repo_root, ours_content)
1422
1423 base_snap = _make_manifest({"README.md": base_oid})
1424 ours_snap = _make_manifest({"README.md": ours_oid})
1425 theirs_snap = base_snap # theirs unchanged
1426
1427 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1428 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1429
1430 result = self.plugin.merge_ops(
1431 base_snap, ours_snap, theirs_snap,
1432 ours_delta["ops"], theirs_delta["ops"],
1433 repo_root=repo_root,
1434 )
1435 assert result.is_clean, f"Only ours changed — should auto-merge, got: {result.conflicts}"
1436
1437 # ------------------------------------------------------------------
1438 # Scenario 4: Completely disjoint files → clean merge (no regression).
1439 # ------------------------------------------------------------------
1440
1441 def test_disjoint_files_remain_clean(self, tmp_path: pathlib.Path) -> None:
1442 """Each branch changed a different file entirely → clean merge."""
1443 repo_root = tmp_path / "repo"
1444 repo_root.mkdir()
1445
1446 base_a = b"# File A\n\nOriginal.\n"
1447 base_b = b"# File B\n\nOriginal.\n"
1448 ours_a = b"# File A\n\nOurs update.\n"
1449
1450 base_a_oid = _store_blob(repo_root, base_a)
1451 base_b_oid = _store_blob(repo_root, base_b)
1452 ours_a_oid = _store_blob(repo_root, ours_a)
1453 theirs_b_oid = _store_blob(repo_root, b"# File B\n\nTheirs update.\n")
1454
1455 base_snap = _make_manifest({"a.md": base_a_oid, "b.md": base_b_oid})
1456 ours_snap = _make_manifest({"a.md": ours_a_oid, "b.md": base_b_oid})
1457 theirs_snap = _make_manifest({"a.md": base_a_oid, "b.md": theirs_b_oid})
1458
1459 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1460 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1461
1462 result = self.plugin.merge_ops(
1463 base_snap, ours_snap, theirs_snap,
1464 ours_delta["ops"], theirs_delta["ops"],
1465 repo_root=repo_root,
1466 )
1467 assert result.is_clean, f"Disjoint files — should auto-merge, got: {result.conflicts}"
1468
1469
1470 # ---------------------------------------------------------------------------
1471 # CodePlugin — drift
1472 # ---------------------------------------------------------------------------
1473
1474
1475 class TestCodePluginDrift:
1476 plugin = CodePlugin()
1477
1478 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1479 workdir = tmp_path
1480 (workdir / "app.py").write_text("x = 1\n")
1481 snap = self.plugin.snapshot(workdir)
1482 report = self.plugin.drift(snap, workdir)
1483 assert not report.has_drift
1484
1485 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1486 workdir = tmp_path
1487 f = workdir / "app.py"
1488 f.write_text("x = 1\n")
1489 snap = self.plugin.snapshot(workdir)
1490 f.write_text("x = 2\n")
1491 report = self.plugin.drift(snap, workdir)
1492 assert report.has_drift
1493
1494 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1495 workdir = tmp_path
1496 (workdir / "a.py").write_text("a = 1\n")
1497 snap = self.plugin.snapshot(workdir)
1498 (workdir / "b.py").write_text("b = 2\n")
1499 report = self.plugin.drift(snap, workdir)
1500 assert report.has_drift
1501
1502 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1503 workdir = tmp_path
1504 f = workdir / "gone.py"
1505 f.write_text("x = 1\n")
1506 snap = self.plugin.snapshot(workdir)
1507 f.unlink()
1508 report = self.plugin.drift(snap, workdir)
1509 assert report.has_drift
1510
1511
1512 # ---------------------------------------------------------------------------
1513 # CodePlugin — apply (passthrough)
1514 # ---------------------------------------------------------------------------
1515
1516
1517 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1518 plugin = CodePlugin()
1519 workdir = tmp_path
1520 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1521 result = plugin.apply(delta, workdir)
1522 assert result is workdir
1523
1524
1525 # ---------------------------------------------------------------------------
1526 # CodePlugin — schema
1527 # ---------------------------------------------------------------------------
1528
1529
1530 class TestCodePluginSchema:
1531 plugin = CodePlugin()
1532
1533 def test_schema_domain(self) -> None:
1534 assert self.plugin.schema()["domain"] == "code"
1535
1536 def test_schema_merge_mode(self) -> None:
1537 assert self.plugin.schema()["merge_mode"] == "three_way"
1538
1539 def test_schema_version(self) -> None:
1540 assert self.plugin.schema()["schema_version"] == __version__
1541
1542 def test_schema_dimensions(self) -> None:
1543 dims = self.plugin.schema()["dimensions"]
1544 names = {d["name"] for d in dims}
1545 assert "structure" in names
1546 assert "symbols" in names
1547 assert "imports" in names
1548
1549 def test_schema_top_level_is_tree(self) -> None:
1550 top = self.plugin.schema()["top_level"]
1551 assert top["kind"] == "tree"
1552
1553 def test_schema_description_non_empty(self) -> None:
1554 assert len(self.plugin.schema()["description"]) > 0
1555
1556
1557 # ---------------------------------------------------------------------------
1558 # delta_summary
1559 # ---------------------------------------------------------------------------
1560
1561
1562 class TestDeltaSummary:
1563 def test_empty_ops(self) -> None:
1564 assert delta_summary([]) == "no changes"
1565
1566 def test_file_added(self) -> None:
1567 from muse.domain import DomainOp
1568 ops: list[DomainOp] = [InsertOp(
1569 op="insert", address="f.py", position=None,
1570 content_id="abc", content_summary="added f.py",
1571 )]
1572 summary = delta_summary(ops)
1573 assert "added" in summary
1574 assert "file" in summary
1575
1576 def test_symbols_counted_from_patch(self) -> None:
1577 from muse.domain import DomainOp, PatchOp
1578 child: list[DomainOp] = [
1579 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1580 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1581 ]
1582 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1583 summary = delta_summary(ops)
1584 assert "symbol" in summary
1585
1586
1587 # ---------------------------------------------------------------------------
1588 # Markdown adapter
1589 # ---------------------------------------------------------------------------
1590
1591
1592 class TestMarkdownAdapter:
1593 """Semantic symbol extraction via tree-sitter-markdown."""
1594
1595 def _parse(self, src: str) -> SymbolTree:
1596 from muse.plugins.code.ast_parser import MarkdownAdapter
1597 adapter = MarkdownAdapter()
1598 if adapter._parser is None:
1599 pytest.skip("tree-sitter-markdown not available")
1600 return adapter.parse_symbols(src.encode(), "README.md")
1601
1602 def test_h1_extracted(self) -> None:
1603 syms = self._parse("# Hello World\n")
1604 assert any("Hello World" in k for k in syms), f"keys: {list(syms)}"
1605
1606 def test_h2_extracted(self) -> None:
1607 syms = self._parse("# Title\n\n## Section Two\n")
1608 assert any("Section Two" in k for k in syms)
1609
1610 def test_multiple_headings(self) -> None:
1611 src = "# Top\n\n## Alpha\n\n## Beta\n\n### Deep\n"
1612 syms = self._parse(src)
1613 kinds = {r["kind"] for r in syms.values()}
1614 assert "section" in kinds
1615 assert len(syms) >= 4
1616
1617 def test_section_lineno(self) -> None:
1618 src = "# First\n\n## Second\n"
1619 syms = self._parse(src)
1620 second = next((r for r in syms.values() if "Second" in r["name"]), None)
1621 assert second is not None
1622 assert second["lineno"] == 3
1623
1624 def test_content_id_changes_with_text(self) -> None:
1625 s1 = self._parse("# Hello\n")
1626 s2 = self._parse("# World\n")
1627 ids1 = {r["content_id"] for r in s1.values()}
1628 ids2 = {r["content_id"] for r in s2.values()}
1629 assert ids1 != ids2
1630
1631 def test_adapter_for_path_md(self) -> None:
1632 from muse.plugins.code.ast_parser import MarkdownAdapter
1633 adapter = adapter_for_path("docs/README.md")
1634 assert isinstance(adapter, MarkdownAdapter)
1635
1636 def test_adapter_for_path_rst(self) -> None:
1637 from muse.plugins.code.ast_parser import MarkdownAdapter
1638 adapter = adapter_for_path("notes.rst")
1639 assert isinstance(adapter, MarkdownAdapter)
1640
1641
1642 # ---------------------------------------------------------------------------
1643 # HTML adapter
1644 # ---------------------------------------------------------------------------
1645
1646
1647 class TestHtmlAdapter:
1648 """Semantic element and id-bearing element extraction via tree-sitter-html."""
1649
1650 def _parse(self, src: str) -> SymbolTree:
1651 from muse.plugins.code.ast_parser import HtmlAdapter
1652 adapter = HtmlAdapter()
1653 if adapter._parser is None:
1654 pytest.skip("tree-sitter-html not available")
1655 return adapter.parse_symbols(src.encode(), "index.html")
1656
1657 def test_id_bearing_div_extracted(self) -> None:
1658 syms = self._parse('<html><body><div id="hero">x</div></body></html>')
1659 assert any("div#hero" in k for k in syms), f"keys: {list(syms)}"
1660
1661 def test_semantic_section_extracted(self) -> None:
1662 syms = self._parse('<html><body><section>content</section></body></html>')
1663 assert any("section" in k for k in syms)
1664
1665 def test_h1_heading_extracted(self) -> None:
1666 syms = self._parse('<html><body><h1>Title</h1></body></html>')
1667 assert any("h1" in k for k in syms)
1668
1669 def test_generic_div_without_id_skipped(self) -> None:
1670 syms = self._parse('<html><body><div>plain</div></body></html>')
1671 assert not any("div" in k for k in syms), f"unexpected: {list(syms)}"
1672
1673 def test_multiple_ids(self) -> None:
1674 src = '<html><body><section id="intro">a</section><section id="outro">b</section></body></html>'
1675 syms = self._parse(src)
1676 assert any("section#intro" in k for k in syms)
1677 assert any("section#outro" in k for k in syms)
1678
1679 def test_adapter_for_path_html(self) -> None:
1680 from muse.plugins.code.ast_parser import HtmlAdapter
1681 assert isinstance(adapter_for_path("page.html"), HtmlAdapter)
1682
1683 def test_adapter_for_path_htm(self) -> None:
1684 from muse.plugins.code.ast_parser import HtmlAdapter
1685 assert isinstance(adapter_for_path("legacy.htm"), HtmlAdapter)
1686
1687
1688 # ---------------------------------------------------------------------------
1689 # CSS adapter
1690 # ---------------------------------------------------------------------------
1691
1692
1693 class TestCssAdapter:
1694 """Rule-set, @keyframes, and @media extraction via tree-sitter-css."""
1695
1696 def _parse(self, src: str, path: str = "styles.css") -> SymbolTree:
1697 adapter = adapter_for_path(path)
1698 # If the CSS grammar is unavailable the adapter degrades to FallbackAdapter.
1699 if isinstance(adapter, FallbackAdapter):
1700 pytest.skip("tree-sitter-css not available")
1701 return adapter.parse_symbols(src.encode(), path)
1702
1703 def test_rule_set_extracted(self) -> None:
1704 syms = self._parse(".btn { color: red; }")
1705 assert len(syms) >= 1
1706 kinds = {r["kind"] for r in syms.values()}
1707 assert "rule" in kinds
1708
1709 def test_keyframes_extracted(self) -> None:
1710 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }")
1711 assert any("spin" in r["name"] for r in syms.values()), f"symbols: {list(syms)}"
1712
1713 def test_multiple_rules(self) -> None:
1714 src = ".a { color: red; }\n.b { color: blue; }"
1715 syms = self._parse(src)
1716 assert len(syms) >= 2
1717
1718 def test_scss_extension_uses_css_parser(self) -> None:
1719 syms = self._parse(".mixin { display: flex; }", path="app.scss")
1720 assert len(syms) >= 1
1721
1722 def test_content_id_differs_for_different_rules(self) -> None:
1723 s1 = self._parse(".a { color: red; }")
1724 s2 = self._parse(".b { color: blue; }")
1725 ids1 = {r["content_id"] for r in s1.values()}
1726 ids2 = {r["content_id"] for r in s2.values()}
1727 assert ids1 != ids2
1728
1729
1730 # ---------------------------------------------------------------------------
1731 # JS/TS: arrow functions and async detection
1732 # ---------------------------------------------------------------------------
1733
1734
1735 class TestJSArrowFunctions:
1736 """Arrow functions and function expressions bound to const/let."""
1737
1738 def _parse(self, src: str, path: str = "mod.js") -> SymbolTree:
1739 adapter = adapter_for_path(path)
1740 if isinstance(adapter, FallbackAdapter):
1741 pytest.skip("tree-sitter-javascript not available")
1742 return adapter.parse_symbols(src.encode(), path)
1743
1744 def test_const_arrow_function(self) -> None:
1745 syms = self._parse("const greet = (name) => `Hello ${name}`;\n")
1746 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
1747
1748 def test_const_function_expression(self) -> None:
1749 syms = self._parse("const add = function(a, b) { return a + b; };\n")
1750 assert any("add" in k for k in syms)
1751
1752 def test_ts_arrow_function(self) -> None:
1753 syms = self._parse(
1754 "const greet = (name: string): string => `Hello ${name}`;\n",
1755 path="mod.ts",
1756 )
1757 assert any("greet" in k for k in syms)
1758
1759 def test_class_method_still_extracted(self) -> None:
1760 syms = self._parse("class Foo { bar() { return 1; } }\n")
1761 assert any("bar" in k for k in syms)
1762
1763 def test_async_function_detected(self) -> None:
1764 syms = self._parse("async function fetchData() { return await fetch('/'); }\n")
1765 kinds = {r["kind"] for r in syms.values() if "fetchData" in r["name"]}
1766 assert "async_function" in kinds, f"kinds: {kinds}"
1767
1768
1769 # ---------------------------------------------------------------------------
1770 # Go: const and var spec extraction
1771 # ---------------------------------------------------------------------------
1772
1773
1774 class TestGoConstVar:
1775 def _parse(self, src: str) -> SymbolTree:
1776 adapter = adapter_for_path("main.go")
1777 if isinstance(adapter, FallbackAdapter):
1778 pytest.skip("tree-sitter-go not available")
1779 return adapter.parse_symbols(src.encode(), "main.go")
1780
1781 def test_const_extracted(self) -> None:
1782 syms = self._parse("package main\nconst MaxRetries = 3\n")
1783 assert any("MaxRetries" in k for k in syms), f"keys: {list(syms)}"
1784
1785 def test_var_extracted(self) -> None:
1786 syms = self._parse("package main\nvar ErrNotFound = errors.New(\"not found\")\n")
1787 assert any("ErrNotFound" in k for k in syms)
1788
1789 def test_const_kind_is_variable(self) -> None:
1790 syms = self._parse("package main\nconst Timeout = 30\n")
1791 records = [r for r in syms.values() if "Timeout" in r["name"]]
1792 assert records
1793 assert records[0]["kind"] == "variable"
1794
1795
1796 # ---------------------------------------------------------------------------
1797 # Rust: static, const, type alias, mod
1798 # ---------------------------------------------------------------------------
1799
1800
1801 class TestRustExtended:
1802 def _parse(self, src: str) -> SymbolTree:
1803 adapter = adapter_for_path("lib.rs")
1804 if isinstance(adapter, FallbackAdapter):
1805 pytest.skip("tree-sitter-rust not available")
1806 return adapter.parse_symbols(src.encode(), "lib.rs")
1807
1808 def test_static_extracted(self) -> None:
1809 syms = self._parse("static MAX: usize = 100;\n")
1810 assert any("MAX" in k for k in syms), f"keys: {list(syms)}"
1811
1812 def test_const_extracted(self) -> None:
1813 syms = self._parse("const TIMEOUT: u64 = 30;\n")
1814 assert any("TIMEOUT" in k for k in syms)
1815
1816 def test_type_alias_extracted(self) -> None:
1817 syms = self._parse("type Result<T> = std::result::Result<T, Error>;\n")
1818 assert any("Result" in k for k in syms)
1819
1820 def test_mod_extracted(self) -> None:
1821 syms = self._parse("mod utils { pub fn helper() {} }\n")
1822 assert any("utils" in k for k in syms)
1823
1824
1825 # ---------------------------------------------------------------------------
1826 # C: struct and enum extraction
1827 # ---------------------------------------------------------------------------
1828
1829
1830 class TestCStructEnum:
1831 def _parse(self, src: str) -> SymbolTree:
1832 adapter = adapter_for_path("main.c")
1833 if isinstance(adapter, FallbackAdapter):
1834 pytest.skip("tree-sitter-c not available")
1835 return adapter.parse_symbols(src.encode(), "main.c")
1836
1837 def test_struct_extracted(self) -> None:
1838 syms = self._parse("struct Point { int x; int y; };\n")
1839 assert any("Point" in k for k in syms), f"keys: {list(syms)}"
1840
1841 def test_enum_extracted(self) -> None:
1842 syms = self._parse("enum Color { RED, GREEN, BLUE };\n")
1843 assert any("Color" in k for k in syms)
1844
1845 def test_struct_kind(self) -> None:
1846 syms = self._parse("struct Node { int val; struct Node *next; };\n")
1847 records = [r for r in syms.values() if "Node" in r["name"]]
1848 assert records
1849 assert records[0]["kind"] == "class"
1850
1851
1852 # ---------------------------------------------------------------------------
1853 # C#: property and record extraction
1854 # ---------------------------------------------------------------------------
1855
1856
1857 class TestCSharpExtended:
1858 def _parse(self, src: str) -> SymbolTree:
1859 adapter = adapter_for_path("Model.cs")
1860 if isinstance(adapter, FallbackAdapter):
1861 pytest.skip("tree-sitter-c-sharp not available")
1862 return adapter.parse_symbols(src.encode(), "Model.cs")
1863
1864 def test_property_extracted(self) -> None:
1865 syms = self._parse(
1866 "class User { public string Name { get; set; } }\n"
1867 )
1868 assert any("Name" in k for k in syms), f"keys: {list(syms)}"
1869
1870 def test_record_extracted(self) -> None:
1871 syms = self._parse("public record Point(int X, int Y);\n")
1872 assert any("Point" in k for k in syms)
1873
1874 def test_property_kind(self) -> None:
1875 syms = self._parse(
1876 "class C { public int Age { get; set; } }\n"
1877 )
1878 records = [r for r in syms.values() if "Age" in r["name"]]
1879 assert records
1880 assert records[0]["kind"] == "variable"
1881
1882
1883 # ---------------------------------------------------------------------------
1884 # Java: annotation type and record extraction
1885 # ---------------------------------------------------------------------------
1886
1887
1888 class TestJavaExtended:
1889 def _parse(self, src: str) -> SymbolTree:
1890 adapter = adapter_for_path("Main.java")
1891 if isinstance(adapter, FallbackAdapter):
1892 pytest.skip("tree-sitter-java not available")
1893 return adapter.parse_symbols(src.encode(), "Main.java")
1894
1895 def test_annotation_type_extracted(self) -> None:
1896 syms = self._parse("public @interface Cacheable { String value() default \"\"; }\n")
1897 assert any("Cacheable" in k for k in syms), f"keys: {list(syms)}"
1898
1899 def test_record_extracted(self) -> None:
1900 syms = self._parse("public record Point(int x, int y) {}\n")
1901 assert any("Point" in k for k in syms)
1902
1903
1904 # ---------------------------------------------------------------------------
1905 # Kotlin: object declaration and property extraction
1906 # ---------------------------------------------------------------------------
1907
1908
1909 class TestKotlinExtended:
1910 def _parse(self, src: str) -> SymbolTree:
1911 adapter = adapter_for_path("Main.kt")
1912 if isinstance(adapter, FallbackAdapter):
1913 pytest.skip("tree-sitter-kotlin not available")
1914 return adapter.parse_symbols(src.encode(), "Main.kt")
1915
1916 def test_object_declaration_extracted(self) -> None:
1917 syms = self._parse("object Singleton { fun greet() = println(\"hi\") }\n")
1918 assert any("Singleton" in k for k in syms), f"keys: {list(syms)}"
1919
1920 def test_property_declaration_extracted(self) -> None:
1921 syms = self._parse("val MAX_SIZE: Int = 100\n")
1922 assert any("MAX_SIZE" in k for k in syms)
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 155 days ago