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