gabriel / muse public
test_code_plugin.py python
2,461 lines 100.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 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 pathlib
54 import textwrap
55
56 import pytest
57
58 from muse._version import __version__
59 from muse.core._types import blob_id, fake_id
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 as _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 blob_id(b)
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 = blob_id(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 = blob_id(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": fake_id("missing-base")})
1164 target = _make_manifest({"f.py": fake_id("missing-target")})
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 → clean merge.
1234
1235 The OT engine identifies that the individual symbol edits commute
1236 (different addresses) and the text-merge succeeds because the edits are
1237 non-overlapping. merge_ops produces a clean merged blob containing both
1238 changes — no conflict is raised.
1239 """
1240 repo_root = tmp_path / "repo"
1241 repo_root.mkdir()
1242
1243 base_src = _src("""\
1244 def foo(x: int) -> int:
1245 return x
1246
1247 def bar(y: int) -> int:
1248 return y
1249 """)
1250 # Ours: modify foo.
1251 ours_src = _src("""\
1252 def foo(x: int) -> int:
1253 return x * 2
1254
1255 def bar(y: int) -> int:
1256 return y
1257 """)
1258 # Theirs: modify bar.
1259 theirs_src = _src("""\
1260 def foo(x: int) -> int:
1261 return x
1262
1263 def bar(y: int) -> int:
1264 return y + 1
1265 """)
1266
1267 base_snap = self._py_snap("m.py", base_src, repo_root)
1268 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1269 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1270
1271 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1272 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1273
1274 result = self.plugin.merge_ops(
1275 base_snap,
1276 ours_snap,
1277 theirs_snap,
1278 ours_delta["ops"],
1279 theirs_delta["ops"],
1280 repo_root=repo_root,
1281 )
1282 # Non-overlapping edits to different symbols commute and text-merge cleanly.
1283 assert result.is_clean, "Expected clean merge for non-overlapping symbol edits"
1284 assert "m.py" in result.merged.get("files", {})
1285
1286 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1287 """Both agents modify the same function → conflict at symbol address."""
1288 repo_root = tmp_path / "repo"
1289 repo_root.mkdir()
1290
1291 base_src = _src("def calc(x: int) -> int:\n return x\n")
1292 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1293 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1294
1295 base_snap = self._py_snap("calc.py", base_src, repo_root)
1296 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1297 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1298
1299 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1300 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1301
1302 result = self.plugin.merge_ops(
1303 base_snap,
1304 ours_snap,
1305 theirs_snap,
1306 ours_delta["ops"],
1307 theirs_delta["ops"],
1308 repo_root=repo_root,
1309 )
1310 assert not result.is_clean
1311 # Conflict should be at file or symbol level.
1312 assert len(result.conflicts) > 0
1313
1314 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1315 """Agents modify completely different files → auto-merge."""
1316 repo_root = tmp_path / "repo"
1317 repo_root.mkdir()
1318
1319 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1320 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1321 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1322
1323 ours_delta = self.plugin.diff(base, ours)
1324 theirs_delta = self.plugin.diff(base, theirs)
1325
1326 result = self.plugin.merge_ops(
1327 base, ours, theirs,
1328 ours_delta["ops"],
1329 theirs_delta["ops"],
1330 )
1331 assert result.is_clean
1332
1333
1334 # ---------------------------------------------------------------------------
1335 # merge_ops conflict-propagation regression tests
1336 # ---------------------------------------------------------------------------
1337
1338
1339 class TestMergeOpsConflictPropagation:
1340 """Regression tests for the merge_ops conflict-propagation bug.
1341
1342 Before the fix, merge_ops silently used the "ours" blob when the OT check
1343 missed a conflict — either because of mixed op types (one side ReplaceOp,
1344 other side PatchOp) or because symbol-level ops commuted while the file
1345 blobs still differed. Both cases produced wrong merged content without
1346 flagging a conflict.
1347
1348 After the fix, merge_ops propagates file-level conflicts from the fallback
1349 merge() unless the path was already auto-resolved by a .museattributes
1350 strategy. See: muse/plugins/code/plugin.py::CodePlugin.merge_ops Step 4.
1351 """
1352
1353 plugin = CodePlugin()
1354
1355 # ------------------------------------------------------------------
1356 # Scenario 1: Completely different file versions — both sides changed
1357 # the entire content of a non-code file (e.g. AGENTS.md regression).
1358 # ------------------------------------------------------------------
1359
1360 def test_commuting_symbol_changes_same_file_is_conflict(
1361 self, tmp_path: pathlib.Path
1362 ) -> None:
1363 """Both branches modify different sections → OT says commute, but file-level conflict.
1364
1365 This is the exact scenario that caused the AGENTS.md regression:
1366 - merge base has Section A
1367 - ours modifies Section A (different content, same heading)
1368 - theirs adds Section B (new heading not in base or ours)
1369
1370 The OT check sees ReplaceOp("AGENTS.md::Project.Section A") vs
1371 InsertOp("AGENTS.md::Project.Section B") — different addresses → they commute.
1372 OT declares a clean merge, but the merged blob is just "ours" (Section A
1373 updated, Section B absent), silently discarding theirs' new section.
1374
1375 After the fix, merge_ops propagates the file-level conflict from the
1376 fallback merge() so the user is told to resolve it manually.
1377 """
1378 repo_root = tmp_path / "repo"
1379 repo_root.mkdir()
1380
1381 # Base: one section.
1382 base_content = b"# Project\n\n## Section A\n\nOriginal content.\n"
1383 # Ours: modified Section A (different text but same heading).
1384 ours_content = b"# Project\n\n## Section A\n\nOurs rewrote Section A.\n"
1385 # Theirs: Section A unchanged + added Section B.
1386 theirs_content = (
1387 b"# Project\n\n## Section A\n\nOriginal content.\n\n"
1388 b"## Section B\n\nTheirs added this new section.\n"
1389 )
1390
1391 base_oid = _store_blob(repo_root, base_content)
1392 ours_oid = _store_blob(repo_root, ours_content)
1393 theirs_oid = _store_blob(repo_root, theirs_content)
1394
1395 base_snap = _make_manifest({"AGENTS.md": base_oid})
1396 ours_snap = _make_manifest({"AGENTS.md": ours_oid})
1397 theirs_snap = _make_manifest({"AGENTS.md": theirs_oid})
1398
1399 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1400 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1401
1402 result = self.plugin.merge_ops(
1403 base_snap, ours_snap, theirs_snap,
1404 ours_delta["ops"], theirs_delta["ops"],
1405 repo_root=repo_root,
1406 )
1407 # OT sees commuting ops (different symbol addresses), but the merged file
1408 # blob would silently be "ours" — theirs' Section B would be dropped.
1409 # merge_ops must surface the file-level conflict.
1410 assert not result.is_clean, (
1411 "Commuting ops on same file — expected file-level conflict to be propagated, "
1412 f"got is_clean=True. Conflicts: {result.conflicts}. "
1413 "AGENTS.md :: Section B from 'theirs' would be silently discarded."
1414 )
1415 conflict_files = {c.split("::")[0] for c in result.conflicts}
1416 assert "AGENTS.md" in conflict_files, (
1417 f"Expected 'AGENTS.md' in conflict file paths, got: {result.conflicts}"
1418 )
1419
1420 # ------------------------------------------------------------------
1421 # Scenario 2: Mixed op types — one side ReplaceOp, other PatchOp.
1422 # ------------------------------------------------------------------
1423
1424 def test_mixed_op_types_is_conflict(self, tmp_path: pathlib.Path) -> None:
1425 """One side has ReplaceOp (no symbol tree), other has PatchOp → conflict.
1426
1427 If the file has no parseable symbols on one branch (e.g. a plain text
1428 file where one branch added a heading and the other didn't), the diff
1429 produces a ReplaceOp on the no-heading side and a PatchOp on the
1430 heading side. They never appear together in the OT conflict loops,
1431 so the OT check sees no conflict — but the blobs differ on both sides.
1432 """
1433 repo_root = tmp_path / "repo"
1434 repo_root.mkdir()
1435
1436 # Base: plain text, no Markdown headings → no symbol tree.
1437 base_content = b"version = 1\n"
1438 # Ours: still no heading → ReplaceOp at file level.
1439 ours_content = b"version = 1-hotfix\n"
1440 # Theirs: added a heading → PatchOp with symbol child.
1441 theirs_content = b"version = 2\n\n# comprehensive update\n"
1442
1443 base_oid = _store_blob(repo_root, base_content)
1444 ours_oid = _store_blob(repo_root, ours_content)
1445 theirs_oid = _store_blob(repo_root, theirs_content)
1446
1447 base_snap = _make_manifest({"config.txt": base_oid})
1448 ours_snap = _make_manifest({"config.txt": ours_oid})
1449 theirs_snap = _make_manifest({"config.txt": theirs_oid})
1450
1451 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1452 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1453
1454 result = self.plugin.merge_ops(
1455 base_snap, ours_snap, theirs_snap,
1456 ours_delta["ops"], theirs_delta["ops"],
1457 repo_root=repo_root,
1458 )
1459 assert not result.is_clean, (
1460 "Mixed op types (ReplaceOp ours, PatchOp theirs) for config.txt — "
1461 f"expected conflict, got is_clean=True. "
1462 f"Ours ops: {ours_delta['ops']}. Theirs ops: {theirs_delta['ops']}."
1463 )
1464 assert "config.txt" in result.conflicts
1465
1466 # ------------------------------------------------------------------
1467 # Scenario 3: Only one side changed the file → clean merge (no regression).
1468 # ------------------------------------------------------------------
1469
1470 def test_only_ours_changed_is_clean(self, tmp_path: pathlib.Path) -> None:
1471 """Only our branch changed a text file → theirs is base → clean merge."""
1472 repo_root = tmp_path / "repo"
1473 repo_root.mkdir()
1474
1475 base_content = b"# Docs\n\nOriginal.\n"
1476 ours_content = b"# Docs\n\nOurs update.\n"
1477
1478 base_oid = _store_blob(repo_root, base_content)
1479 ours_oid = _store_blob(repo_root, ours_content)
1480
1481 base_snap = _make_manifest({"README.md": base_oid})
1482 ours_snap = _make_manifest({"README.md": ours_oid})
1483 theirs_snap = base_snap # theirs unchanged
1484
1485 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1486 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1487
1488 result = self.plugin.merge_ops(
1489 base_snap, ours_snap, theirs_snap,
1490 ours_delta["ops"], theirs_delta["ops"],
1491 repo_root=repo_root,
1492 )
1493 assert result.is_clean, f"Only ours changed — should auto-merge, got: {result.conflicts}"
1494
1495 # ------------------------------------------------------------------
1496 # Scenario 4: Completely disjoint files → clean merge (no regression).
1497 # ------------------------------------------------------------------
1498
1499 def test_disjoint_files_remain_clean(self, tmp_path: pathlib.Path) -> None:
1500 """Each branch changed a different file entirely → clean merge."""
1501 repo_root = tmp_path / "repo"
1502 repo_root.mkdir()
1503
1504 base_a = b"# File A\n\nOriginal.\n"
1505 base_b = b"# File B\n\nOriginal.\n"
1506 ours_a = b"# File A\n\nOurs update.\n"
1507
1508 base_a_oid = _store_blob(repo_root, base_a)
1509 base_b_oid = _store_blob(repo_root, base_b)
1510 ours_a_oid = _store_blob(repo_root, ours_a)
1511 theirs_b_oid = _store_blob(repo_root, b"# File B\n\nTheirs update.\n")
1512
1513 base_snap = _make_manifest({"a.md": base_a_oid, "b.md": base_b_oid})
1514 ours_snap = _make_manifest({"a.md": ours_a_oid, "b.md": base_b_oid})
1515 theirs_snap = _make_manifest({"a.md": base_a_oid, "b.md": theirs_b_oid})
1516
1517 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1518 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1519
1520 result = self.plugin.merge_ops(
1521 base_snap, ours_snap, theirs_snap,
1522 ours_delta["ops"], theirs_delta["ops"],
1523 repo_root=repo_root,
1524 )
1525 assert result.is_clean, f"Disjoint files — should auto-merge, got: {result.conflicts}"
1526
1527
1528 # ---------------------------------------------------------------------------
1529 # CodePlugin — drift
1530 # ---------------------------------------------------------------------------
1531
1532
1533 class TestCodePluginDrift:
1534 plugin = CodePlugin()
1535
1536 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1537 workdir = tmp_path
1538 (workdir / "app.py").write_text("x = 1\n")
1539 snap = self.plugin.snapshot(workdir)
1540 report = self.plugin.drift(snap, workdir)
1541 assert not report.has_drift
1542
1543 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1544 workdir = tmp_path
1545 f = workdir / "app.py"
1546 f.write_text("x = 1\n")
1547 snap = self.plugin.snapshot(workdir)
1548 f.write_text("x = 2\n")
1549 report = self.plugin.drift(snap, workdir)
1550 assert report.has_drift
1551
1552 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1553 workdir = tmp_path
1554 (workdir / "a.py").write_text("a = 1\n")
1555 snap = self.plugin.snapshot(workdir)
1556 (workdir / "b.py").write_text("b = 2\n")
1557 report = self.plugin.drift(snap, workdir)
1558 assert report.has_drift
1559
1560 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1561 workdir = tmp_path
1562 f = workdir / "gone.py"
1563 f.write_text("x = 1\n")
1564 snap = self.plugin.snapshot(workdir)
1565 f.unlink()
1566 report = self.plugin.drift(snap, workdir)
1567 assert report.has_drift
1568
1569 def test_ignored_extant_file_not_in_drift(self, tmp_path: pathlib.Path) -> None:
1570 """A file that was committed, added to .museignore, and still exists on
1571 disk must not appear as deleted in the drift report.
1572
1573 This is the canonical regression test for the bug where build artifacts
1574 (e.g. app.js, app.css) added to .museignore while still present on disk
1575 caused muse status to show them as deleted and blocked muse checkout."""
1576 workdir = tmp_path
1577 (workdir / "src.py").write_text("x = 1\n")
1578 (workdir / "app.js").write_text("// build output\n")
1579 # Include .museignore in the initial snapshot so adding it later
1580 # does not itself register as drift — isolates the variable under test.
1581 (workdir / ".museignore").write_text(
1582 '[global]\npatterns = ["app.js"]\n', encoding="utf-8"
1583 )
1584 # Snapshot with both src.py and .museignore already committed, but
1585 # app.js is also tracked (HEAD committed it before .museignore was in effect).
1586 # Re-read it without .museignore filtering by building manifest directly.
1587 from muse.core.snapshot import hash_file
1588 snap_files = {
1589 "src.py": hash_file(workdir / "src.py"),
1590 "app.js": hash_file(workdir / "app.js"),
1591 ".museignore": hash_file(workdir / ".museignore"),
1592 }
1593 from muse.domain import SnapshotManifest
1594 snap = SnapshotManifest(files=snap_files, domain="code", directories=[])
1595 # app.js still exists on disk — not deleted, just now ignored.
1596 report = self.plugin.drift(snap, workdir)
1597 deleted_addresses = {
1598 op["address"]
1599 for op in report.delta.get("ops", [])
1600 if op.get("op") == "delete"
1601 }
1602 assert "app.js" not in deleted_addresses, (
1603 "ignored-and-extant file must not appear as deleted in drift"
1604 )
1605 assert not report.has_drift, (
1606 "drift must be clean when only ignored-and-extant files differ"
1607 )
1608
1609 def test_truly_deleted_ignored_file_still_in_drift(self, tmp_path: pathlib.Path) -> None:
1610 """A file that is in .museignore AND genuinely absent from disk IS
1611 deleted and must appear in the drift report."""
1612 workdir = tmp_path
1613 (workdir / "src.py").write_text("x = 1\n")
1614 (workdir / "app.js").write_text("// build output\n")
1615 snap = self.plugin.snapshot(workdir)
1616 # Add to .museignore AND delete from disk — this is a real deletion.
1617 (workdir / ".museignore").write_text(
1618 '[global]\npatterns = ["app.js"]\n', encoding="utf-8"
1619 )
1620 (workdir / "app.js").unlink()
1621 report = self.plugin.drift(snap, workdir)
1622 deleted_addresses = {
1623 op["address"]
1624 for op in report.delta.get("ops", [])
1625 if op.get("op") == "delete"
1626 }
1627 assert "app.js" in deleted_addresses, (
1628 "a file in .museignore that is genuinely absent from disk must still be deleted"
1629 )
1630
1631
1632 # ---------------------------------------------------------------------------
1633 # CodePlugin — apply (passthrough)
1634 # ---------------------------------------------------------------------------
1635
1636
1637 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1638 plugin = CodePlugin()
1639 workdir = tmp_path
1640 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1641 result = plugin.apply(delta, workdir)
1642 assert result is workdir
1643
1644
1645 # ---------------------------------------------------------------------------
1646 # CodePlugin — schema
1647 # ---------------------------------------------------------------------------
1648
1649
1650 class TestCodePluginSchema:
1651 plugin = CodePlugin()
1652
1653 def test_schema_domain(self) -> None:
1654 assert self.plugin.schema()["domain"] == "code"
1655
1656 def test_schema_merge_mode(self) -> None:
1657 assert self.plugin.schema()["merge_mode"] == "three_way"
1658
1659 def test_schema_version(self) -> None:
1660 assert self.plugin.schema()["schema_version"] == __version__
1661
1662 def test_schema_dimensions(self) -> None:
1663 dims = self.plugin.schema()["dimensions"]
1664 names = {d["name"] for d in dims}
1665 assert "structure" in names
1666 assert "symbols" in names
1667 assert "imports" in names
1668
1669 def test_schema_top_level_is_tree(self) -> None:
1670 top = self.plugin.schema()["top_level"]
1671 assert top["kind"] == "tree"
1672
1673 def test_schema_description_non_empty(self) -> None:
1674 assert len(self.plugin.schema()["description"]) > 0
1675
1676
1677 # ---------------------------------------------------------------------------
1678 # delta_summary
1679 # ---------------------------------------------------------------------------
1680
1681
1682 class TestDeltaSummary:
1683 def test_empty_ops(self) -> None:
1684 assert delta_summary([]) == "no changes"
1685
1686 def test_file_added(self) -> None:
1687 from muse.domain import DomainOp
1688 ops: list[DomainOp] = [InsertOp(
1689 op="insert", address="f.py", position=None,
1690 content_id="abc", content_summary="added f.py",
1691 )]
1692 summary = delta_summary(ops)
1693 assert "added" in summary
1694 assert "file" in summary
1695
1696 def test_symbols_counted_from_patch(self) -> None:
1697 from muse.domain import DomainOp, PatchOp
1698 child: list[DomainOp] = [
1699 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1700 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1701 ]
1702 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1703 summary = delta_summary(ops)
1704 assert "symbol" in summary
1705
1706
1707 # ---------------------------------------------------------------------------
1708 # Markdown adapter
1709 # ---------------------------------------------------------------------------
1710
1711
1712 class TestMarkdownAdapter:
1713 """Semantic symbol extraction via tree-sitter-markdown."""
1714
1715 def _parse(self, src: str) -> SymbolTree:
1716 from muse.plugins.code.ast_parser import MarkdownAdapter
1717 adapter = MarkdownAdapter()
1718 if adapter._parser is None:
1719 pytest.skip("tree-sitter-markdown not available")
1720 return adapter.parse_symbols(src.encode(), "README.md")
1721
1722 def test_h1_extracted(self) -> None:
1723 syms = self._parse("# Hello World\n")
1724 assert any("Hello World" in k for k in syms), f"keys: {list(syms)}"
1725
1726 def test_h2_extracted(self) -> None:
1727 syms = self._parse("# Title\n\n## Section Two\n")
1728 assert any("Section Two" in k for k in syms)
1729
1730 def test_multiple_headings(self) -> None:
1731 src = "# Top\n\n## Alpha\n\n## Beta\n\n### Deep\n"
1732 syms = self._parse(src)
1733 kinds = {r["kind"] for r in syms.values()}
1734 assert "section" in kinds
1735 assert len(syms) >= 4
1736
1737 def test_section_lineno(self) -> None:
1738 src = "# First\n\n## Second\n"
1739 syms = self._parse(src)
1740 second = next((r for r in syms.values() if "Second" in r["name"]), None)
1741 assert second is not None
1742 assert second["lineno"] == 3
1743
1744 def test_content_id_changes_with_text(self) -> None:
1745 s1 = self._parse("# Hello\n")
1746 s2 = self._parse("# World\n")
1747 ids1 = {r["content_id"] for r in s1.values()}
1748 ids2 = {r["content_id"] for r in s2.values()}
1749 assert ids1 != ids2
1750
1751 def test_adapter_for_path_md(self) -> None:
1752 from muse.plugins.code.ast_parser import MarkdownAdapter
1753 adapter = adapter_for_path("docs/README.md")
1754 assert isinstance(adapter, MarkdownAdapter)
1755
1756 def test_adapter_for_path_rst(self) -> None:
1757 from muse.plugins.code.ast_parser import MarkdownAdapter
1758 adapter = adapter_for_path("notes.rst")
1759 assert isinstance(adapter, MarkdownAdapter)
1760
1761
1762 # ---------------------------------------------------------------------------
1763 # HTML adapter
1764 # ---------------------------------------------------------------------------
1765
1766
1767 class TestHtmlAdapter:
1768 """Semantic element and id-bearing element extraction via tree-sitter-html."""
1769
1770 def _parse(self, src: str) -> SymbolTree:
1771 from muse.plugins.code.ast_parser import HtmlAdapter
1772 adapter = HtmlAdapter()
1773 if adapter._parser is None:
1774 pytest.skip("tree-sitter-html not available")
1775 return adapter.parse_symbols(src.encode(), "index.html")
1776
1777 # ------------------------------------------------------------------
1778 # id attribute — highest priority name source
1779 # ------------------------------------------------------------------
1780
1781 def test_id_bearing_div_extracted(self) -> None:
1782 syms = self._parse('<html><body><div id="hero">x</div></body></html>')
1783 assert any("div#hero" in k for k in syms), f"keys: {list(syms)}"
1784
1785 def test_id_name_format(self) -> None:
1786 syms = self._parse('<section id="intro">content</section>')
1787 assert any("section#intro" in k for k in syms)
1788
1789 def test_multiple_ids(self) -> None:
1790 src = '<section id="intro">a</section><section id="outro">b</section>'
1791 syms = self._parse(src)
1792 assert any("section#intro" in k for k in syms)
1793 assert any("section#outro" in k for k in syms)
1794
1795 # ------------------------------------------------------------------
1796 # aria-label — second priority
1797 # ------------------------------------------------------------------
1798
1799 def test_aria_label_nav(self) -> None:
1800 syms = self._parse('<nav aria-label="Primary Navigation"><ul></ul></nav>')
1801 assert any("nav[Primary Navigation]" in k for k in syms), f"keys: {list(syms)}"
1802
1803 def test_aria_label_beats_lineno(self) -> None:
1804 syms = self._parse('<main aria-label="Content"><p>text</p></main>')
1805 assert any("main[Content]" in k for k in syms)
1806 assert not any("@" in k for k in syms), f"lineno leaked: {list(syms)}"
1807
1808 # ------------------------------------------------------------------
1809 # name attribute — form / fieldset / slot / input
1810 # ------------------------------------------------------------------
1811
1812 def test_form_name_attr(self) -> None:
1813 syms = self._parse('<form name="login"><input></form>')
1814 assert any("form[login]" in k for k in syms), f"keys: {list(syms)}"
1815
1816 def test_fieldset_name_attr(self) -> None:
1817 syms = self._parse('<fieldset name="address"><legend>Addr</legend></fieldset>')
1818 assert any("fieldset[address]" in k for k in syms)
1819
1820 def test_slot_name_attr(self) -> None:
1821 syms = self._parse('<slot name="header"></slot>')
1822 assert any("slot[header]" in k for k in syms), f"keys: {list(syms)}"
1823
1824 # ------------------------------------------------------------------
1825 # Headings and label elements — text content as name
1826 # ------------------------------------------------------------------
1827
1828 def test_h1_heading_extracted(self) -> None:
1829 syms = self._parse('<h1>Page Title</h1>')
1830 assert any("h1: Page Title" in k for k in syms), f"keys: {list(syms)}"
1831
1832 def test_h2_heading_extracted(self) -> None:
1833 syms = self._parse('<h2>Section Name</h2>')
1834 assert any("h2: Section Name" in k for k in syms)
1835
1836 def test_summary_text_extracted(self) -> None:
1837 syms = self._parse('<details><summary>More info</summary><p>body</p></details>')
1838 assert any("summary: More info" in k for k in syms), f"keys: {list(syms)}"
1839
1840 def test_figcaption_text_extracted(self) -> None:
1841 syms = self._parse('<figure><img src="x.jpg"><figcaption>A photo</figcaption></figure>')
1842 assert any("figcaption: A photo" in k for k in syms), f"keys: {list(syms)}"
1843
1844 def test_legend_text_extracted(self) -> None:
1845 syms = self._parse('<fieldset name="contact"><legend>Contact Us</legend></fieldset>')
1846 assert any("legend: Contact Us" in k for k in syms)
1847
1848 # ------------------------------------------------------------------
1849 # Child heading fallback — semantic element derives name from h1-h6 child
1850 # ------------------------------------------------------------------
1851
1852 def test_section_with_child_heading(self) -> None:
1853 syms = self._parse('<section><h2>About Us</h2><p>content</p></section>')
1854 assert any("section: About Us" in k for k in syms), f"keys: {list(syms)}"
1855
1856 def test_article_with_child_h3(self) -> None:
1857 syms = self._parse('<article><h3>News Item</h3><p>text</p></article>')
1858 assert any("article: News Item" in k for k in syms)
1859
1860 def test_child_heading_not_emitted_twice(self) -> None:
1861 # The h2 should appear once as its own symbol and once named via parent,
1862 # but the parent section should not get a @lineno address.
1863 syms = self._parse('<section><h2>About</h2></section>')
1864 assert any("section: About" in k for k in syms)
1865 assert not any("section@" in k for k in syms), f"lineno leaked: {list(syms)}"
1866
1867 # ------------------------------------------------------------------
1868 # Custom elements (Web Components — hyphenated tag names)
1869 # ------------------------------------------------------------------
1870
1871 def test_custom_element_with_id(self) -> None:
1872 syms = self._parse('<my-button id="submit-btn">Submit</my-button>')
1873 assert any("my-button#submit-btn" in k for k in syms), f"keys: {list(syms)}"
1874
1875 def test_custom_element_with_aria_label(self) -> None:
1876 syms = self._parse('<app-header aria-label="Site Header"></app-header>')
1877 assert any("app-header[Site Header]" in k for k in syms)
1878
1879 # ------------------------------------------------------------------
1880 # Template and slot (Web Component definitions)
1881 # ------------------------------------------------------------------
1882
1883 def test_template_with_id(self) -> None:
1884 syms = self._parse('<template id="card-tpl"><div class="card"></div></template>')
1885 assert any("template#card-tpl" in k for k in syms), f"keys: {list(syms)}"
1886
1887 # ------------------------------------------------------------------
1888 # Semantic structure — bare elements fall back to @lineno
1889 # ------------------------------------------------------------------
1890
1891 def test_semantic_section_extracted(self) -> None:
1892 syms = self._parse('<section>content</section>')
1893 assert any("section" in k for k in syms)
1894
1895 def test_generic_div_without_id_skipped(self) -> None:
1896 syms = self._parse('<div>plain</div>')
1897 assert not any("div" in k for k in syms), f"unexpected: {list(syms)}"
1898
1899 # ------------------------------------------------------------------
1900 # Content IDs
1901 # ------------------------------------------------------------------
1902
1903 def test_content_id_present(self) -> None:
1904 syms = self._parse('<h1>Title</h1>')
1905 records = [r for r in syms.values() if "h1" in r["name"]]
1906 assert records
1907 cid = records[0]["content_id"]
1908 assert cid.startswith("sha256:") and len(cid) == 71
1909
1910 def test_content_id_differs_for_different_content(self) -> None:
1911 s1 = self._parse('<section id="a"><p>alpha</p></section>')
1912 s2 = self._parse('<section id="a"><p>beta</p></section>')
1913 ids1 = {r["content_id"] for r in s1.values() if "section#a" in r["name"]}
1914 ids2 = {r["content_id"] for r in s2.values() if "section#a" in r["name"]}
1915 assert ids1 and ids2
1916 assert ids1 != ids2
1917
1918 def test_adapter_for_path_html(self) -> None:
1919 from muse.plugins.code.ast_parser import HtmlAdapter
1920 assert isinstance(adapter_for_path("page.html"), HtmlAdapter)
1921
1922 def test_adapter_for_path_htm(self) -> None:
1923 from muse.plugins.code.ast_parser import HtmlAdapter
1924 assert isinstance(adapter_for_path("legacy.htm"), HtmlAdapter)
1925
1926
1927 # ---------------------------------------------------------------------------
1928 # CSS adapter
1929 # ---------------------------------------------------------------------------
1930
1931
1932 class TestCssAdapter:
1933 """Rule-set, @keyframes, @media, @supports, and @layer extraction via tree-sitter-css."""
1934
1935 def _parse(self, src: str, path: str = "styles.css") -> SymbolTree:
1936 adapter = adapter_for_path(path)
1937 # If the CSS grammar is unavailable the adapter degrades to FallbackAdapter.
1938 if isinstance(adapter, FallbackAdapter):
1939 pytest.skip("tree-sitter-css not available")
1940 return adapter.parse_symbols(src.encode(), path)
1941
1942 def test_rule_set_extracted(self) -> None:
1943 syms = self._parse(".btn { color: red; }")
1944 assert len(syms) >= 1
1945 kinds = {r["kind"] for r in syms.values()}
1946 assert "rule" in kinds
1947
1948 def test_rule_set_kind(self) -> None:
1949 syms = self._parse(".card { display: flex; }")
1950 records = [r for r in syms.values() if ".card" in r["name"]]
1951 assert records, f"keys: {list(syms)}"
1952 assert records[0]["kind"] == "rule"
1953
1954 def test_keyframes_extracted(self) -> None:
1955 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }")
1956 assert any("spin" in r["name"] for r in syms.values()), f"symbols: {list(syms)}"
1957
1958 def test_keyframes_kind(self) -> None:
1959 syms = self._parse("@keyframes bounce { 0% { top: 0; } 100% { top: 10px; } }")
1960 records = [r for r in syms.values() if "bounce" in r["name"]]
1961 assert records, f"keys: {list(syms)}"
1962 assert records[0]["kind"] == "rule"
1963
1964 def test_media_extracted(self) -> None:
1965 syms = self._parse("@media (max-width: 768px) { .btn { display: none; } }")
1966 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1967
1968 def test_supports_extracted(self) -> None:
1969 syms = self._parse("@supports (display: grid) { .container { display: grid; } }")
1970 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1971
1972 def test_layer_extracted(self) -> None:
1973 syms = self._parse("@layer base { .btn { display: inline-block; } }")
1974 assert any(r["kind"] == "rule" for r in syms.values()), f"symbols: {list(syms)}"
1975
1976 def test_multiple_rules(self) -> None:
1977 src = ".a { color: red; }\n.b { color: blue; }"
1978 syms = self._parse(src)
1979 assert len(syms) >= 2
1980
1981 def test_content_id_differs_for_different_rules(self) -> None:
1982 s1 = self._parse(".a { color: red; }")
1983 s2 = self._parse(".b { color: blue; }")
1984 ids1 = {r["content_id"] for r in s1.values()}
1985 ids2 = {r["content_id"] for r in s2.values()}
1986 assert ids1 != ids2
1987
1988 def test_scss_extension_uses_separate_spec(self) -> None:
1989 """`.scss` files use tree-sitter-scss; `.css` files use tree-sitter-css."""
1990 from muse.plugins.code.ast_parser import TreeSitterAdapter
1991 css_adapter = adapter_for_path("styles.css")
1992 scss_adapter = adapter_for_path("styles.scss")
1993 assert isinstance(css_adapter, TreeSitterAdapter)
1994 assert isinstance(scss_adapter, TreeSitterAdapter)
1995 # Each must have its own language spec — different module names.
1996 assert css_adapter._spec["module_name"] == "tree_sitter_css"
1997 assert scss_adapter._spec["module_name"] == "tree_sitter_scss"
1998
1999
2000 # ---------------------------------------------------------------------------
2001 # SCSS: variables, mixins, functions, nested rules
2002 # ---------------------------------------------------------------------------
2003
2004
2005 class TestScssAdapter:
2006 """Symbol extraction for SCSS via tree-sitter-scss.
2007
2008 Covers the four SCSS-specific symbol kinds:
2009 variable — $name: value (top-level only)
2010 mixin — @mixin name(…) { … }
2011 function — @function name(…) { @return … }
2012 rule — selector rule-sets, @keyframes, @media
2013 """
2014
2015 def _parse(self, src: str, path: str = "styles.scss") -> SymbolTree:
2016 adapter = adapter_for_path(path)
2017 if isinstance(adapter, FallbackAdapter):
2018 pytest.skip("tree-sitter-scss not available")
2019 return adapter.parse_symbols(src.encode(), path)
2020
2021 def test_rule_set_extracted(self) -> None:
2022 syms = self._parse(".btn { color: red; }")
2023 assert len(syms) >= 1
2024 kinds = {r["kind"] for r in syms.values()}
2025 assert "rule" in kinds
2026
2027 def test_rule_set_kind(self) -> None:
2028 syms = self._parse(".card { display: flex; }")
2029 records = [r for r in syms.values() if ".card" in r["name"]]
2030 assert records, f"keys: {list(syms)}"
2031 assert records[0]["kind"] == "rule"
2032
2033 def test_variable_extracted(self) -> None:
2034 syms = self._parse("$primary-color: #333;\n")
2035 assert any("primary-color" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2036
2037 def test_variable_kind(self) -> None:
2038 syms = self._parse("$spacing: 8px;\n")
2039 records = [r for r in syms.values() if "spacing" in r["name"]]
2040 assert records, f"keys: {list(syms)}"
2041 assert records[0]["kind"] == "variable"
2042
2043 def test_mixin_extracted(self) -> None:
2044 syms = self._parse("@mixin flex-center($dir: row) { display: flex; }\n")
2045 assert any("flex-center" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2046
2047 def test_mixin_kind(self) -> None:
2048 syms = self._parse("@mixin respond-to($bp) { @media (min-width: $bp) { @content; } }\n")
2049 records = [r for r in syms.values() if "respond-to" in r["name"]]
2050 assert records, f"keys: {list(syms)}"
2051 assert records[0]["kind"] == "mixin"
2052
2053 def test_function_extracted(self) -> None:
2054 syms = self._parse("@function em($px, $base: 16) { @return $px / $base * 1em; }\n")
2055 assert any("em" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2056
2057 def test_function_kind(self) -> None:
2058 syms = self._parse("@function rem($px) { @return $px / 16px * 1rem; }\n")
2059 records = [r for r in syms.values() if "rem" in r["name"]]
2060 assert records, f"keys: {list(syms)}"
2061 assert records[0]["kind"] == "function"
2062
2063 def test_keyframes_extracted(self) -> None:
2064 syms = self._parse("@keyframes spin { from { transform: rotate(0deg); } }\n")
2065 assert any("spin" in r["name"] for r in syms.values()), f"keys: {list(syms)}"
2066
2067 def test_keyframes_kind(self) -> None:
2068 syms = self._parse("@keyframes fade { from { opacity: 1; } to { opacity: 0; } }\n")
2069 records = [r for r in syms.values() if "fade" in r["name"]]
2070 assert records, f"keys: {list(syms)}"
2071 assert records[0]["kind"] == "rule"
2072
2073 def test_multiple_kinds_coexist(self) -> None:
2074 src = (
2075 "$spacing: 8px;\n"
2076 "@mixin flex-center { display: flex; }\n"
2077 "@function rem($px) { @return $px / 16px * 1rem; }\n"
2078 ".card { padding: $spacing; }\n"
2079 )
2080 syms = self._parse(src)
2081 kinds = {r["kind"] for r in syms.values()}
2082 assert "variable" in kinds
2083 assert "mixin" in kinds
2084 assert "function" in kinds
2085 assert "rule" in kinds
2086
2087 def test_variable_inside_rule_not_extracted(self) -> None:
2088 """$var inside a rule block is a CSS property value, not a symbol."""
2089 src = ".card {\n $local: 10px;\n padding: $local;\n}\n"
2090 syms = self._parse(src)
2091 # Only the rule_set itself should be extracted — not the inner $local
2092 assert not any("local" in r["name"] for r in syms.values()), (
2093 f"inner variable leaked: {[r['name'] for r in syms.values()]}"
2094 )
2095
2096 def test_content_id_stable(self) -> None:
2097 src = "$primary: red;\n"
2098 syms1 = self._parse(src)
2099 syms2 = self._parse(src)
2100 ids1 = {r["content_id"] for r in syms1.values()}
2101 ids2 = {r["content_id"] for r in syms2.values()}
2102 assert ids1 == ids2
2103
2104 def test_content_id_differs_for_different_symbols(self) -> None:
2105 s1 = self._parse("$a: 1px;\n")
2106 s2 = self._parse("$b: 2px;\n")
2107 ids1 = {r["content_id"] for r in s1.values()}
2108 ids2 = {r["content_id"] for r in s2.values()}
2109 assert ids1 != ids2
2110
2111
2112 # ---------------------------------------------------------------------------
2113 # JS/TS: arrow functions and async detection
2114 # ---------------------------------------------------------------------------
2115
2116
2117 class TestJSArrowFunctions:
2118 """Arrow functions and function expressions bound to const/let."""
2119
2120 def _parse(self, src: str, path: str = "mod.js") -> SymbolTree:
2121 adapter = adapter_for_path(path)
2122 if isinstance(adapter, FallbackAdapter):
2123 pytest.skip("tree-sitter-javascript not available")
2124 return adapter.parse_symbols(src.encode(), path)
2125
2126 def test_const_arrow_function(self) -> None:
2127 syms = self._parse("const greet = (name) => `Hello ${name}`;\n")
2128 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
2129
2130 def test_const_function_expression(self) -> None:
2131 syms = self._parse("const add = function(a, b) { return a + b; };\n")
2132 assert any("add" in k for k in syms)
2133
2134 def test_ts_arrow_function(self) -> None:
2135 syms = self._parse(
2136 "const greet = (name: string): string => `Hello ${name}`;\n",
2137 path="mod.ts",
2138 )
2139 assert any("greet" in k for k in syms)
2140
2141 def test_class_method_still_extracted(self) -> None:
2142 syms = self._parse("class Foo { bar() { return 1; } }\n")
2143 assert any("bar" in k for k in syms)
2144
2145 def test_async_function_detected(self) -> None:
2146 syms = self._parse("async function fetchData() { return await fetch('/'); }\n")
2147 kinds = {r["kind"] for r in syms.values() if "fetchData" in r["name"]}
2148 assert "async_function" in kinds, f"kinds: {kinds}"
2149
2150
2151 # ---------------------------------------------------------------------------
2152 # Go: const and var spec extraction
2153 # ---------------------------------------------------------------------------
2154
2155
2156 class TestGoConstVar:
2157 def _parse(self, src: str) -> SymbolTree:
2158 adapter = adapter_for_path("main.go")
2159 if isinstance(adapter, FallbackAdapter):
2160 pytest.skip("tree-sitter-go not available")
2161 return adapter.parse_symbols(src.encode(), "main.go")
2162
2163 def test_const_extracted(self) -> None:
2164 syms = self._parse("package main\nconst MaxRetries = 3\n")
2165 assert any("MaxRetries" in k for k in syms), f"keys: {list(syms)}"
2166
2167 def test_var_extracted(self) -> None:
2168 syms = self._parse("package main\nvar ErrNotFound = errors.New(\"not found\")\n")
2169 assert any("ErrNotFound" in k for k in syms)
2170
2171 def test_const_kind_is_variable(self) -> None:
2172 syms = self._parse("package main\nconst Timeout = 30\n")
2173 records = [r for r in syms.values() if "Timeout" in r["name"]]
2174 assert records
2175 assert records[0]["kind"] == "variable"
2176
2177
2178 # ---------------------------------------------------------------------------
2179 # Rust: static, const, type alias, mod
2180 # ---------------------------------------------------------------------------
2181
2182
2183 class TestRustExtended:
2184 def _parse(self, src: str) -> SymbolTree:
2185 adapter = adapter_for_path("lib.rs")
2186 if isinstance(adapter, FallbackAdapter):
2187 pytest.skip("tree-sitter-rust not available")
2188 return adapter.parse_symbols(src.encode(), "lib.rs")
2189
2190 def test_static_extracted(self) -> None:
2191 syms = self._parse("static MAX: usize = 100;\n")
2192 assert any("MAX" in k for k in syms), f"keys: {list(syms)}"
2193
2194 def test_const_extracted(self) -> None:
2195 syms = self._parse("const TIMEOUT: u64 = 30;\n")
2196 assert any("TIMEOUT" in k for k in syms)
2197
2198 def test_type_alias_extracted(self) -> None:
2199 syms = self._parse("type Result<T> = std::result::Result<T, Error>;\n")
2200 assert any("Result" in k for k in syms)
2201
2202 def test_mod_extracted(self) -> None:
2203 syms = self._parse("mod utils { pub fn helper() {} }\n")
2204 assert any("utils" in k for k in syms)
2205
2206
2207 # ---------------------------------------------------------------------------
2208 # C: struct and enum extraction
2209 # ---------------------------------------------------------------------------
2210
2211
2212 class TestCStructEnum:
2213 def _parse(self, src: str) -> SymbolTree:
2214 adapter = adapter_for_path("main.c")
2215 if isinstance(adapter, FallbackAdapter):
2216 pytest.skip("tree-sitter-c not available")
2217 return adapter.parse_symbols(src.encode(), "main.c")
2218
2219 def test_struct_extracted(self) -> None:
2220 syms = self._parse("struct Point { int x; int y; };\n")
2221 assert any("Point" in k for k in syms), f"keys: {list(syms)}"
2222
2223 def test_enum_extracted(self) -> None:
2224 syms = self._parse("enum Color { RED, GREEN, BLUE };\n")
2225 assert any("Color" in k for k in syms)
2226
2227 def test_enum_kind(self) -> None:
2228 syms = self._parse("enum Status { OK, ERR };\n")
2229 records = [r for r in syms.values() if "Status" in r["name"]]
2230 assert records, f"keys: {list(syms)}"
2231 assert records[0]["kind"] == "enum"
2232
2233 def test_struct_kind(self) -> None:
2234 syms = self._parse("struct Node { int val; struct Node *next; };\n")
2235 records = [r for r in syms.values() if "Node" in r["name"]]
2236 assert records
2237 assert records[0]["kind"] == "struct"
2238
2239
2240 # ---------------------------------------------------------------------------
2241 # C#: property and record extraction
2242 # ---------------------------------------------------------------------------
2243
2244
2245 class TestCSharpExtended:
2246 def _parse(self, src: str) -> SymbolTree:
2247 adapter = adapter_for_path("Model.cs")
2248 if isinstance(adapter, FallbackAdapter):
2249 pytest.skip("tree-sitter-c-sharp not available")
2250 return adapter.parse_symbols(src.encode(), "Model.cs")
2251
2252 def test_property_extracted(self) -> None:
2253 syms = self._parse(
2254 "class User { public string Name { get; set; } }\n"
2255 )
2256 assert any("Name" in k for k in syms), f"keys: {list(syms)}"
2257
2258 def test_record_extracted(self) -> None:
2259 syms = self._parse("public record Point(int X, int Y);\n")
2260 assert any("Point" in k for k in syms)
2261
2262 def test_property_kind(self) -> None:
2263 syms = self._parse(
2264 "class C { public int Age { get; set; } }\n"
2265 )
2266 records = [r for r in syms.values() if "Age" in r["name"]]
2267 assert records
2268 assert records[0]["kind"] == "variable"
2269
2270
2271 # ---------------------------------------------------------------------------
2272 # Java: annotation type and record extraction
2273 # ---------------------------------------------------------------------------
2274
2275
2276 class TestJavaExtended:
2277 def _parse(self, src: str) -> SymbolTree:
2278 adapter = adapter_for_path("Main.java")
2279 if isinstance(adapter, FallbackAdapter):
2280 pytest.skip("tree-sitter-java not available")
2281 return adapter.parse_symbols(src.encode(), "Main.java")
2282
2283 def test_annotation_type_extracted(self) -> None:
2284 syms = self._parse("public @interface Cacheable { String value() default \"\"; }\n")
2285 assert any("Cacheable" in k for k in syms), f"keys: {list(syms)}"
2286
2287 def test_record_extracted(self) -> None:
2288 syms = self._parse("public record Point(int x, int y) {}\n")
2289 assert any("Point" in k for k in syms)
2290
2291
2292 # ---------------------------------------------------------------------------
2293 # Kotlin: object declaration and property extraction
2294 # ---------------------------------------------------------------------------
2295
2296
2297 class TestKotlinExtended:
2298 def _parse(self, src: str) -> SymbolTree:
2299 adapter = adapter_for_path("Main.kt")
2300 if isinstance(adapter, FallbackAdapter):
2301 pytest.skip("tree-sitter-kotlin not available")
2302 return adapter.parse_symbols(src.encode(), "Main.kt")
2303
2304 def test_object_declaration_extracted(self) -> None:
2305 syms = self._parse("object Singleton { fun greet() = println(\"hi\") }\n")
2306 assert any("Singleton" in k for k in syms), f"keys: {list(syms)}"
2307
2308 def test_property_declaration_extracted(self) -> None:
2309 syms = self._parse("val MAX_SIZE: Int = 100\n")
2310 assert any("MAX_SIZE" in k for k in syms)
2311
2312
2313 # ---------------------------------------------------------------------------
2314 # Bash / sh / zsh adapter
2315 # ---------------------------------------------------------------------------
2316
2317
2318 class TestBashAdapter:
2319 """Symbol extraction tests for the bash/sh/zsh tree-sitter adapter.
2320
2321 All tests skip gracefully when ``tree-sitter-bash`` is not installed so
2322 they are safe to run in environments that only install a subset of grammars.
2323 The grammar covers bash, sh, and zsh (zsh is a strict backward-compatible
2324 superset of bash at the AST level).
2325 """
2326
2327 def _parse(self, src: str, path: str = "script.sh") -> SymbolTree:
2328 """Parse *src* via the bash adapter; skip if grammar not installed."""
2329 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2330 adapter = adapter_for_path(path)
2331 if isinstance(adapter, FallbackAdapter):
2332 pytest.skip("tree-sitter-bash not installed (pip install 'muse[shell]')")
2333 return adapter.parse_symbols(src.encode(), path)
2334
2335 def test_function_definition_extracted(self) -> None:
2336 """A bare ``function_definition`` node is extracted as a symbol."""
2337 syms = self._parse("greet() {\n echo hello\n}\n")
2338 assert any("greet" in k for k in syms), f"keys: {list(syms)}"
2339
2340 def test_function_kind_is_function(self) -> None:
2341 syms = self._parse("build() {\n make all\n}\n")
2342 matches = [r for r in syms.values() if r["name"] == "build"]
2343 assert matches, "symbol 'build' not found"
2344 assert matches[0]["kind"] == "function"
2345
2346 def test_variable_assignment_extracted(self) -> None:
2347 """Top-level variable assignments are extracted as ``variable`` symbols."""
2348 syms = self._parse("APP_NAME=muse\n")
2349 assert any("APP_NAME" in k for k in syms), f"keys: {list(syms)}"
2350
2351 def test_variable_kind_is_variable(self) -> None:
2352 syms = self._parse("VERSION=1.0.0\n")
2353 matches = [r for r in syms.values() if r["name"] == "VERSION"]
2354 assert matches, "symbol 'VERSION' not found"
2355 assert matches[0]["kind"] == "variable"
2356
2357 def test_multiple_functions_extracted(self) -> None:
2358 src = "init() {\n echo init\n}\ndeploy() {\n echo deploy\n}\n"
2359 syms = self._parse(src)
2360 names = {r["name"] for r in syms.values()}
2361 assert "init" in names
2362 assert "deploy" in names
2363
2364 def test_function_and_variable_coexist(self) -> None:
2365 src = "ENV=prod\nstart() {\n echo starting\n}\n"
2366 syms = self._parse(src)
2367 names = {r["name"] for r in syms.values()}
2368 assert "ENV" in names
2369 assert "start" in names
2370
2371 def test_symbol_record_has_content_id(self) -> None:
2372 syms = self._parse("run() {\n ./app\n}\n")
2373 records = [r for r in syms.values() if r["name"] == "run"]
2374 assert records
2375 cid = records[0]["content_id"]
2376 assert cid.startswith("sha256:") and len(cid) == 71
2377
2378 def test_content_id_stable_across_calls(self) -> None:
2379 src = "setup() {\n echo setup\n}\n"
2380 syms_a = self._parse(src)
2381 syms_b = self._parse(src)
2382 for addr in syms_a:
2383 assert syms_a[addr]["content_id"] == syms_b[addr]["content_id"]
2384
2385 def test_body_hash_differs_for_different_bodies(self) -> None:
2386 sym_a = self._parse("fn() {\n echo a\n}\n")
2387 sym_b = self._parse("fn() {\n echo b\n}\n")
2388 records_a = [r for r in sym_a.values() if r["name"] == "fn"]
2389 records_b = [r for r in sym_b.values() if r["name"] == "fn"]
2390 assert records_a and records_b
2391 assert records_a[0]["body_hash"] != records_b[0]["body_hash"]
2392
2393 def test_sh_extension_routes_to_same_adapter(self) -> None:
2394 """`.sh` and `.bash` share the same grammar package."""
2395 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2396 sh = adapter_for_path("script.sh")
2397 ba = adapter_for_path("script.bash")
2398 if isinstance(sh, FallbackAdapter):
2399 pytest.skip("tree-sitter-bash not installed")
2400 # Both should be the same adapter class (TreeSitterAdapter) and share
2401 # the same supported_extensions set.
2402 assert type(sh) is type(ba)
2403 assert ".sh" in sh.supported_extensions()
2404 assert ".bash" in sh.supported_extensions()
2405
2406 def test_zsh_extension_routed(self) -> None:
2407 """.zsh files route to the bash adapter (zsh is a bash superset)."""
2408 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2409 adapter = adapter_for_path("config.zsh")
2410 if isinstance(adapter, FallbackAdapter):
2411 pytest.skip("tree-sitter-bash not installed")
2412 assert ".zsh" in adapter.supported_extensions()
2413
2414 def test_plugin_zsh_extension_routed(self) -> None:
2415 """.plugin.zsh files route to the bash adapter."""
2416 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2417 adapter = adapter_for_path("muse.plugin.zsh")
2418 if isinstance(adapter, FallbackAdapter):
2419 pytest.skip("tree-sitter-bash not installed")
2420 assert ".plugin.zsh" in adapter.supported_extensions()
2421
2422 def test_parse_zsh_function(self) -> None:
2423 """Zsh function syntax (no ``function`` keyword) parses correctly."""
2424 syms = self._parse("muse_prompt_info() {\n echo branch\n}\n", "muse.plugin.zsh")
2425 assert any("muse_prompt_info" in k for k in syms), f"keys: {list(syms)}"
2426
2427 def test_address_prefix_matches_file_path(self) -> None:
2428 """Symbol addresses are prefixed with the supplied file path."""
2429 syms = self._parse("build() {\n echo\n}\n", "scripts/build.sh")
2430 assert any(k.startswith("scripts/build.sh::") for k in syms), (
2431 f"keys: {list(syms)}"
2432 )
2433
2434 def test_lineno_is_positive(self) -> None:
2435 syms = self._parse("deploy() {\n echo deploy\n}\n")
2436 for rec in syms.values():
2437 assert rec["lineno"] >= 1
2438
2439 def test_empty_file_returns_empty_tree(self) -> None:
2440 syms = self._parse("")
2441 assert syms == {}
2442
2443 def test_comment_only_file_returns_empty_tree(self) -> None:
2444 syms = self._parse("# This is a comment\n# Another comment\n")
2445 assert syms == {}
2446
2447 def test_file_content_id_stable(self) -> None:
2448 """``file_content_id`` is stable for the same bytes."""
2449 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2450 adapter = adapter_for_path("install.sh")
2451 if isinstance(adapter, FallbackAdapter):
2452 pytest.skip("tree-sitter-bash not installed")
2453 src = b"#!/bin/bash\necho hello\n"
2454 assert adapter.file_content_id(src) == adapter.file_content_id(src)
2455
2456 def test_file_content_id_differs_for_different_content(self) -> None:
2457 from muse.plugins.code.ast_parser import FallbackAdapter, adapter_for_path
2458 adapter = adapter_for_path("install.sh")
2459 if isinstance(adapter, FallbackAdapter):
2460 pytest.skip("tree-sitter-bash not installed")
2461 assert adapter.file_content_id(b"echo a\n") != adapter.file_content_id(b"echo b\n")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago