gabriel / muse public
test_code_plugin.py python
2,462 lines 100.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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"] == "interface"
344
345 def test_ts_enum_kind(self) -> None:
346 src = b"enum Color { Red, Green, Blue }"
347 syms = self._syms(src, "colors.ts")
348 assert "colors.ts::Color" in syms
349 assert syms["colors.ts::Color"] == "enum"
350
351 def test_ts_namespace_kind(self) -> None:
352 src = b"namespace MyLib { export function greet(): void {} }"
353 syms = self._syms(src, "lib.ts")
354 assert "lib.ts::MyLib" in syms
355 assert syms["lib.ts::MyLib"] == "namespace"
356
357 def test_ts_type_alias_kind(self) -> None:
358 src = b"type ID = string;"
359 syms = self._syms(src, "types.ts")
360 assert "types.ts::ID" in syms
361 assert syms["types.ts::ID"] == "type_alias"
362
363 def test_ts_class_and_method(self) -> None:
364 src = b"class Dog { bark(): string { return 'woof'; } }"
365 syms = self._syms(src, "dog.ts")
366 assert "dog.ts::Dog" in syms
367 assert "dog.ts::Dog.bark" in syms
368
369 def test_tsx_parses_correctly(self) -> None:
370 src = b"function Button(): void { return; }\ninterface Props { label: string; }"
371 syms = self._syms(src, "button.tsx")
372 assert "button.tsx::Button" in syms
373 assert "button.tsx::Props" in syms
374
375 # --- Go -------------------------------------------------------------------
376
377 def test_go_function(self) -> None:
378 src = b"func NewDog(name string) string { return name }"
379 syms = self._syms(src, "dog.go")
380 assert "dog.go::NewDog" in syms
381 assert syms["dog.go::NewDog"] == "function"
382
383 def test_go_method_qualified_with_receiver(self) -> None:
384 """Go methods carry the receiver type as qualified-name prefix."""
385 src = b"type Dog struct { Name string }\nfunc (d Dog) Bark() string { return d.Name }"
386 syms = self._syms(src, "dog.go")
387 assert "dog.go::Dog" in syms
388 assert "dog.go::Dog.Bark" in syms
389 assert syms["dog.go::Dog.Bark"] == "method"
390
391 def test_go_pointer_receiver_stripped(self) -> None:
392 """Pointer receivers (*Dog) are stripped to give Dog.Method."""
393 src = b"type Dog struct {}\nfunc (d *Dog) Sit() {}"
394 syms = self._syms(src, "d.go")
395 assert "d.go::Dog.Sit" in syms
396
397 def test_go_struct_interface_type_alias_kinds(self) -> None:
398 """Go type_spec is refined to struct/interface/type_alias via child node type."""
399 src = (
400 b"type Dog struct { Name string }\n"
401 b"type Animal interface { Speak() string }\n"
402 b"type MyInt int\n"
403 )
404 syms = self._syms(src, "types.go")
405 assert "types.go::Dog" in syms
406 assert syms["types.go::Dog"] == "struct"
407 assert "types.go::Animal" in syms
408 assert syms["types.go::Animal"] == "interface"
409 assert "types.go::MyInt" in syms
410 assert syms["types.go::MyInt"] == "type_alias"
411
412 # --- Rust -----------------------------------------------------------------
413
414 def test_rust_standalone_function(self) -> None:
415 src = b"fn add(a: i32, b: i32) -> i32 { a + b }"
416 syms = self._syms(src, "math.rs")
417 assert "math.rs::add" in syms
418 assert syms["math.rs::add"] == "function"
419
420 def test_rust_impl_method_qualified(self) -> None:
421 """Rust impl methods are qualified as TypeName.method."""
422 src = b"struct Dog { name: String }\nimpl Dog { fn bark(&self) -> String { self.name.clone() } }"
423 syms = self._syms(src, "dog.rs")
424 assert "dog.rs::Dog" in syms
425 assert "dog.rs::Dog.bark" in syms
426
427 def test_rust_struct_and_trait(self) -> None:
428 src = b"struct Point { x: f64, y: f64 }\ntrait Shape { fn area(&self) -> f64; }"
429 syms = self._syms(src, "shapes.rs")
430 assert "shapes.rs::Point" in syms
431 assert syms["shapes.rs::Point"] == "struct"
432 assert "shapes.rs::Shape" in syms
433 assert syms["shapes.rs::Shape"] == "trait"
434
435 def test_rust_enum_kind(self) -> None:
436 src = b"enum Direction { North, South, East, West }"
437 syms = self._syms(src, "dir.rs")
438 assert "dir.rs::Direction" in syms
439 assert syms["dir.rs::Direction"] == "enum"
440
441 # --- Java -----------------------------------------------------------------
442
443 def test_java_class_and_method(self) -> None:
444 src = b"public class Calculator { public int add(int a, int b) { return a + b; } }"
445 syms = self._syms(src, "Calc.java")
446 assert "Calc.java::Calculator" in syms
447 assert syms["Calc.java::Calculator"] == "class"
448 assert "Calc.java::Calculator.add" in syms
449 assert syms["Calc.java::Calculator.add"] == "method"
450
451 def test_java_interface(self) -> None:
452 src = b"public interface Shape { double area(); }"
453 syms = self._syms(src, "Shape.java")
454 assert "Shape.java::Shape" in syms
455 assert syms["Shape.java::Shape"] == "interface"
456
457 def test_java_enum_kind(self) -> None:
458 src = b"public enum Color { RED, GREEN, BLUE }"
459 syms = self._syms(src, "Color.java")
460 assert "Color.java::Color" in syms
461 assert syms["Color.java::Color"] == "enum"
462
463 # --- C --------------------------------------------------------------------
464
465 def test_c_function(self) -> None:
466 src = b"int add(int a, int b) { return a + b; }\nvoid noop(void) {}"
467 syms = self._syms(src, "math.c")
468 assert "math.c::add" in syms
469 assert syms["math.c::add"] == "function"
470 assert "math.c::noop" in syms
471
472 # --- C++ ------------------------------------------------------------------
473
474 def test_cpp_class_and_function(self) -> None:
475 src = b"class Animal { public: void speak() {} };\nint square(int x) { return x * x; }"
476 syms = self._syms(src, "app.cpp")
477 assert "app.cpp::Animal" in syms
478 assert syms["app.cpp::Animal"] == "class"
479 assert "app.cpp::square" in syms
480
481 # --- C# -------------------------------------------------------------------
482
483 def test_cs_class_and_method(self) -> None:
484 src = b"public class Greeter { public string Hello(string name) { return name; } }"
485 syms = self._syms(src, "Greeter.cs")
486 assert "Greeter.cs::Greeter" in syms
487 assert syms["Greeter.cs::Greeter"] == "class"
488 assert "Greeter.cs::Greeter.Hello" in syms
489 assert syms["Greeter.cs::Greeter.Hello"] == "method"
490
491 def test_cs_interface_and_struct(self) -> None:
492 src = b"interface IShape { double Area(); }\nstruct Point { public int X, Y; }"
493 syms = self._syms(src, "shapes.cs")
494 assert "shapes.cs::IShape" in syms
495 assert syms["shapes.cs::IShape"] == "interface"
496 assert "shapes.cs::Point" in syms
497 assert syms["shapes.cs::Point"] == "struct"
498
499 def test_cs_enum_kind(self) -> None:
500 src = b"enum Status { Active, Inactive, Pending }"
501 syms = self._syms(src, "status.cs")
502 assert "status.cs::Status" in syms
503 assert syms["status.cs::Status"] == "enum"
504
505 # --- Ruby -----------------------------------------------------------------
506
507 def test_ruby_class_and_method(self) -> None:
508 src = b"class Dog\n def bark\n puts 'woof'\n end\nend"
509 syms = self._syms(src, "dog.rb")
510 assert "dog.rb::Dog" in syms
511 assert syms["dog.rb::Dog"] == "class"
512 assert "dog.rb::Dog.bark" in syms
513 assert syms["dog.rb::Dog.bark"] == "method"
514
515 def test_ruby_module(self) -> None:
516 src = b"module Greetable\n def greet\n 'hello'\n end\nend"
517 syms = self._syms(src, "greet.rb")
518 assert "greet.rb::Greetable" in syms
519 assert syms["greet.rb::Greetable"] == "module"
520
521 # --- Kotlin ---------------------------------------------------------------
522
523 def test_kotlin_function_and_class(self) -> None:
524 src = b"fun greet(name: String): String = name\nclass Dog { fun bark(): Unit { } }"
525 syms = self._syms(src, "main.kt")
526 assert "main.kt::greet" in syms
527 assert syms["main.kt::greet"] == "function"
528 assert "main.kt::Dog" in syms
529 assert "main.kt::Dog.bark" in syms
530
531 def test_kotlin_object_kind(self) -> None:
532 """Kotlin singleton object declarations have kind 'object', not 'class'."""
533 src = b"object Singleton { val x = 1 }"
534 syms = self._syms(src, "s.kt")
535 assert "s.kt::Singleton" in syms
536 assert syms["s.kt::Singleton"] == "object"
537
538 # --- cross-language adapter routing ---------------------------------------
539
540 def test_adapter_for_path_routes_all_extensions(self) -> None:
541 """adapter_for_path must return a TreeSitterAdapter (not Fallback) for all supported exts."""
542 from muse.plugins.code.ast_parser import TreeSitterAdapter, adapter_for_path
543
544 for ext in (
545 ".js", ".jsx", ".mjs", ".cjs",
546 ".ts", ".tsx",
547 ".go",
548 ".rs",
549 ".java",
550 ".c", ".h",
551 ".cpp", ".cc", ".cxx", ".hpp",
552 ".cs",
553 ".rb",
554 ".kt", ".kts",
555 ):
556 a = adapter_for_path(f"src/file{ext}")
557 assert isinstance(a, TreeSitterAdapter), (
558 f"Expected TreeSitterAdapter for {ext}, got {type(a).__name__}"
559 )
560
561 def test_semantic_extensions_covers_all_ts_languages(self) -> None:
562 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS
563
564 expected = {
565 ".py", ".pyi",
566 ".js", ".jsx", ".mjs", ".cjs",
567 ".ts", ".tsx",
568 ".go", ".rs",
569 ".java",
570 ".c", ".h",
571 ".cpp", ".cc", ".cxx", ".hpp", ".hxx",
572 ".cs",
573 ".rb",
574 ".kt", ".kts",
575 }
576 assert expected <= SEMANTIC_EXTENSIONS
577
578
579 # ---------------------------------------------------------------------------
580 # adapter_for_path
581 # ---------------------------------------------------------------------------
582
583
584 def test_adapter_for_py_is_python() -> None:
585 assert isinstance(adapter_for_path("src/utils.py"), PythonAdapter)
586
587
588 def test_adapter_for_ts_is_tree_sitter() -> None:
589 from muse.plugins.code.ast_parser import TreeSitterAdapter
590
591 assert isinstance(adapter_for_path("src/app.ts"), TreeSitterAdapter)
592
593
594 def test_adapter_for_no_extension_is_fallback() -> None:
595 assert isinstance(adapter_for_path("Makefile"), FallbackAdapter)
596
597
598 # ---------------------------------------------------------------------------
599 # diff_symbol_trees — golden test cases
600 # ---------------------------------------------------------------------------
601
602
603 class TestDiffSymbolTrees:
604 """Golden test cases for symbol-level diff."""
605
606 def _func(
607 self,
608 addr: str,
609 content_id: str,
610 body_hash: str | None = None,
611 signature_id: str | None = None,
612 name: str = "f",
613 ) -> tuple[str, SymbolRecord]:
614 return addr, SymbolRecord(
615 kind="function",
616 name=name,
617 qualified_name=name,
618 content_id=content_id,
619 body_hash=body_hash or content_id,
620 signature_id=signature_id or content_id,
621 lineno=1,
622 end_lineno=3,
623 )
624
625 def test_empty_trees_produce_no_ops(self) -> None:
626 assert diff_symbol_trees({}, {}) == []
627
628 def test_added_symbol(self) -> None:
629 base: SymbolTree = {}
630 target: SymbolTree = dict([self._func("f.py::new_fn", "abc", name="new_fn")])
631 ops = diff_symbol_trees(base, target)
632 assert len(ops) == 1
633 assert ops[0]["op"] == "insert"
634 assert ops[0]["address"] == "f.py::new_fn"
635
636 def test_removed_symbol(self) -> None:
637 base: SymbolTree = dict([self._func("f.py::old", "abc", name="old")])
638 target: SymbolTree = {}
639 ops = diff_symbol_trees(base, target)
640 assert len(ops) == 1
641 assert ops[0]["op"] == "delete"
642 assert ops[0]["address"] == "f.py::old"
643
644 def test_unchanged_symbol_no_op(self) -> None:
645 rec = dict([self._func("f.py::stable", "xyz", name="stable")])
646 assert diff_symbol_trees(rec, rec) == []
647
648 def test_implementation_changed(self) -> None:
649 """Same signature, different body → ReplaceOp with 'implementation changed'."""
650 sig_id = _sha256("calc(x)->int")
651 base: SymbolTree = dict([self._func("m.py::calc", "old_body", body_hash="old", signature_id=sig_id, name="calc")])
652 target: SymbolTree = dict([self._func("m.py::calc", "new_body", body_hash="new", signature_id=sig_id, name="calc")])
653 ops = diff_symbol_trees(base, target)
654 assert len(ops) == 1
655 assert ops[0]["op"] == "replace"
656 assert "implementation changed" in ops[0]["new_summary"]
657
658 def test_signature_changed(self) -> None:
659 """Same body, different signature → ReplaceOp with 'signature changed'."""
660 body = _sha256("return x + 1")
661 base: SymbolTree = dict([self._func("m.py::f", "c1", body_hash=body, signature_id="old_sig", name="f")])
662 target: SymbolTree = dict([self._func("m.py::f", "c2", body_hash=body, signature_id="new_sig", name="f")])
663 ops = diff_symbol_trees(base, target)
664 assert len(ops) == 1
665 assert ops[0]["op"] == "replace"
666 assert "signature changed" in ops[0]["old_summary"]
667
668 def test_rename_detected(self) -> None:
669 """Same body_hash, different name/address → ReplaceOp with 'renamed to'."""
670 body = _sha256("return 42")
671 base: SymbolTree = dict([self._func("u.py::old_name", "old_cid", body_hash=body, name="old_name")])
672 target: SymbolTree = dict([self._func("u.py::new_name", "new_cid", body_hash=body, name="new_name")])
673 ops = diff_symbol_trees(base, target)
674 assert len(ops) == 1
675 assert ops[0]["op"] == "replace"
676 assert "renamed to" in ops[0]["new_summary"]
677 assert "new_name" in ops[0]["new_summary"]
678
679 def test_independent_changes_both_emitted(self) -> None:
680 """Different symbols changed independently → two ReplaceOps."""
681 sig_a = "sig_a"
682 sig_b = "sig_b"
683 base: SymbolTree = {
684 **dict([self._func("f.py::foo", "foo_old", body_hash="foo_b_old", signature_id=sig_a, name="foo")]),
685 **dict([self._func("f.py::bar", "bar_old", body_hash="bar_b_old", signature_id=sig_b, name="bar")]),
686 }
687 target: SymbolTree = {
688 **dict([self._func("f.py::foo", "foo_new", body_hash="foo_b_new", signature_id=sig_a, name="foo")]),
689 **dict([self._func("f.py::bar", "bar_new", body_hash="bar_b_new", signature_id=sig_b, name="bar")]),
690 }
691 ops = diff_symbol_trees(base, target)
692 assert len(ops) == 2
693 addrs = {o["address"] for o in ops}
694 assert "f.py::foo" in addrs
695 assert "f.py::bar" in addrs
696
697
698 # ---------------------------------------------------------------------------
699 # build_diff_ops — integration
700 # ---------------------------------------------------------------------------
701
702
703 class TestBuildDiffOps:
704 def test_added_file_no_tree(self) -> None:
705 ops = build_diff_ops(
706 base_files={},
707 target_files={"new.ts": "abc"},
708 base_trees={},
709 target_trees={},
710 )
711 assert len(ops) == 1
712 assert ops[0]["op"] == "insert"
713 assert ops[0]["address"] == "new.ts"
714
715 def test_removed_file_no_tree(self) -> None:
716 ops = build_diff_ops(
717 base_files={"old.ts": "abc"},
718 target_files={},
719 base_trees={},
720 target_trees={},
721 )
722 assert len(ops) == 1
723 assert ops[0]["op"] == "delete"
724
725 def test_modified_file_with_trees(self) -> None:
726 body = _sha256("return x")
727 base_tree: SymbolTree = {
728 "u.py::foo": SymbolRecord(
729 kind="function", name="foo", qualified_name="foo",
730 content_id="old_c", body_hash=body, signature_id="sig",
731 lineno=1, end_lineno=2,
732 )
733 }
734 target_tree: SymbolTree = {
735 "u.py::foo": SymbolRecord(
736 kind="function", name="foo", qualified_name="foo",
737 content_id="new_c", body_hash="new_body", signature_id="sig",
738 lineno=1, end_lineno=2,
739 )
740 }
741 ops = build_diff_ops(
742 base_files={"u.py": "base_hash"},
743 target_files={"u.py": "target_hash"},
744 base_trees={"u.py": base_tree},
745 target_trees={"u.py": target_tree},
746 )
747 assert len(ops) == 1
748 assert ops[0]["op"] == "patch"
749 assert ops[0]["address"] == "u.py"
750 assert len(ops[0]["child_ops"]) == 1
751 assert ops[0]["child_ops"][0]["op"] == "replace"
752
753 def test_reformat_only_produces_replace_op(self) -> None:
754 """When all symbol content_ids are unchanged, emit a reformatted ReplaceOp."""
755 content_id = _sha256("return x")
756 tree: SymbolTree = {
757 "u.py::foo": SymbolRecord(
758 kind="function", name="foo", qualified_name="foo",
759 content_id=content_id, body_hash=content_id, signature_id=content_id,
760 lineno=1, end_lineno=2,
761 )
762 }
763 ops = build_diff_ops(
764 base_files={"u.py": "hash_before"},
765 target_files={"u.py": "hash_after"},
766 base_trees={"u.py": tree},
767 target_trees={"u.py": tree}, # same tree → no symbol changes
768 )
769 assert len(ops) == 1
770 assert ops[0]["op"] == "replace"
771 assert "reformatted" in ops[0]["new_summary"]
772
773 def test_cross_file_move_annotation(self) -> None:
774 """A symbol deleted in file A and inserted in file B is annotated as moved."""
775 content_id = _sha256("the_body")
776 base_tree: SymbolTree = {
777 "a.py::helper": SymbolRecord(
778 kind="function", name="helper", qualified_name="helper",
779 content_id=content_id, body_hash=content_id, signature_id=content_id,
780 lineno=1, end_lineno=3,
781 )
782 }
783 target_tree: SymbolTree = {
784 "b.py::helper": SymbolRecord(
785 kind="function", name="helper", qualified_name="helper",
786 content_id=content_id, body_hash=content_id, signature_id=content_id,
787 lineno=1, end_lineno=3,
788 )
789 }
790 ops = build_diff_ops(
791 base_files={"a.py": "hash_a", "b.py": "hash_b_before"},
792 target_files={"b.py": "hash_b_after"},
793 base_trees={"a.py": base_tree},
794 target_trees={"b.py": target_tree},
795 )
796 # Find the patch ops.
797 patch_addrs = {o["address"] for o in ops if o["op"] == "patch"}
798 assert "a.py" in patch_addrs or "b.py" in patch_addrs
799
800
801 class TestFileMoveAndEdit:
802 """Regression: a file renamed+edited must be emitted as a single move+edit PatchOp.
803
804 Before the fix, Muse emitted an all-delete PatchOp for the old path and
805 an all-insert PatchOp for the new path — showing a spurious delete+add
806 rather than a move+edit. After the fix, the two are collapsed into a
807 single PatchOp carrying ``from_address`` and symbol-level child diffs.
808 """
809
810 def _func(
811 self,
812 addr: str,
813 content_id: str,
814 body_hash: str | None = None,
815 signature_id: str | None = None,
816 name: str = "f",
817 ) -> tuple[str, SymbolRecord]:
818 return addr, SymbolRecord(
819 kind="function",
820 name=name,
821 qualified_name=name,
822 content_id=content_id,
823 body_hash=body_hash or content_id,
824 signature_id=signature_id or content_id,
825 lineno=1,
826 end_lineno=3,
827 )
828
829 def test_move_and_edit_collapses_to_single_patch(self) -> None:
830 """File renamed utils.py→helpers.py with one symbol changed must emit one PatchOp."""
831 shared_body = _sha256("def unchanged(): pass")
832 base_tree: SymbolTree = {
833 "utils.py::unchanged": SymbolRecord(
834 kind="function", name="unchanged", qualified_name="unchanged",
835 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
836 lineno=1, end_lineno=2,
837 ),
838 "utils.py::modified": SymbolRecord(
839 kind="function", name="modified", qualified_name="modified",
840 content_id="old_cid", body_hash="old_body", signature_id="old_sig",
841 lineno=3, end_lineno=5,
842 ),
843 }
844 target_tree: SymbolTree = {
845 "helpers.py::unchanged": SymbolRecord(
846 kind="function", name="unchanged", qualified_name="unchanged",
847 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
848 lineno=1, end_lineno=2,
849 ),
850 "helpers.py::modified": SymbolRecord(
851 kind="function", name="modified", qualified_name="modified",
852 content_id="new_cid", body_hash="new_body", signature_id="new_sig",
853 lineno=3, end_lineno=5,
854 ),
855 }
856 ops = build_diff_ops(
857 base_files={"utils.py": "hash_old"},
858 target_files={"helpers.py": "hash_new"},
859 base_trees={"utils.py": base_tree},
860 target_trees={"helpers.py": target_tree},
861 )
862 assert len(ops) == 1, f"Expected 1 op, got {len(ops)}: {[o['op'] for o in ops]}"
863 assert ops[0]["op"] == "patch"
864 assert ops[0]["address"] == "helpers.py"
865 assert ops[0].get("from_address") == "utils.py"
866
867 def test_move_and_edit_child_ops_show_symbol_diff(self) -> None:
868 """Child ops of a move+edit PatchOp must reflect symbol-level changes only."""
869 shared_body = _sha256("def keep(): pass")
870 base_tree: SymbolTree = {
871 "a.py::keep": SymbolRecord(
872 kind="function", name="keep", qualified_name="keep",
873 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
874 lineno=1, end_lineno=2,
875 ),
876 "a.py::gone": SymbolRecord(
877 kind="function", name="gone", qualified_name="gone",
878 content_id="cid_gone", body_hash="body_gone", signature_id="sig_gone",
879 lineno=3, end_lineno=5,
880 ),
881 }
882 target_tree: SymbolTree = {
883 "b.py::keep": SymbolRecord(
884 kind="function", name="keep", qualified_name="keep",
885 content_id=shared_body, body_hash=shared_body, signature_id=shared_body,
886 lineno=1, end_lineno=2,
887 ),
888 "b.py::new_fn": SymbolRecord(
889 kind="function", name="new_fn", qualified_name="new_fn",
890 content_id="cid_new", body_hash="body_new", signature_id="sig_new",
891 lineno=3, end_lineno=5,
892 ),
893 }
894 ops = build_diff_ops(
895 base_files={"a.py": "hash_a"},
896 target_files={"b.py": "hash_b"},
897 base_trees={"a.py": base_tree},
898 target_trees={"b.py": target_tree},
899 )
900 assert len(ops) == 1
901 patch = ops[0]
902 assert patch["op"] == "patch"
903 child_op_types = {c["op"] for c in patch["child_ops"]}
904 # "gone" was deleted, "new_fn" was inserted; "keep" is unchanged → no op.
905 assert "delete" in child_op_types
906 assert "insert" in child_op_types
907
908 def test_no_false_positive_unrelated_files(self) -> None:
909 """Two files with no symbol overlap must NOT be collapsed into a move+edit."""
910 ops = build_diff_ops(
911 base_files={"old.py": "hash_old"},
912 target_files={"new.py": "hash_new"},
913 base_trees={
914 "old.py": {
915 "old.py::alpha": SymbolRecord(
916 kind="function", name="alpha", qualified_name="alpha",
917 content_id="cid_a", body_hash="body_a", signature_id="sig_a",
918 lineno=1, end_lineno=2,
919 )
920 }
921 },
922 target_trees={
923 "new.py": {
924 "new.py::omega": SymbolRecord(
925 kind="function", name="omega", qualified_name="omega",
926 content_id="cid_o", body_hash="body_o", signature_id="sig_o",
927 lineno=1, end_lineno=2,
928 )
929 }
930 },
931 )
932 # No overlap → separate delete + insert ops, NOT a move+edit.
933 assert len(ops) == 2
934 op_types = {o["op"] for o in ops}
935 assert op_types == {"patch"} # Both are PatchOps wrapping single-symbol trees.
936 for op in ops:
937 assert op.get("from_address") is None
938
939
940 # ---------------------------------------------------------------------------
941 # CodePlugin — snapshot
942 # ---------------------------------------------------------------------------
943
944
945 class TestCodePluginSnapshot:
946 plugin = CodePlugin()
947
948 def test_path_returns_manifest(self, tmp_path: pathlib.Path) -> None:
949 workdir = tmp_path
950 (workdir / "app.py").write_text("x = 1\n")
951 snap = self.plugin.snapshot(workdir)
952 assert snap["domain"] == "code"
953 assert "app.py" in snap["files"]
954
955 def test_snapshot_stability(self, tmp_path: pathlib.Path) -> None:
956 workdir = tmp_path
957 (workdir / "main.py").write_text("def f(): pass\n")
958 s1 = self.plugin.snapshot(workdir)
959 s2 = self.plugin.snapshot(workdir)
960 assert s1 == s2
961
962 def test_snapshot_uses_raw_bytes_hash(self, tmp_path: pathlib.Path) -> None:
963 workdir = tmp_path
964 content = b"def add(a, b): return a + b\n"
965 (workdir / "math.py").write_bytes(content)
966 snap = self.plugin.snapshot(workdir)
967 expected = _sha256_bytes(content)
968 assert snap["files"]["math.py"] == expected
969
970 def test_museignore_respected(self, tmp_path: pathlib.Path) -> None:
971 workdir = tmp_path
972 (workdir / "keep.py").write_text("x = 1\n")
973 (workdir / "skip.log").write_text("log\n")
974 ignore = tmp_path / ".museignore"
975 ignore.write_text('[global]\npatterns = ["*.log"]\n')
976 snap = self.plugin.snapshot(workdir)
977 assert "keep.py" in snap["files"]
978 assert "skip.log" not in snap["files"]
979
980 def test_pycache_always_ignored(self, tmp_path: pathlib.Path) -> None:
981 workdir = tmp_path
982 cache = workdir / "__pycache__"
983 cache.mkdir()
984 (cache / "utils.cpython-312.pyc").write_bytes(b"\x00")
985 (workdir / "main.py").write_text("x = 1\n")
986 snap = self.plugin.snapshot(workdir)
987 assert "main.py" in snap["files"]
988 assert not any("__pycache__" in k for k in snap["files"])
989
990 def test_nested_files_tracked(self, tmp_path: pathlib.Path) -> None:
991 workdir = tmp_path
992 (workdir / "src").mkdir(parents=True)
993 (workdir / "src" / "utils.py").write_text("pass\n")
994 snap = self.plugin.snapshot(workdir)
995 assert "src/utils.py" in snap["files"]
996
997 def test_manifest_passthrough(self) -> None:
998 manifest = _make_manifest({"a.py": "hash"})
999 result = self.plugin.snapshot(manifest)
1000 assert result is manifest
1001
1002
1003 # ---------------------------------------------------------------------------
1004 # CodePlugin — diff (file-level, no repo_root)
1005 # ---------------------------------------------------------------------------
1006
1007
1008 class TestCodePluginDiffFileLevel:
1009 plugin = CodePlugin()
1010
1011 def test_added_file(self) -> None:
1012 base = _make_manifest({})
1013 target = _make_manifest({"new.py": "abc"})
1014 delta = self.plugin.diff(base, target)
1015 assert len(delta["ops"]) == 1
1016 assert delta["ops"][0]["op"] == "insert"
1017
1018 def test_removed_file(self) -> None:
1019 base = _make_manifest({"old.py": "abc"})
1020 target = _make_manifest({})
1021 delta = self.plugin.diff(base, target)
1022 assert len(delta["ops"]) == 1
1023 assert delta["ops"][0]["op"] == "delete"
1024
1025 def test_modified_file(self) -> None:
1026 base = _make_manifest({"f.py": "old"})
1027 target = _make_manifest({"f.py": "new"})
1028 delta = self.plugin.diff(base, target)
1029 assert len(delta["ops"]) == 1
1030 assert delta["ops"][0]["op"] == "replace"
1031
1032 def test_no_changes_empty_ops(self) -> None:
1033 snap = _make_manifest({"f.py": "abc"})
1034 delta = self.plugin.diff(snap, snap)
1035 assert delta["ops"] == []
1036 assert delta["summary"] == "no changes"
1037
1038 def test_domain_is_code(self) -> None:
1039 delta = self.plugin.diff(_make_manifest({}), _make_manifest({}))
1040 assert delta["domain"] == "code"
1041
1042
1043 # ---------------------------------------------------------------------------
1044 # CodePlugin — diff (semantic, with repo_root)
1045 # ---------------------------------------------------------------------------
1046
1047
1048 class TestCodePluginDiffSemantic:
1049 plugin = CodePlugin()
1050
1051 def _setup_repo(
1052 self, tmp_path: pathlib.Path
1053 ) -> tuple[pathlib.Path, pathlib.Path]:
1054 repo_root = tmp_path / "repo"
1055 repo_root.mkdir()
1056 workdir = repo_root
1057 return repo_root, workdir
1058
1059 def test_add_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
1060 repo_root, _ = self._setup_repo(tmp_path)
1061 base_src = _src("x = 1\n")
1062 target_src = _src("x = 1\n\ndef greet(name: str) -> str:\n return f'Hello {name}'\n")
1063
1064 base_oid = _store_blob(repo_root, base_src)
1065 target_oid = _store_blob(repo_root, target_src)
1066
1067 base = _make_manifest({"hello.py": base_oid})
1068 target = _make_manifest({"hello.py": target_oid})
1069 delta = self.plugin.diff(base, target, repo_root=repo_root)
1070
1071 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1072 assert len(patch_ops) == 1
1073 assert patch_ops[0]["address"] == "hello.py"
1074 child_ops = patch_ops[0]["child_ops"]
1075 assert any(c["op"] == "insert" and "greet" in c.get("content_summary", "") for c in child_ops)
1076
1077 def test_remove_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
1078 repo_root, _ = self._setup_repo(tmp_path)
1079 base_src = _src("def old_fn() -> None:\n pass\n")
1080 target_src = _src("# removed\n")
1081
1082 base_oid = _store_blob(repo_root, base_src)
1083 target_oid = _store_blob(repo_root, target_src)
1084
1085 base = _make_manifest({"mod.py": base_oid})
1086 target = _make_manifest({"mod.py": target_oid})
1087 delta = self.plugin.diff(base, target, repo_root=repo_root)
1088
1089 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1090 assert len(patch_ops) == 1
1091 child_ops = patch_ops[0]["child_ops"]
1092 assert any(c["op"] == "delete" and "old_fn" in c.get("content_summary", "") for c in child_ops)
1093
1094 def test_rename_function_detected(self, tmp_path: pathlib.Path) -> None:
1095 repo_root, _ = self._setup_repo(tmp_path)
1096 base_src = _src("def compute(x: int) -> int:\n return x * 2\n")
1097 target_src = _src("def calculate(x: int) -> int:\n return x * 2\n")
1098
1099 base_oid = _store_blob(repo_root, base_src)
1100 target_oid = _store_blob(repo_root, target_src)
1101
1102 base = _make_manifest({"ops.py": base_oid})
1103 target = _make_manifest({"ops.py": target_oid})
1104 delta = self.plugin.diff(base, target, repo_root=repo_root)
1105
1106 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1107 assert len(patch_ops) == 1
1108 child_ops = patch_ops[0]["child_ops"]
1109 rename_ops = [
1110 c for c in child_ops
1111 if c["op"] == "replace" and "renamed to" in c.get("new_summary", "")
1112 ]
1113 assert len(rename_ops) == 1
1114 assert "calculate" in rename_ops[0]["new_summary"]
1115
1116 def test_implementation_change_detected(self, tmp_path: pathlib.Path) -> None:
1117 repo_root, _ = self._setup_repo(tmp_path)
1118 base_src = _src("def double(x: int) -> int:\n return x * 2\n")
1119 target_src = _src("def double(x: int) -> int:\n return x + x\n")
1120
1121 base_oid = _store_blob(repo_root, base_src)
1122 target_oid = _store_blob(repo_root, target_src)
1123
1124 base = _make_manifest({"math.py": base_oid})
1125 target = _make_manifest({"math.py": target_oid})
1126 delta = self.plugin.diff(base, target, repo_root=repo_root)
1127
1128 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1129 child_ops = patch_ops[0]["child_ops"]
1130 impl_ops = [c for c in child_ops if "implementation changed" in c.get("new_summary", "")]
1131 assert len(impl_ops) == 1
1132
1133 def test_reformat_only_produces_replace_with_reformatted(
1134 self, tmp_path: pathlib.Path
1135 ) -> None:
1136 repo_root, _ = self._setup_repo(tmp_path)
1137 base_src = _src("def add(a,b):\n return a+b\n")
1138 # Same semantics, different formatting — ast.unparse normalizes both.
1139 target_src = _src("def add(a, b):\n return a + b\n")
1140
1141 base_oid = _store_blob(repo_root, base_src)
1142 target_oid = _store_blob(repo_root, target_src)
1143
1144 base = _make_manifest({"f.py": base_oid})
1145 target = _make_manifest({"f.py": target_oid})
1146 delta = self.plugin.diff(base, target, repo_root=repo_root)
1147
1148 # The diff should produce a reformatted ReplaceOp rather than a PatchOp.
1149 replace_ops = [o for o in delta["ops"] if o["op"] == "replace"]
1150 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
1151 # Reformatting: either zero ops (if raw hashes are identical) or a
1152 # reformatted replace (if raw hashes differ but symbols unchanged).
1153 if delta["ops"]:
1154 assert replace_ops or patch_ops # something was emitted
1155 if replace_ops:
1156 assert any("reformatted" in o.get("new_summary", "") for o in replace_ops)
1157
1158 def test_missing_object_falls_back_to_file_level(
1159 self, tmp_path: pathlib.Path
1160 ) -> None:
1161 repo_root, _ = self._setup_repo(tmp_path)
1162 # Objects NOT written to store — should fall back gracefully.
1163 base = _make_manifest({"f.py": "deadbeef" * 8})
1164 target = _make_manifest({"f.py": "cafebabe" * 8})
1165 delta = self.plugin.diff(base, target, repo_root=repo_root)
1166 assert len(delta["ops"]) == 1
1167 assert delta["ops"][0]["op"] == "replace"
1168
1169
1170 # ---------------------------------------------------------------------------
1171 # CodePlugin — merge
1172 # ---------------------------------------------------------------------------
1173
1174
1175 class TestCodePluginMerge:
1176 plugin = CodePlugin()
1177
1178 def test_only_one_side_changed(self) -> None:
1179 base = _make_manifest({"f.py": "v1"})
1180 left = _make_manifest({"f.py": "v1"})
1181 right = _make_manifest({"f.py": "v2"})
1182 result = self.plugin.merge(base, left, right)
1183 assert result.is_clean
1184 assert result.merged["files"]["f.py"] == "v2"
1185
1186 def test_both_sides_same_change(self) -> None:
1187 base = _make_manifest({"f.py": "v1"})
1188 left = _make_manifest({"f.py": "v2"})
1189 right = _make_manifest({"f.py": "v2"})
1190 result = self.plugin.merge(base, left, right)
1191 assert result.is_clean
1192 assert result.merged["files"]["f.py"] == "v2"
1193
1194 def test_conflict_when_both_sides_differ(self) -> None:
1195 base = _make_manifest({"f.py": "v1"})
1196 left = _make_manifest({"f.py": "v2"})
1197 right = _make_manifest({"f.py": "v3"})
1198 result = self.plugin.merge(base, left, right)
1199 assert not result.is_clean
1200 assert "f.py" in result.conflicts
1201
1202 def test_disjoint_additions_auto_merge(self) -> None:
1203 base = _make_manifest({})
1204 left = _make_manifest({"a.py": "hash_a"})
1205 right = _make_manifest({"b.py": "hash_b"})
1206 result = self.plugin.merge(base, left, right)
1207 assert result.is_clean
1208 assert "a.py" in result.merged["files"]
1209 assert "b.py" in result.merged["files"]
1210
1211 def test_deletion_on_one_side(self) -> None:
1212 base = _make_manifest({"f.py": "v1"})
1213 left = _make_manifest({})
1214 right = _make_manifest({"f.py": "v1"})
1215 result = self.plugin.merge(base, left, right)
1216 assert result.is_clean
1217 assert "f.py" not in result.merged["files"]
1218
1219
1220 # ---------------------------------------------------------------------------
1221 # CodePlugin — merge_ops (symbol-level OT)
1222 # ---------------------------------------------------------------------------
1223
1224
1225 class TestCodePluginMergeOps:
1226 plugin = CodePlugin()
1227
1228 def _py_snap(self, file_path: str, src: bytes, repo_root: pathlib.Path) -> SnapshotManifest:
1229 oid = _store_blob(repo_root, src)
1230 return _make_manifest({file_path: oid})
1231
1232 def test_different_symbols_same_file_conflict(self, tmp_path: pathlib.Path) -> None:
1233 """Two agents modify different functions in the same file → file-level conflict.
1234
1235 The OT engine correctly identifies that the individual symbol edits commute
1236 (different addresses), but Muse cannot reconstruct the merged file blob
1237 without a text-merge pass. Silently returning "ours" would discard the
1238 other branch's changes, so merge_ops propagates the file-level conflict
1239 from the fallback merge() and surfaces it to the user for manual resolution.
1240 """
1241 repo_root = tmp_path / "repo"
1242 repo_root.mkdir()
1243
1244 base_src = _src("""\
1245 def foo(x: int) -> int:
1246 return x
1247
1248 def bar(y: int) -> int:
1249 return y
1250 """)
1251 # Ours: modify foo.
1252 ours_src = _src("""\
1253 def foo(x: int) -> int:
1254 return x * 2
1255
1256 def bar(y: int) -> int:
1257 return y
1258 """)
1259 # Theirs: modify bar.
1260 theirs_src = _src("""\
1261 def foo(x: int) -> int:
1262 return x
1263
1264 def bar(y: int) -> int:
1265 return y + 1
1266 """)
1267
1268 base_snap = self._py_snap("m.py", base_src, repo_root)
1269 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1270 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1271
1272 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1273 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1274
1275 result = self.plugin.merge_ops(
1276 base_snap,
1277 ours_snap,
1278 theirs_snap,
1279 ours_delta["ops"],
1280 theirs_delta["ops"],
1281 repo_root=repo_root,
1282 )
1283 # The OT check says ops commute, but the blobs differ on both sides.
1284 # Without text-merge, accepting silently would discard "theirs" changes
1285 # to bar(). merge_ops must propagate the file-level conflict instead.
1286 assert not result.is_clean, "Expected file-level conflict for same-file concurrent edits"
1287 assert "m.py" in result.conflicts
1288
1289 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1290 """Both agents modify the same function → conflict at symbol address."""
1291 repo_root = tmp_path / "repo"
1292 repo_root.mkdir()
1293
1294 base_src = _src("def calc(x: int) -> int:\n return x\n")
1295 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1296 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1297
1298 base_snap = self._py_snap("calc.py", base_src, repo_root)
1299 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1300 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1301
1302 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1303 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1304
1305 result = self.plugin.merge_ops(
1306 base_snap,
1307 ours_snap,
1308 theirs_snap,
1309 ours_delta["ops"],
1310 theirs_delta["ops"],
1311 repo_root=repo_root,
1312 )
1313 assert not result.is_clean
1314 # Conflict should be at file or symbol level.
1315 assert len(result.conflicts) > 0
1316
1317 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1318 """Agents modify completely different files → auto-merge."""
1319 repo_root = tmp_path / "repo"
1320 repo_root.mkdir()
1321
1322 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1323 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1324 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1325
1326 ours_delta = self.plugin.diff(base, ours)
1327 theirs_delta = self.plugin.diff(base, theirs)
1328
1329 result = self.plugin.merge_ops(
1330 base, ours, theirs,
1331 ours_delta["ops"],
1332 theirs_delta["ops"],
1333 )
1334 assert result.is_clean
1335
1336
1337 # ---------------------------------------------------------------------------
1338 # merge_ops conflict-propagation regression tests
1339 # ---------------------------------------------------------------------------
1340
1341
1342 class TestMergeOpsConflictPropagation:
1343 """Regression tests for the merge_ops conflict-propagation bug.
1344
1345 Before the fix, merge_ops silently used the "ours" blob when the OT check
1346 missed a conflict — either because of mixed op types (one side ReplaceOp,
1347 other side PatchOp) or because symbol-level ops commuted while the file
1348 blobs still differed. Both cases produced wrong merged content without
1349 flagging a conflict.
1350
1351 After the fix, merge_ops propagates file-level conflicts from the fallback
1352 merge() unless the path was already auto-resolved by a .museattributes
1353 strategy. See: muse/plugins/code/plugin.py::CodePlugin.merge_ops Step 4.
1354 """
1355
1356 plugin = CodePlugin()
1357
1358 # ------------------------------------------------------------------
1359 # Scenario 1: Completely different file versions — both sides changed
1360 # the entire content of a non-code file (e.g. AGENTS.md regression).
1361 # ------------------------------------------------------------------
1362
1363 def test_commuting_symbol_changes_same_file_is_conflict(
1364 self, tmp_path: pathlib.Path
1365 ) -> None:
1366 """Both branches modify different sections → OT says commute, but file-level conflict.
1367
1368 This is the exact scenario that caused the AGENTS.md regression:
1369 - merge base has Section A
1370 - ours modifies Section A (different content, same heading)
1371 - theirs adds Section B (new heading not in base or ours)
1372
1373 The OT check sees ReplaceOp("AGENTS.md::Project.Section A") vs
1374 InsertOp("AGENTS.md::Project.Section B") — different addresses → they commute.
1375 OT declares a clean merge, but the merged blob is just "ours" (Section A
1376 updated, Section B absent), silently discarding theirs' new section.
1377
1378 After the fix, merge_ops propagates the file-level conflict from the
1379 fallback merge() so the user is told to resolve it manually.
1380 """
1381 repo_root = tmp_path / "repo"
1382 repo_root.mkdir()
1383
1384 # Base: one section.
1385 base_content = b"# Project\n\n## Section A\n\nOriginal content.\n"
1386 # Ours: modified Section A (different text but same heading).
1387 ours_content = b"# Project\n\n## Section A\n\nOurs rewrote Section A.\n"
1388 # Theirs: Section A unchanged + added Section B.
1389 theirs_content = (
1390 b"# Project\n\n## Section A\n\nOriginal content.\n\n"
1391 b"## Section B\n\nTheirs added this new section.\n"
1392 )
1393
1394 base_oid = _store_blob(repo_root, base_content)
1395 ours_oid = _store_blob(repo_root, ours_content)
1396 theirs_oid = _store_blob(repo_root, theirs_content)
1397
1398 base_snap = _make_manifest({"AGENTS.md": base_oid})
1399 ours_snap = _make_manifest({"AGENTS.md": ours_oid})
1400 theirs_snap = _make_manifest({"AGENTS.md": theirs_oid})
1401
1402 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1403 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1404
1405 result = self.plugin.merge_ops(
1406 base_snap, ours_snap, theirs_snap,
1407 ours_delta["ops"], theirs_delta["ops"],
1408 repo_root=repo_root,
1409 )
1410 # OT sees commuting ops (different symbol addresses), but the merged file
1411 # blob would silently be "ours" — theirs' Section B would be dropped.
1412 # merge_ops must surface the file-level conflict.
1413 assert not result.is_clean, (
1414 "Commuting ops on same file — expected file-level conflict to be propagated, "
1415 f"got is_clean=True. Conflicts: {result.conflicts}. "
1416 "AGENTS.md :: Section B from 'theirs' would be silently discarded."
1417 )
1418 conflict_files = {c.split("::")[0] for c in result.conflicts}
1419 assert "AGENTS.md" in conflict_files, (
1420 f"Expected 'AGENTS.md' in conflict file paths, got: {result.conflicts}"
1421 )
1422
1423 # ------------------------------------------------------------------
1424 # Scenario 2: Mixed op types — one side ReplaceOp, other PatchOp.
1425 # ------------------------------------------------------------------
1426
1427 def test_mixed_op_types_is_conflict(self, tmp_path: pathlib.Path) -> None:
1428 """One side has ReplaceOp (no symbol tree), other has PatchOp → conflict.
1429
1430 If the file has no parseable symbols on one branch (e.g. a plain text
1431 file where one branch added a heading and the other didn't), the diff
1432 produces a ReplaceOp on the no-heading side and a PatchOp on the
1433 heading side. They never appear together in the OT conflict loops,
1434 so the OT check sees no conflict — but the blobs differ on both sides.
1435 """
1436 repo_root = tmp_path / "repo"
1437 repo_root.mkdir()
1438
1439 # Base: plain text, no Markdown headings → no symbol tree.
1440 base_content = b"version = 1\n"
1441 # Ours: still no heading → ReplaceOp at file level.
1442 ours_content = b"version = 1-hotfix\n"
1443 # Theirs: added a heading → PatchOp with symbol child.
1444 theirs_content = b"version = 2\n\n# comprehensive update\n"
1445
1446 base_oid = _store_blob(repo_root, base_content)
1447 ours_oid = _store_blob(repo_root, ours_content)
1448 theirs_oid = _store_blob(repo_root, theirs_content)
1449
1450 base_snap = _make_manifest({"config.txt": base_oid})
1451 ours_snap = _make_manifest({"config.txt": ours_oid})
1452 theirs_snap = _make_manifest({"config.txt": theirs_oid})
1453
1454 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1455 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1456
1457 result = self.plugin.merge_ops(
1458 base_snap, ours_snap, theirs_snap,
1459 ours_delta["ops"], theirs_delta["ops"],
1460 repo_root=repo_root,
1461 )
1462 assert not result.is_clean, (
1463 "Mixed op types (ReplaceOp ours, PatchOp theirs) for config.txt — "
1464 f"expected conflict, got is_clean=True. "
1465 f"Ours ops: {ours_delta['ops']}. Theirs ops: {theirs_delta['ops']}."
1466 )
1467 assert "config.txt" in result.conflicts
1468
1469 # ------------------------------------------------------------------
1470 # Scenario 3: Only one side changed the file → clean merge (no regression).
1471 # ------------------------------------------------------------------
1472
1473 def test_only_ours_changed_is_clean(self, tmp_path: pathlib.Path) -> None:
1474 """Only our branch changed a text file → theirs is base → clean merge."""
1475 repo_root = tmp_path / "repo"
1476 repo_root.mkdir()
1477
1478 base_content = b"# Docs\n\nOriginal.\n"
1479 ours_content = b"# Docs\n\nOurs update.\n"
1480
1481 base_oid = _store_blob(repo_root, base_content)
1482 ours_oid = _store_blob(repo_root, ours_content)
1483
1484 base_snap = _make_manifest({"README.md": base_oid})
1485 ours_snap = _make_manifest({"README.md": ours_oid})
1486 theirs_snap = base_snap # theirs unchanged
1487
1488 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1489 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1490
1491 result = self.plugin.merge_ops(
1492 base_snap, ours_snap, theirs_snap,
1493 ours_delta["ops"], theirs_delta["ops"],
1494 repo_root=repo_root,
1495 )
1496 assert result.is_clean, f"Only ours changed — should auto-merge, got: {result.conflicts}"
1497
1498 # ------------------------------------------------------------------
1499 # Scenario 4: Completely disjoint files → clean merge (no regression).
1500 # ------------------------------------------------------------------
1501
1502 def test_disjoint_files_remain_clean(self, tmp_path: pathlib.Path) -> None:
1503 """Each branch changed a different file entirely → clean merge."""
1504 repo_root = tmp_path / "repo"
1505 repo_root.mkdir()
1506
1507 base_a = b"# File A\n\nOriginal.\n"
1508 base_b = b"# File B\n\nOriginal.\n"
1509 ours_a = b"# File A\n\nOurs update.\n"
1510
1511 base_a_oid = _store_blob(repo_root, base_a)
1512 base_b_oid = _store_blob(repo_root, base_b)
1513 ours_a_oid = _store_blob(repo_root, ours_a)
1514 theirs_b_oid = _store_blob(repo_root, b"# File B\n\nTheirs update.\n")
1515
1516 base_snap = _make_manifest({"a.md": base_a_oid, "b.md": base_b_oid})
1517 ours_snap = _make_manifest({"a.md": ours_a_oid, "b.md": base_b_oid})
1518 theirs_snap = _make_manifest({"a.md": base_a_oid, "b.md": theirs_b_oid})
1519
1520 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1521 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1522
1523 result = self.plugin.merge_ops(
1524 base_snap, ours_snap, theirs_snap,
1525 ours_delta["ops"], theirs_delta["ops"],
1526 repo_root=repo_root,
1527 )
1528 assert result.is_clean, f"Disjoint files — should auto-merge, got: {result.conflicts}"
1529
1530
1531 # ---------------------------------------------------------------------------
1532 # CodePlugin — drift
1533 # ---------------------------------------------------------------------------
1534
1535
1536 class TestCodePluginDrift:
1537 plugin = CodePlugin()
1538
1539 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1540 workdir = tmp_path
1541 (workdir / "app.py").write_text("x = 1\n")
1542 snap = self.plugin.snapshot(workdir)
1543 report = self.plugin.drift(snap, workdir)
1544 assert not report.has_drift
1545
1546 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1547 workdir = tmp_path
1548 f = workdir / "app.py"
1549 f.write_text("x = 1\n")
1550 snap = self.plugin.snapshot(workdir)
1551 f.write_text("x = 2\n")
1552 report = self.plugin.drift(snap, workdir)
1553 assert report.has_drift
1554
1555 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1556 workdir = tmp_path
1557 (workdir / "a.py").write_text("a = 1\n")
1558 snap = self.plugin.snapshot(workdir)
1559 (workdir / "b.py").write_text("b = 2\n")
1560 report = self.plugin.drift(snap, workdir)
1561 assert report.has_drift
1562
1563 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1564 workdir = tmp_path
1565 f = workdir / "gone.py"
1566 f.write_text("x = 1\n")
1567 snap = self.plugin.snapshot(workdir)
1568 f.unlink()
1569 report = self.plugin.drift(snap, workdir)
1570 assert report.has_drift
1571
1572 def test_ignored_extant_file_not_in_drift(self, tmp_path: pathlib.Path) -> None:
1573 """A file that was committed, added to .museignore, and still exists on
1574 disk must not appear as deleted in the drift report.
1575
1576 This is the canonical regression test for the bug where build artifacts
1577 (e.g. app.js, app.css) added to .museignore while still present on disk
1578 caused muse status to show them as deleted and blocked muse checkout."""
1579 workdir = tmp_path
1580 (workdir / "src.py").write_text("x = 1\n")
1581 (workdir / "app.js").write_text("// build output\n")
1582 # Include .museignore in the initial snapshot so adding it later
1583 # does not itself register as drift — isolates the variable under test.
1584 (workdir / ".museignore").write_text(
1585 '[global]\npatterns = ["app.js"]\n', encoding="utf-8"
1586 )
1587 # Snapshot with both src.py and .museignore already committed, but
1588 # app.js is also tracked (HEAD committed it before .museignore was in effect).
1589 # Re-read it without .museignore filtering by building manifest directly.
1590 from muse.core.snapshot import hash_file
1591 snap_files = {
1592 "src.py": hash_file(workdir / "src.py"),
1593 "app.js": hash_file(workdir / "app.js"),
1594 ".museignore": hash_file(workdir / ".museignore"),
1595 }
1596 from muse.domain import SnapshotManifest
1597 snap = SnapshotManifest(files=snap_files, domain="code", directories=[])
1598 # app.js still exists on disk — not deleted, just now ignored.
1599 report = self.plugin.drift(snap, workdir)
1600 deleted_addresses = {
1601 op["address"]
1602 for op in report.delta.get("ops", [])
1603 if op.get("op") == "delete"
1604 }
1605 assert "app.js" not in deleted_addresses, (
1606 "ignored-and-extant file must not appear as deleted in drift"
1607 )
1608 assert not report.has_drift, (
1609 "drift must be clean when only ignored-and-extant files differ"
1610 )
1611
1612 def test_truly_deleted_ignored_file_still_in_drift(self, tmp_path: pathlib.Path) -> None:
1613 """A file that is in .museignore AND genuinely absent from disk IS
1614 deleted and must appear in the drift report."""
1615 workdir = tmp_path
1616 (workdir / "src.py").write_text("x = 1\n")
1617 (workdir / "app.js").write_text("// build output\n")
1618 snap = self.plugin.snapshot(workdir)
1619 # Add to .museignore AND delete from disk — this is a real deletion.
1620 (workdir / ".museignore").write_text(
1621 '[global]\npatterns = ["app.js"]\n', encoding="utf-8"
1622 )
1623 (workdir / "app.js").unlink()
1624 report = self.plugin.drift(snap, workdir)
1625 deleted_addresses = {
1626 op["address"]
1627 for op in report.delta.get("ops", [])
1628 if op.get("op") == "delete"
1629 }
1630 assert "app.js" in deleted_addresses, (
1631 "a file in .museignore that is genuinely absent from disk must still be deleted"
1632 )
1633
1634
1635 # ---------------------------------------------------------------------------
1636 # CodePlugin — apply (passthrough)
1637 # ---------------------------------------------------------------------------
1638
1639
1640 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1641 plugin = CodePlugin()
1642 workdir = tmp_path
1643 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1644 result = plugin.apply(delta, workdir)
1645 assert result is workdir
1646
1647
1648 # ---------------------------------------------------------------------------
1649 # CodePlugin — schema
1650 # ---------------------------------------------------------------------------
1651
1652
1653 class TestCodePluginSchema:
1654 plugin = CodePlugin()
1655
1656 def test_schema_domain(self) -> None:
1657 assert self.plugin.schema()["domain"] == "code"
1658
1659 def test_schema_merge_mode(self) -> None:
1660 assert self.plugin.schema()["merge_mode"] == "three_way"
1661
1662 def test_schema_version(self) -> None:
1663 assert self.plugin.schema()["schema_version"] == __version__
1664
1665 def test_schema_dimensions(self) -> None:
1666 dims = self.plugin.schema()["dimensions"]
1667 names = {d["name"] for d in dims}
1668 assert "structure" in names
1669 assert "symbols" in names
1670 assert "imports" in names
1671
1672 def test_schema_top_level_is_tree(self) -> None:
1673 top = self.plugin.schema()["top_level"]
1674 assert top["kind"] == "tree"
1675
1676 def test_schema_description_non_empty(self) -> None:
1677 assert len(self.plugin.schema()["description"]) > 0
1678
1679
1680 # ---------------------------------------------------------------------------
1681 # delta_summary
1682 # ---------------------------------------------------------------------------
1683
1684
1685 class TestDeltaSummary:
1686 def test_empty_ops(self) -> None:
1687 assert delta_summary([]) == "no changes"
1688
1689 def test_file_added(self) -> None:
1690 from muse.domain import DomainOp
1691 ops: list[DomainOp] = [InsertOp(
1692 op="insert", address="f.py", position=None,
1693 content_id="abc", content_summary="added f.py",
1694 )]
1695 summary = delta_summary(ops)
1696 assert "added" in summary
1697 assert "file" in summary
1698
1699 def test_symbols_counted_from_patch(self) -> None:
1700 from muse.domain import DomainOp, PatchOp
1701 child: list[DomainOp] = [
1702 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1703 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1704 ]
1705 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1706 summary = delta_summary(ops)
1707 assert "symbol" in summary
1708
1709
1710 # ---------------------------------------------------------------------------
1711 # Markdown adapter
1712 # ---------------------------------------------------------------------------
1713
1714
1715 class TestMarkdownAdapter:
1716 """Semantic symbol extraction via tree-sitter-markdown."""
1717
1718 def _parse(self, src: str) -> SymbolTree:
1719 from muse.plugins.code.ast_parser import MarkdownAdapter
1720 adapter = MarkdownAdapter()
1721 if adapter._parser is None:
1722 pytest.skip("tree-sitter-markdown not available")
1723 return adapter.parse_symbols(src.encode(), "README.md")
1724
1725 def test_h1_extracted(self) -> None:
1726 syms = self._parse("# Hello World\n")
1727 assert any("Hello World" in k for k in syms), f"keys: {list(syms)}"
1728
1729 def test_h2_extracted(self) -> None:
1730 syms = self._parse("# Title\n\n## Section Two\n")
1731 assert any("Section Two" in k for k in syms)
1732
1733 def test_multiple_headings(self) -> None:
1734 src = "# Top\n\n## Alpha\n\n## Beta\n\n### Deep\n"
1735 syms = self._parse(src)
1736 kinds = {r["kind"] for r in syms.values()}
1737 assert "section" in kinds
1738 assert len(syms) >= 4
1739
1740 def test_section_lineno(self) -> None:
1741 src = "# First\n\n## Second\n"
1742 syms = self._parse(src)
1743 second = next((r for r in syms.values() if "Second" in r["name"]), None)
1744 assert second is not None
1745 assert second["lineno"] == 3
1746
1747 def test_content_id_changes_with_text(self) -> None:
1748 s1 = self._parse("# Hello\n")
1749 s2 = self._parse("# World\n")
1750 ids1 = {r["content_id"] for r in s1.values()}
1751 ids2 = {r["content_id"] for r in s2.values()}
1752 assert ids1 != ids2
1753
1754 def test_adapter_for_path_md(self) -> None:
1755 from muse.plugins.code.ast_parser import MarkdownAdapter
1756 adapter = adapter_for_path("docs/README.md")
1757 assert isinstance(adapter, MarkdownAdapter)
1758
1759 def test_adapter_for_path_rst(self) -> None:
1760 from muse.plugins.code.ast_parser import MarkdownAdapter
1761 adapter = adapter_for_path("notes.rst")
1762 assert isinstance(adapter, MarkdownAdapter)
1763
1764
1765 # ---------------------------------------------------------------------------
1766 # HTML adapter
1767 # ---------------------------------------------------------------------------
1768
1769
1770 class TestHtmlAdapter:
1771 """Semantic element and id-bearing element extraction via tree-sitter-html."""
1772
1773 def _parse(self, src: str) -> SymbolTree:
1774 from muse.plugins.code.ast_parser import HtmlAdapter
1775 adapter = HtmlAdapter()
1776 if adapter._parser is None:
1777 pytest.skip("tree-sitter-html not available")
1778 return adapter.parse_symbols(src.encode(), "index.html")
1779
1780 # ------------------------------------------------------------------
1781 # id attribute — highest priority name source
1782 # ------------------------------------------------------------------
1783
1784 def test_id_bearing_div_extracted(self) -> None:
1785 syms = self._parse('<html><body><div id="hero">x</div></body></html>')
1786 assert any("div#hero" in k for k in syms), f"keys: {list(syms)}"
1787
1788 def test_id_name_format(self) -> None:
1789 syms = self._parse('<section id="intro">content</section>')
1790 assert any("section#intro" in k for k in syms)
1791
1792 def test_multiple_ids(self) -> None:
1793 src = '<section id="intro">a</section><section id="outro">b</section>'
1794 syms = self._parse(src)
1795 assert any("section#intro" in k for k in syms)
1796 assert any("section#outro" in k for k in syms)
1797
1798 # ------------------------------------------------------------------
1799 # aria-label — second priority
1800 # ------------------------------------------------------------------
1801
1802 def test_aria_label_nav(self) -> None:
1803 syms = self._parse('<nav aria-label="Primary Navigation"><ul></ul></nav>')
1804 assert any("nav[Primary Navigation]" in k for k in syms), f"keys: {list(syms)}"
1805
1806 def test_aria_label_beats_lineno(self) -> None:
1807 syms = self._parse('<main aria-label="Content"><p>text</p></main>')
1808 assert any("main[Content]" in k for k in syms)
1809 assert not any("@" in k for k in syms), f"lineno leaked: {list(syms)}"
1810
1811 # ------------------------------------------------------------------
1812 # name attribute — form / fieldset / slot / input
1813 # ------------------------------------------------------------------
1814
1815 def test_form_name_attr(self) -> None:
1816 syms = self._parse('<form name="login"><input></form>')
1817 assert any("form[login]" in k for k in syms), f"keys: {list(syms)}"
1818
1819 def test_fieldset_name_attr(self) -> None:
1820 syms = self._parse('<fieldset name="address"><legend>Addr</legend></fieldset>')
1821 assert any("fieldset[address]" in k for k in syms)
1822
1823 def test_slot_name_attr(self) -> None:
1824 syms = self._parse('<slot name="header"></slot>')
1825 assert any("slot[header]" in k for k in syms), f"keys: {list(syms)}"
1826
1827 # ------------------------------------------------------------------
1828 # Headings and label elements — text content as name
1829 # ------------------------------------------------------------------
1830
1831 def test_h1_heading_extracted(self) -> None:
1832 syms = self._parse('<h1>Page Title</h1>')
1833 assert any("h1: Page Title" in k for k in syms), f"keys: {list(syms)}"
1834
1835 def test_h2_heading_extracted(self) -> None:
1836 syms = self._parse('<h2>Section Name</h2>')
1837 assert any("h2: Section Name" in k for k in syms)
1838
1839 def test_summary_text_extracted(self) -> None:
1840 syms = self._parse('<details><summary>More info</summary><p>body</p></details>')
1841 assert any("summary: More info" in k for k in syms), f"keys: {list(syms)}"
1842
1843 def test_figcaption_text_extracted(self) -> None:
1844 syms = self._parse('<figure><img src="x.jpg"><figcaption>A photo</figcaption></figure>')
1845 assert any("figcaption: A photo" in k for k in syms), f"keys: {list(syms)}"
1846
1847 def test_legend_text_extracted(self) -> None:
1848 syms = self._parse('<fieldset name="contact"><legend>Contact Us</legend></fieldset>')
1849 assert any("legend: Contact Us" in k for k in syms)
1850
1851 # ------------------------------------------------------------------
1852 # Child heading fallback — semantic element derives name from h1-h6 child
1853 # ------------------------------------------------------------------
1854
1855 def test_section_with_child_heading(self) -> None:
1856 syms = self._parse('<section><h2>About Us</h2><p>content</p></section>')
1857 assert any("section: About Us" in k for k in syms), f"keys: {list(syms)}"
1858
1859 def test_article_with_child_h3(self) -> None:
1860 syms = self._parse('<article><h3>News Item</h3><p>text</p></article>')
1861 assert any("article: News Item" in k for k in syms)
1862
1863 def test_child_heading_not_emitted_twice(self) -> None:
1864 # The h2 should appear once as its own symbol and once named via parent,
1865 # but the parent section should not get a @lineno address.
1866 syms = self._parse('<section><h2>About</h2></section>')
1867 assert any("section: About" in k for k in syms)
1868 assert not any("section@" in k for k in syms), f"lineno leaked: {list(syms)}"
1869
1870 # ------------------------------------------------------------------
1871 # Custom elements (Web Components — hyphenated tag names)
1872 # ------------------------------------------------------------------
1873
1874 def test_custom_element_with_id(self) -> None:
1875 syms = self._parse('<my-button id="submit-btn">Submit</my-button>')
1876 assert any("my-button#submit-btn" in k for k in syms), f"keys: {list(syms)}"
1877
1878 def test_custom_element_with_aria_label(self) -> None:
1879 syms = self._parse('<app-header aria-label="Site Header"></app-header>')
1880 assert any("app-header[Site Header]" in k for k in syms)
1881
1882 # ------------------------------------------------------------------
1883 # Template and slot (Web Component definitions)
1884 # ------------------------------------------------------------------
1885
1886 def test_template_with_id(self) -> None:
1887 syms = self._parse('<template id="card-tpl"><div class="card"></div></template>')
1888 assert any("template#card-tpl" in k for k in syms), f"keys: {list(syms)}"
1889
1890 # ------------------------------------------------------------------
1891 # Semantic structure — bare elements fall back to @lineno
1892 # ------------------------------------------------------------------
1893
1894 def test_semantic_section_extracted(self) -> None:
1895 syms = self._parse('<section>content</section>')
1896 assert any("section" in k for k in syms)
1897
1898 def test_generic_div_without_id_skipped(self) -> None:
1899 syms = self._parse('<div>plain</div>')
1900 assert not any("div" in k for k in syms), f"unexpected: {list(syms)}"
1901
1902 # ------------------------------------------------------------------
1903 # Content IDs
1904 # ------------------------------------------------------------------
1905
1906 def test_content_id_present(self) -> None:
1907 syms = self._parse('<h1>Title</h1>')
1908 records = [r for r in syms.values() if "h1" in r["name"]]
1909 assert records
1910 assert len(records[0]["content_id"]) == 64
1911
1912 def test_content_id_differs_for_different_content(self) -> None:
1913 s1 = self._parse('<section id="a"><p>alpha</p></section>')
1914 s2 = self._parse('<section id="a"><p>beta</p></section>')
1915 ids1 = {r["content_id"] for r in s1.values() if "section#a" in r["name"]}
1916 ids2 = {r["content_id"] for r in s2.values() if "section#a" in r["name"]}
1917 assert ids1 and ids2
1918 assert ids1 != ids2
1919
1920 def test_adapter_for_path_html(self) -> None:
1921 from muse.plugins.code.ast_parser import HtmlAdapter
1922 assert isinstance(adapter_for_path("page.html"), HtmlAdapter)
1923
1924 def test_adapter_for_path_htm(self) -> None:
1925 from muse.plugins.code.ast_parser import HtmlAdapter
1926 assert isinstance(adapter_for_path("legacy.htm"), HtmlAdapter)
1927
1928
1929 # ---------------------------------------------------------------------------
1930 # CSS adapter
1931 # ---------------------------------------------------------------------------
1932
1933
1934 class TestCssAdapter:
1935 """Rule-set, @keyframes, @media, @supports, and @layer extraction via tree-sitter-css."""
1936
1937 def _parse(self, src: str, path: str = "styles.css") -> SymbolTree:
1938 adapter = adapter_for_path(path)
1939 # If the CSS grammar is unavailable the adapter degrades to FallbackAdapter.
1940 if isinstance(adapter, FallbackAdapter):
1941 pytest.skip("tree-sitter-css not available")
1942 return adapter.parse_symbols(src.encode(), path)
1943
1944 def test_rule_set_extracted(self) -> None:
1945 syms = self._parse(".btn { color: red; }")
1946 assert len(syms) >= 1
1947 kinds = {r["kind"] for r in syms.values()}
1948 assert "rule" in kinds
1949
1950 def test_rule_set_kind(self) -> None:
1951 syms = self._parse(".card { display: flex; }")
1952 records = [r for r in syms.values() if ".card" in r["name"]]
1953 assert records, f"keys: {list(syms)}"
1954 assert records[0]["kind"] == "rule"
1955
1956 def test_keyframes_extracted(self) -> None:
1957 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }")
1958 assert any("spin" in r["name"] for r in syms.values()), f"symbols: {list(syms)}"
1959
1960 def test_keyframes_kind(self) -> None:
1961 syms = self._parse("@keyframes bounce { 0% { top: 0; } 100% { top: 10px; } }")
1962 records = [r for r in syms.values() if "bounce" in r["name"]]
1963 assert records, f"keys: {list(syms)}"
1964 assert records[0]["kind"] == "rule"
1965
1966 def test_media_extracted(self) -> None:
1967 syms = self._parse("@media (max-width: 768px) { .btn { display: none; } }")
1968 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1969
1970 def test_supports_extracted(self) -> None:
1971 syms = self._parse("@supports (display: grid) { .container { display: grid; } }")
1972 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1973
1974 def test_layer_extracted(self) -> None:
1975 syms = self._parse("@layer base { .btn { display: inline-block; } }")
1976 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1977
1978 def test_multiple_rules(self) -> None:
1979 src = ".a { color: red; }\n.b { color: blue; }"
1980 syms = self._parse(src)
1981 assert len(syms) >= 2
1982
1983 def test_content_id_differs_for_different_rules(self) -> None:
1984 s1 = self._parse(".a { color: red; }")
1985 s2 = self._parse(".b { color: blue; }")
1986 ids1 = {r["content_id"] for r in s1.values()}
1987 ids2 = {r["content_id"] for r in s2.values()}
1988 assert ids1 != ids2
1989
1990 def test_scss_extension_uses_separate_spec(self) -> None:
1991 """`.scss` files use tree-sitter-scss; `.css` files use tree-sitter-css."""
1992 from muse.plugins.code.ast_parser import TreeSitterAdapter
1993 css_adapter = adapter_for_path("styles.css")
1994 scss_adapter = adapter_for_path("styles.scss")
1995 assert isinstance(css_adapter, TreeSitterAdapter)
1996 assert isinstance(scss_adapter, TreeSitterAdapter)
1997 # Each must have its own language spec — different module names.
1998 assert css_adapter._spec["module_name"] == "tree_sitter_css"
1999 assert scss_adapter._spec["module_name"] == "tree_sitter_scss"
2000
2001
2002 # ---------------------------------------------------------------------------
2003 # SCSS: variables, mixins, functions, nested rules
2004 # ---------------------------------------------------------------------------
2005
2006
2007 class TestScssAdapter:
2008 """Symbol extraction for SCSS via tree-sitter-scss.
2009
2010 Covers the four SCSS-specific symbol kinds:
2011 variable — $name: value (top-level only)
2012 mixin — @mixin name(…) { … }
2013 function — @function name(…) { @return … }
2014 rule — selector rule-sets, @keyframes, @media
2015 """
2016
2017 def _parse(self, src: str, path: str = "styles.scss") -> SymbolTree:
2018 adapter = adapter_for_path(path)
2019 if isinstance(adapter, FallbackAdapter):
2020 pytest.skip("tree-sitter-scss not available")
2021 return adapter.parse_symbols(src.encode(), path)
2022
2023 def test_rule_set_extracted(self) -> None:
2024 syms = self._parse(".btn { color: red; }")
2025 assert len(syms) >= 1
2026 kinds = {r["kind"] for r in syms.values()}
2027 assert "rule" in kinds
2028
2029 def test_rule_set_kind(self) -> None:
2030 syms = self._parse(".card { display: flex; }")
2031 records = [r for r in syms.values() if ".card" in r["name"]]
2032 assert records, f"keys: {list(syms)}"
2033 assert records[0]["kind"] == "rule"
2034
2035 def test_variable_extracted(self) -> None:
2036 syms = self._parse("$primary-color: #333;\n")
2037 assert any("primary-color" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2038
2039 def test_variable_kind(self) -> None:
2040 syms = self._parse("$spacing: 8px;\n")
2041 records = [r for r in syms.values() if "spacing" in r["name"]]
2042 assert records, f"keys: {list(syms)}"
2043 assert records[0]["kind"] == "variable"
2044
2045 def test_mixin_extracted(self) -> None:
2046 syms = self._parse("@mixin flex-center($dir: row) { display: flex; }\n")
2047 assert any("flex-center" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2048
2049 def test_mixin_kind(self) -> None:
2050 syms = self._parse("@mixin respond-to($bp) { @media (min-width: $bp) { @content; } }\n")
2051 records = [r for r in syms.values() if "respond-to" in r["name"]]
2052 assert records, f"keys: {list(syms)}"
2053 assert records[0]["kind"] == "mixin"
2054
2055 def test_function_extracted(self) -> None:
2056 syms = self._parse("@function em($px, $base: 16) { @return $px / $base * 1em; }\n")
2057 assert any("em" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2058
2059 def test_function_kind(self) -> None:
2060 syms = self._parse("@function rem($px) { @return $px / 16px * 1rem; }\n")
2061 records = [r for r in syms.values() if "rem" in r["name"]]
2062 assert records, f"keys: {list(syms)}"
2063 assert records[0]["kind"] == "function"
2064
2065 def test_keyframes_extracted(self) -> None:
2066 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }\n")
2067 assert any("spin" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2068
2069 def test_keyframes_kind(self) -> None:
2070 syms = self._parse("@keyframes fade { from { opacity: 1; } to { opacity: 0; } }\n")
2071 records = [r for r in syms.values() if "fade" in r["name"]]
2072 assert records, f"keys: {list(syms)}"
2073 assert records[0]["kind"] == "rule"
2074
2075 def test_multiple_kinds_coexist(self) -> None:
2076 src = (
2077 "$spacing: 8px;\n"
2078 "@mixin flex-center { display: flex; }\n"
2079 "@function rem($px) { @return $px / 16px * 1rem; }\n"
2080 ".card { padding: $spacing; }\n"
2081 )
2082 syms = self._parse(src)
2083 kinds = {r["kind"] for r in syms.values()}
2084 assert "variable" in kinds
2085 assert "mixin" in kinds
2086 assert "function" in kinds
2087 assert "rule" in kinds
2088
2089 def test_variable_inside_rule_not_extracted(self) -> None:
2090 """$var inside a rule block is a CSS property value, not a symbol."""
2091 src = ".card {\n $local: 10px;\n padding: $local;\n}\n"
2092 syms = self._parse(src)
2093 # Only the rule_set itself should be extracted — not the inner $local
2094 assert not any("local" in r["name"] for r in syms.values()), (
2095 f"inner variable leaked: {[r['name'] for r in syms.values()]}"
2096 )
2097
2098 def test_content_id_stable(self) -> None:
2099 src = "$primary: red;\n"
2100 syms1 = self._parse(src)
2101 syms2 = self._parse(src)
2102 ids1 = {r["content_id"] for r in syms1.values()}
2103 ids2 = {r["content_id"] for r in syms2.values()}
2104 assert ids1 == ids2
2105
2106 def test_content_id_differs_for_different_symbols(self) -> None:
2107 s1 = self._parse("$a: 1px;\n")
2108 s2 = self._parse("$b: 2px;\n")
2109 ids1 = {r["content_id"] for r in s1.values()}
2110 ids2 = {r["content_id"] for r in s2.values()}
2111 assert ids1 != ids2
2112
2113
2114 # ---------------------------------------------------------------------------
2115 # JS/TS: arrow functions and async detection
2116 # ---------------------------------------------------------------------------
2117
2118
2119 class TestJSArrowFunctions:
2120 """Arrow functions and function expressions bound to const/let."""
2121
2122 def _parse(self, src: str, path: str = "mod.js") -> SymbolTree:
2123 adapter = adapter_for_path(path)
2124 if isinstance(adapter, FallbackAdapter):
2125 pytest.skip("tree-sitter-javascript not available")
2126 return adapter.parse_symbols(src.encode(), path)
2127
2128 def test_const_arrow_function(self) -> None:
2129 syms = self._parse("const greet = (name) => `Hello ${name}`;\n")
2130 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
2131
2132 def test_const_function_expression(self) -> None:
2133 syms = self._parse("const add = function(a, b) { return a + b; };\n")
2134 assert any("add" in k for k in syms)
2135
2136 def test_ts_arrow_function(self) -> None:
2137 syms = self._parse(
2138 "const greet = (name: string): string => `Hello ${name}`;\n",
2139 path="mod.ts",
2140 )
2141 assert any("greet" in k for k in syms)
2142
2143 def test_class_method_still_extracted(self) -> None:
2144 syms = self._parse("class Foo { bar() { return 1; } }\n")
2145 assert any("bar" in k for k in syms)
2146
2147 def test_async_function_detected(self) -> None:
2148 syms = self._parse("async function fetchData() { return await fetch('/'); }\n")
2149 kinds = {r["kind"] for r in syms.values() if "fetchData" in r["name"]}
2150 assert "async_function" in kinds, f"kinds: {kinds}"
2151
2152
2153 # ---------------------------------------------------------------------------
2154 # Go: const and var spec extraction
2155 # ---------------------------------------------------------------------------
2156
2157
2158 class TestGoConstVar:
2159 def _parse(self, src: str) -> SymbolTree:
2160 adapter = adapter_for_path("main.go")
2161 if isinstance(adapter, FallbackAdapter):
2162 pytest.skip("tree-sitter-go not available")
2163 return adapter.parse_symbols(src.encode(), "main.go")
2164
2165 def test_const_extracted(self) -> None:
2166 syms = self._parse("package main\nconst MaxRetries = 3\n")
2167 assert any("MaxRetries" in k for k in syms), f"keys: {list(syms)}"
2168
2169 def test_var_extracted(self) -> None:
2170 syms = self._parse("package main\nvar ErrNotFound = errors.New(\"not found\")\n")
2171 assert any("ErrNotFound" in k for k in syms)
2172
2173 def test_const_kind_is_variable(self) -> None:
2174 syms = self._parse("package main\nconst Timeout = 30\n")
2175 records = [r for r in syms.values() if "Timeout" in r["name"]]
2176 assert records
2177 assert records[0]["kind"] == "variable"
2178
2179
2180 # ---------------------------------------------------------------------------
2181 # Rust: static, const, type alias, mod
2182 # ---------------------------------------------------------------------------
2183
2184
2185 class TestRustExtended:
2186 def _parse(self, src: str) -> SymbolTree:
2187 adapter = adapter_for_path("lib.rs")
2188 if isinstance(adapter, FallbackAdapter):
2189 pytest.skip("tree-sitter-rust not available")
2190 return adapter.parse_symbols(src.encode(), "lib.rs")
2191
2192 def test_static_extracted(self) -> None:
2193 syms = self._parse("static MAX: usize = 100;\n")
2194 assert any("MAX" in k for k in syms), f"keys: {list(syms)}"
2195
2196 def test_const_extracted(self) -> None:
2197 syms = self._parse("const TIMEOUT: u64 = 30;\n")
2198 assert any("TIMEOUT" in k for k in syms)
2199
2200 def test_type_alias_extracted(self) -> None:
2201 syms = self._parse("type Result<T> = std::result::Result<T, Error>;\n")
2202 assert any("Result" in k for k in syms)
2203
2204 def test_mod_extracted(self) -> None:
2205 syms = self._parse("mod utils { pub fn helper() {} }\n")
2206 assert any("utils" in k for k in syms)
2207
2208
2209 # ---------------------------------------------------------------------------
2210 # C: struct and enum extraction
2211 # ---------------------------------------------------------------------------
2212
2213
2214 class TestCStructEnum:
2215 def _parse(self, src: str) -> SymbolTree:
2216 adapter = adapter_for_path("main.c")
2217 if isinstance(adapter, FallbackAdapter):
2218 pytest.skip("tree-sitter-c not available")
2219 return adapter.parse_symbols(src.encode(), "main.c")
2220
2221 def test_struct_extracted(self) -> None:
2222 syms = self._parse("struct Point { int x; int y; };\n")
2223 assert any("Point" in k for k in syms), f"keys: {list(syms)}"
2224
2225 def test_enum_extracted(self) -> None:
2226 syms = self._parse("enum Color { RED, GREEN, BLUE };\n")
2227 assert any("Color" in k for k in syms)
2228
2229 def test_enum_kind(self) -> None:
2230 syms = self._parse("enum Status { OK, ERR };\n")
2231 records = [r for r in syms.values() if "Status" in r["name"]]
2232 assert records, f"keys: {list(syms)}"
2233 assert records[0]["kind"] == "enum"
2234
2235 def test_struct_kind(self) -> None:
2236 syms = self._parse("struct Node { int val; struct Node *next; };\n")
2237 records = [r for r in syms.values() if "Node" in r["name"]]
2238 assert records
2239 assert records[0]["kind"] == "struct"
2240
2241
2242 # ---------------------------------------------------------------------------
2243 # C#: property and record extraction
2244 # ---------------------------------------------------------------------------
2245
2246
2247 class TestCSharpExtended:
2248 def _parse(self, src: str) -> SymbolTree:
2249 adapter = adapter_for_path("Model.cs")
2250 if isinstance(adapter, FallbackAdapter):
2251 pytest.skip("tree-sitter-c-sharp not available")
2252 return adapter.parse_symbols(src.encode(), "Model.cs")
2253
2254 def test_property_extracted(self) -> None:
2255 syms = self._parse(
2256 "class User { public string Name { get; set; } }\n"
2257 )
2258 assert any("Name" in k for k in syms), f"keys: {list(syms)}"
2259
2260 def test_record_extracted(self) -> None:
2261 syms = self._parse("public record Point(int X, int Y);\n")
2262 assert any("Point" in k for k in syms)
2263
2264 def test_property_kind(self) -> None:
2265 syms = self._parse(
2266 "class C { public int Age { get; set; } }\n"
2267 )
2268 records = [r for r in syms.values() if "Age" in r["name"]]
2269 assert records
2270 assert records[0]["kind"] == "variable"
2271
2272
2273 # ---------------------------------------------------------------------------
2274 # Java: annotation type and record extraction
2275 # ---------------------------------------------------------------------------
2276
2277
2278 class TestJavaExtended:
2279 def _parse(self, src: str) -> SymbolTree:
2280 adapter = adapter_for_path("Main.java")
2281 if isinstance(adapter, FallbackAdapter):
2282 pytest.skip("tree-sitter-java not available")
2283 return adapter.parse_symbols(src.encode(), "Main.java")
2284
2285 def test_annotation_type_extracted(self) -> None:
2286 syms = self._parse("public @interface Cacheable { String value() default \"\"; }\n")
2287 assert any("Cacheable" in k for k in syms), f"keys: {list(syms)}"
2288
2289 def test_record_extracted(self) -> None:
2290 syms = self._parse("public record Point(int x, int y) {}\n")
2291 assert any("Point" in k for k in syms)
2292
2293
2294 # ---------------------------------------------------------------------------
2295 # Kotlin: object declaration and property extraction
2296 # ---------------------------------------------------------------------------
2297
2298
2299 class TestKotlinExtended:
2300 def _parse(self, src: str) -> SymbolTree:
2301 adapter = adapter_for_path("Main.kt")
2302 if isinstance(adapter, FallbackAdapter):
2303 pytest.skip("tree-sitter-kotlin not available")
2304 return adapter.parse_symbols(src.encode(), "Main.kt")
2305
2306 def test_object_declaration_extracted(self) -> None:
2307 syms = self._parse("object Singleton { fun greet() = println(\"hi\") }\n")
2308 assert any("Singleton" in k for k in syms), f"keys: {list(syms)}"
2309
2310 def test_property_declaration_extracted(self) -> None:
2311 syms = self._parse("val MAX_SIZE: Int = 100\n")
2312 assert any("MAX_SIZE" in k for k in syms)
2313
2314
2315 # ---------------------------------------------------------------------------
2316 # Bash / sh / zsh adapter
2317 # ---------------------------------------------------------------------------
2318
2319
2320 class TestBashAdapter:
2321 """Symbol extraction tests for the bash/sh/zsh tree-sitter adapter.
2322
2323 All tests skip gracefully when ``tree-sitter-bash`` is not installed so
2324 they are safe to run in environments that only install a subset of grammars.
2325 The grammar covers bash, sh, and zsh (zsh is a strict backward-compatible
2326 superset of bash at the AST level).
2327 """
2328
2329 def _parse(self, src: str, path: str = "script.sh") -> SymbolTree:
2330 """Parse *src* via the bash adapter; skip if grammar not installed."""
2331 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2332 adapter = adapter_for_path(path)
2333 if isinstance(adapter, FallbackAdapter):
2334 pytest.skip("tree-sitter-bash not installed (pip install 'muse[shell]')")
2335 return adapter.parse_symbols(src.encode(), path)
2336
2337 def test_function_definition_extracted(self) -> None:
2338 """A bare ``function_definition`` node is extracted as a symbol."""
2339 syms = self._parse("greet() {\n echo hello\n}\n")
2340 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
2341
2342 def test_function_kind_is_function(self) -> None:
2343 syms = self._parse("build() {\n make all\n}\n")
2344 matches = [r for r in syms.values() if r["name"] == "build"]
2345 assert matches, "symbol 'build' not found"
2346 assert matches[0]["kind"] == "function"
2347
2348 def test_variable_assignment_extracted(self) -> None:
2349 """Top-level variable assignments are extracted as ``variable`` symbols."""
2350 syms = self._parse("APP_NAME=muse\n")
2351 assert any("APP_NAME" in k for k in syms), f"keys: {list(syms)}"
2352
2353 def test_variable_kind_is_variable(self) -> None:
2354 syms = self._parse("VERSION=1.0.0\n")
2355 matches = [r for r in syms.values() if r["name"] == "VERSION"]
2356 assert matches, "symbol 'VERSION' not found"
2357 assert matches[0]["kind"] == "variable"
2358
2359 def test_multiple_functions_extracted(self) -> None:
2360 src = "init() {\n echo init\n}\ndeploy() {\n echo deploy\n}\n"
2361 syms = self._parse(src)
2362 names = {r["name"] for r in syms.values()}
2363 assert "init" in names
2364 assert "deploy" in names
2365
2366 def test_function_and_variable_coexist(self) -> None:
2367 src = "ENV=prod\nstart() {\n echo starting\n}\n"
2368 syms = self._parse(src)
2369 names = {r["name"] for r in syms.values()}
2370 assert "ENV" in names
2371 assert "start" in names
2372
2373 def test_symbol_record_has_content_id(self) -> None:
2374 syms = self._parse("run() {\n ./app\n}\n")
2375 records = [r for r in syms.values() if r["name"] == "run"]
2376 assert records
2377 assert len(records[0]["content_id"]) == 64 # SHA-256 hex
2378
2379 def test_content_id_stable_across_calls(self) -> None:
2380 src = "setup() {\n echo setup\n}\n"
2381 syms_a = self._parse(src)
2382 syms_b = self._parse(src)
2383 for addr in syms_a:
2384 assert syms_a[addr]["content_id"] == syms_b[addr]["content_id"]
2385
2386 def test_body_hash_differs_for_different_bodies(self) -> None:
2387 sym_a = self._parse("fn() {\n echo a\n}\n")
2388 sym_b = self._parse("fn() {\n echo b\n}\n")
2389 records_a = [r for r in sym_a.values() if r["name"] == "fn"]
2390 records_b = [r for r in sym_b.values() if r["name"] == "fn"]
2391 assert records_a and records_b
2392 assert records_a[0]["body_hash"] != records_b[0]["body_hash"]
2393
2394 def test_sh_extension_routes_to_same_adapter(self) -> None:
2395 """`.sh` and `.bash` share the same grammar package."""
2396 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2397 sh = adapter_for_path("script.sh")
2398 ba = adapter_for_path("script.bash")
2399 if isinstance(sh, FallbackAdapter):
2400 pytest.skip("tree-sitter-bash not installed")
2401 # Both should be the same adapter class (TreeSitterAdapter) and share
2402 # the same supported_extensions set.
2403 assert type(sh) is type(ba)
2404 assert ".sh" in sh.supported_extensions()
2405 assert ".bash" in sh.supported_extensions()
2406
2407 def test_zsh_extension_routed(self) -> None:
2408 """.zsh files route to the bash adapter (zsh is a bash superset)."""
2409 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2410 adapter = adapter_for_path("config.zsh")
2411 if isinstance(adapter, FallbackAdapter):
2412 pytest.skip("tree-sitter-bash not installed")
2413 assert ".zsh" in adapter.supported_extensions()
2414
2415 def test_plugin_zsh_extension_routed(self) -> None:
2416 """.plugin.zsh files route to the bash adapter."""
2417 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2418 adapter = adapter_for_path("muse.plugin.zsh")
2419 if isinstance(adapter, FallbackAdapter):
2420 pytest.skip("tree-sitter-bash not installed")
2421 assert ".plugin.zsh" in adapter.supported_extensions()
2422
2423 def test_parse_zsh_function(self) -> None:
2424 """Zsh function syntax (no ``function`` keyword) parses correctly."""
2425 syms = self._parse("muse_prompt_info() {\n echo branch\n}\n", "muse.plugin.zsh")
2426 assert any("muse_prompt_info" in k for k in syms), f"keys: {list(syms)}"
2427
2428 def test_address_prefix_matches_file_path(self) -> None:
2429 """Symbol addresses are prefixed with the supplied file path."""
2430 syms = self._parse("build() {\n echo\n}\n", "scripts/build.sh")
2431 assert any(k.startswith("scripts/build.sh::") for k in syms), (
2432 f"keys: {list(syms)}"
2433 )
2434
2435 def test_lineno_is_positive(self) -> None:
2436 syms = self._parse("deploy() {\n echo deploy\n}\n")
2437 for rec in syms.values():
2438 assert rec["lineno"] >= 1
2439
2440 def test_empty_file_returns_empty_tree(self) -> None:
2441 syms = self._parse("")
2442 assert syms == {}
2443
2444 def test_comment_only_file_returns_empty_tree(self) -> None:
2445 syms = self._parse("# This is a comment\n# Another comment\n")
2446 assert syms == {}
2447
2448 def test_file_content_id_stable(self) -> None:
2449 """``file_content_id`` is stable for the same bytes."""
2450 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2451 adapter = adapter_for_path("install.sh")
2452 if isinstance(adapter, FallbackAdapter):
2453 pytest.skip("tree-sitter-bash not installed")
2454 src = b"#!/bin/bash\necho hello\n"
2455 assert adapter.file_content_id(src) == adapter.file_content_id(src)
2456
2457 def test_file_content_id_differs_for_different_content(self) -> None:
2458 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2459 adapter = adapter_for_path("install.sh")
2460 if isinstance(adapter, FallbackAdapter):
2461 pytest.skip("tree-sitter-bash not installed")
2462 assert adapter.file_content_id(b"echo a\n") != adapter.file_content_id(b"echo b\n")
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago