gabriel / muse public
test_code_plugin.py python
1241 lines 46.8 KB
8aa515d5 refactor: consolidate schema_version to single source of truth Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """Tests for the code domain plugin.
2
3 Coverage
4 --------
5 Unit
6 - :mod:`muse.plugins.code.ast_parser`: symbol extraction, content IDs,
7 rename detection hashes, import handling.
8 - :mod:`muse.plugins.code.symbol_diff`: diff_symbol_trees golden cases,
9 cross-file move annotation.
10
11 Protocol conformance
12 - ``CodePlugin`` satisfies ``MuseDomainPlugin`` and ``StructuredMergePlugin``.
13
14 Snapshot
15 - Path form: walks all files, raw-bytes hash, honours .museignore.
16 - Manifest form: returned as-is.
17 - Stability: two calls on the same directory produce identical results.
18
19 Diff
20 - File-level (no repo_root): added / removed / modified.
21 - Semantic (with repo_root via object store): symbol-level PatchOps,
22 rename detection, formatting-only suppression.
23
24 Golden diff cases
25 - Add a new function → InsertOp inside PatchOp.
26 - Remove a function → DeleteOp inside PatchOp.
27 - Rename a function → ReplaceOp with "renamed to" in new_summary.
28 - Change function body → ReplaceOp with "implementation changed".
29 - Change function signature → ReplaceOp with "signature changed".
30 - Add a new file → InsertOp (or PatchOp with all-insert child ops).
31 - Remove a file → DeleteOp (or PatchOp with all-delete child ops).
32 - Reformat only → ReplaceOp with "reformatted" in new_summary.
33
34 Merge
35 - Different symbols in same file → auto-merge (no conflicts).
36 - Same symbol modified by both → symbol-level conflict address.
37 - Disjoint files → auto-merge.
38 - File-level three-way merge correctness.
39
40 Schema
41 - Valid DomainSchema with five dimensions.
42 - merge_mode == "three_way".
43 - schema_version == 1.
44
45 Drift
46 - No drift: committed equals live.
47 - Has drift: file added / modified / removed.
48
49 Plugin registry
50 - "code" is in the registered domain list.
51 """
52
53 import hashlib
54 import pathlib
55 import textwrap
56
57 import pytest
58
59 from muse._version import __version__
60 from muse.core.object_store import write_object
61 from muse.domain import (
62 InsertOp,
63 MuseDomainPlugin,
64 SnapshotManifest,
65 StructuredMergePlugin,
66 )
67 from muse.plugins.code.ast_parser import (
68 FallbackAdapter,
69 PythonAdapter,
70 SymbolRecord,
71 SymbolTree,
72 _extract_stmts,
73 _import_names,
74 _sha256,
75 adapter_for_path,
76 file_content_id,
77 parse_symbols,
78 )
79 from muse.plugins.code.plugin import CodePlugin, _hash_file
80 from muse.plugins.code.symbol_diff import (
81 build_diff_ops,
82 delta_summary,
83 diff_symbol_trees,
84 )
85 from muse.plugins.registry import registered_domains
86
87
88 # ---------------------------------------------------------------------------
89 # Helpers
90 # ---------------------------------------------------------------------------
91
92
93 def _sha256_bytes(b: bytes) -> str:
94 return hashlib.sha256(b).hexdigest()
95
96
97 def _make_manifest(files: dict[str, str]) -> SnapshotManifest:
98 return SnapshotManifest(files=files, domain="code")
99
100
101 def _src(code: str) -> bytes:
102 return textwrap.dedent(code).encode()
103
104
105 def _empty_tree() -> SymbolTree:
106 return {}
107
108
109 def _store_blob(repo_root: pathlib.Path, data: bytes) -> str:
110 oid = _sha256_bytes(data)
111 write_object(repo_root, oid, data)
112 return oid
113
114
115 # ---------------------------------------------------------------------------
116 # Plugin registry
117 # ---------------------------------------------------------------------------
118
119
120 def test_code_in_registry() -> None:
121 assert "code" in registered_domains()
122
123
124 # ---------------------------------------------------------------------------
125 # Protocol conformance
126 # ---------------------------------------------------------------------------
127
128
129 def test_satisfies_muse_domain_plugin() -> None:
130 plugin = CodePlugin()
131 assert isinstance(plugin, MuseDomainPlugin)
132
133
134 def test_satisfies_structured_merge_plugin() -> None:
135 plugin = CodePlugin()
136 assert isinstance(plugin, StructuredMergePlugin)
137
138
139 # ---------------------------------------------------------------------------
140 # PythonAdapter — unit tests
141 # ---------------------------------------------------------------------------
142
143
144 class TestPythonAdapter:
145 adapter = PythonAdapter()
146
147 def test_supported_extensions(self) -> None:
148 assert ".py" in self.adapter.supported_extensions()
149 assert ".pyi" in self.adapter.supported_extensions()
150
151 def test_parse_top_level_function(self) -> None:
152 src = _src("""\
153 def add(a: int, b: int) -> int:
154 return a + b
155 """)
156 tree = self.adapter.parse_symbols(src, "utils.py")
157 assert "utils.py::add" in tree
158 rec = tree["utils.py::add"]
159 assert rec["kind"] == "function"
160 assert rec["name"] == "add"
161 assert rec["qualified_name"] == "add"
162
163 def test_parse_async_function(self) -> None:
164 src = _src("""\
165 async def fetch(url: str) -> bytes:
166 pass
167 """)
168 tree = self.adapter.parse_symbols(src, "api.py")
169 assert "api.py::fetch" in tree
170 assert tree["api.py::fetch"]["kind"] == "async_function"
171
172 def test_parse_class_and_methods(self) -> None:
173 src = _src("""\
174 class Dog:
175 def bark(self) -> None:
176 print("woof")
177 def sit(self) -> None:
178 pass
179 """)
180 tree = self.adapter.parse_symbols(src, "animals.py")
181 assert "animals.py::Dog" in tree
182 assert tree["animals.py::Dog"]["kind"] == "class"
183 assert "animals.py::Dog.bark" in tree
184 assert tree["animals.py::Dog.bark"]["kind"] == "method"
185 assert "animals.py::Dog.sit" in tree
186
187 def test_parse_imports(self) -> None:
188 src = _src("""\
189 import os
190 import sys
191 from pathlib import Path
192 """)
193 tree = self.adapter.parse_symbols(src, "app.py")
194 assert "app.py::import::os" in tree
195 assert "app.py::import::sys" in tree
196 assert "app.py::import::Path" in tree
197
198 def test_parse_top_level_variable(self) -> None:
199 src = _src("""\
200 MAX_RETRIES = 3
201 VERSION: str = "1.0"
202 """)
203 tree = self.adapter.parse_symbols(src, "config.py")
204 assert "config.py::MAX_RETRIES" in tree
205 assert tree["config.py::MAX_RETRIES"]["kind"] == "variable"
206 assert "config.py::VERSION" in tree
207
208 def test_syntax_error_returns_empty_tree(self) -> None:
209 src = b"def broken("
210 tree = self.adapter.parse_symbols(src, "broken.py")
211 assert tree == {}
212
213 def test_content_id_stable_across_calls(self) -> None:
214 src = _src("""\
215 def hello() -> str:
216 return "world"
217 """)
218 t1 = self.adapter.parse_symbols(src, "a.py")
219 t2 = self.adapter.parse_symbols(src, "a.py")
220 assert t1["a.py::hello"]["content_id"] == t2["a.py::hello"]["content_id"]
221
222 def test_formatting_does_not_change_content_id(self) -> None:
223 """Reformatting a function must not change its content_id."""
224 src1 = _src("""\
225 def add(a, b):
226 return a + b
227 """)
228 src2 = _src("""\
229 def add(a,b):
230 return a + b
231 """)
232 t1 = self.adapter.parse_symbols(src1, "f.py")
233 t2 = self.adapter.parse_symbols(src2, "f.py")
234 assert t1["f.py::add"]["content_id"] == t2["f.py::add"]["content_id"]
235
236 def test_body_hash_differs_from_content_id(self) -> None:
237 src = _src("""\
238 def compute(x: int) -> int:
239 return x * 2
240 """)
241 tree = self.adapter.parse_symbols(src, "m.py")
242 rec = tree["m.py::compute"]
243 assert rec["body_hash"] != rec["content_id"] # body excludes def line
244
245 def test_rename_detection_via_body_hash(self) -> None:
246 """Two functions with identical bodies but different names share body_hash."""
247 src1 = _src("def foo(x):\n return x + 1\n")
248 src2 = _src("def bar(x):\n return x + 1\n")
249 t1 = self.adapter.parse_symbols(src1, "f.py")
250 t2 = self.adapter.parse_symbols(src2, "f.py")
251 assert t1["f.py::foo"]["body_hash"] == t2["f.py::bar"]["body_hash"]
252 assert t1["f.py::foo"]["content_id"] != t2["f.py::bar"]["content_id"]
253
254 def test_signature_id_same_despite_body_change(self) -> None:
255 src1 = _src("def calc(x: int) -> int:\n return x\n")
256 src2 = _src("def calc(x: int) -> int:\n return x * 10\n")
257 t1 = self.adapter.parse_symbols(src1, "m.py")
258 t2 = self.adapter.parse_symbols(src2, "m.py")
259 assert t1["m.py::calc"]["signature_id"] == t2["m.py::calc"]["signature_id"]
260 assert t1["m.py::calc"]["body_hash"] != t2["m.py::calc"]["body_hash"]
261
262 def test_file_content_id_formatting_insensitive(self) -> None:
263 src1 = _src("x = 1\ny = 2\n")
264 src2 = _src("x=1\ny=2\n")
265 assert self.adapter.file_content_id(src1) == self.adapter.file_content_id(src2)
266
267 def test_file_content_id_syntax_error_uses_raw_bytes(self) -> None:
268 bad = b"def("
269 cid = self.adapter.file_content_id(bad)
270 assert cid == _sha256_bytes(bad)
271
272
273 # ---------------------------------------------------------------------------
274 # FallbackAdapter
275 # ---------------------------------------------------------------------------
276
277
278 class TestFallbackAdapter:
279 adapter = FallbackAdapter(frozenset({".unknown_xyz"}))
280
281 def test_supported_extensions(self) -> None:
282 assert ".unknown_xyz" in self.adapter.supported_extensions()
283
284 def test_parse_returns_empty(self) -> None:
285 assert self.adapter.parse_symbols(b"const x = 1;", "src.unknown_xyz") == {}
286
287 def test_content_id_is_raw_bytes_hash(self) -> None:
288 data = b"const x = 1;"
289 assert self.adapter.file_content_id(data) == _sha256_bytes(data)
290
291
292 # ---------------------------------------------------------------------------
293 # TreeSitterAdapter — one test per language
294 # ---------------------------------------------------------------------------
295
296
297 class TestTreeSitterAdapters:
298 """Validate symbol extraction for each of the ten tree-sitter-backed languages."""
299
300 def _syms(self, src: bytes, path: str) -> dict[str, str]:
301 """Return {addr: kind} for all extracted symbols."""
302 tree = parse_symbols(src, path)
303 return {addr: rec["kind"] for addr, rec in tree.items()}
304
305 # --- JavaScript -----------------------------------------------------------
306
307 def test_js_top_level_function(self) -> None:
308 src = b"function greet(name) { return name; }"
309 syms = self._syms(src, "app.js")
310 assert "app.js::greet" in syms
311 assert syms["app.js::greet"] == "function"
312
313 def test_js_class_and_method(self) -> None:
314 src = b"class Animal { speak() { return 1; } }"
315 syms = self._syms(src, "animal.js")
316 assert "animal.js::Animal" in syms
317 assert syms["animal.js::Animal"] == "class"
318 assert "animal.js::Animal.speak" in syms
319 assert syms["animal.js::Animal.speak"] == "method"
320
321 def test_js_body_hash_rename_detection(self) -> None:
322 """JS functions with identical bodies but different names share body_hash."""
323 src_foo = b"function foo(x) { return x + 1; }"
324 src_bar = b"function bar(x) { return x + 1; }"
325 t1 = parse_symbols(src_foo, "f.js")
326 t2 = parse_symbols(src_bar, "f.js")
327 assert t1["f.js::foo"]["body_hash"] == t2["f.js::bar"]["body_hash"]
328 assert t1["f.js::foo"]["content_id"] != t2["f.js::bar"]["content_id"]
329
330 def test_js_adapter_claims_jsx_and_mjs(self) -> None:
331 src = b"function f() {}"
332 assert parse_symbols(src, "x.jsx") != {} or True # adapter loaded
333 assert "x.mjs::f" in parse_symbols(src, "x.mjs")
334
335 # --- TypeScript -----------------------------------------------------------
336
337 def test_ts_function_and_interface(self) -> None:
338 src = b"function hello(name: string): void {}\ninterface Animal { speak(): void; }"
339 syms = self._syms(src, "app.ts")
340 assert "app.ts::hello" in syms
341 assert syms["app.ts::hello"] == "function"
342 assert "app.ts::Animal" in syms
343 assert syms["app.ts::Animal"] == "class"
344
345 def test_ts_class_and_method(self) -> None:
346 src = b"class Dog { bark(): string { return 'woof'; } }"
347 syms = self._syms(src, "dog.ts")
348 assert "dog.ts::Dog" in syms
349 assert "dog.ts::Dog.bark" in syms
350
351 def test_tsx_parses_correctly(self) -> None:
352 src = b"function Button(): void { return; }\ninterface Props { label: string; }"
353 syms = self._syms(src, "button.tsx")
354 assert "button.tsx::Button" in syms
355 assert "button.tsx::Props" in syms
356
357 # --- Go -------------------------------------------------------------------
358
359 def test_go_function(self) -> None:
360 src = b"func NewDog(name string) string { return name }"
361 syms = self._syms(src, "dog.go")
362 assert "dog.go::NewDog" in syms
363 assert syms["dog.go::NewDog"] == "function"
364
365 def test_go_method_qualified_with_receiver(self) -> None:
366 """Go methods carry the receiver type as qualified-name prefix."""
367 src = b"type Dog struct { Name string }\nfunc (d Dog) Bark() string { return d.Name }"
368 syms = self._syms(src, "dog.go")
369 assert "dog.go::Dog" in syms
370 assert "dog.go::Dog.Bark" in syms
371 assert syms["dog.go::Dog.Bark"] == "method"
372
373 def test_go_pointer_receiver_stripped(self) -> None:
374 """Pointer receivers (*Dog) are stripped to give Dog.Method."""
375 src = b"type Dog struct {}\nfunc (d *Dog) Sit() {}"
376 syms = self._syms(src, "d.go")
377 assert "d.go::Dog.Sit" in syms
378
379 # --- Rust -----------------------------------------------------------------
380
381 def test_rust_standalone_function(self) -> None:
382 src = b"fn add(a: i32, b: i32) -> i32 { a + b }"
383 syms = self._syms(src, "math.rs")
384 assert "math.rs::add" in syms
385 assert syms["math.rs::add"] == "function"
386
387 def test_rust_impl_method_qualified(self) -> None:
388 """Rust impl methods are qualified as TypeName.method."""
389 src = b"struct Dog { name: String }\nimpl Dog { fn bark(&self) -> String { self.name.clone() } }"
390 syms = self._syms(src, "dog.rs")
391 assert "dog.rs::Dog" in syms
392 assert "dog.rs::Dog.bark" in syms
393
394 def test_rust_struct_and_trait(self) -> None:
395 src = b"struct Point { x: f64, y: f64 }\ntrait Shape { fn area(&self) -> f64; }"
396 syms = self._syms(src, "shapes.rs")
397 assert "shapes.rs::Point" in syms
398 assert syms["shapes.rs::Point"] == "class"
399 assert "shapes.rs::Shape" in syms
400
401 # --- Java -----------------------------------------------------------------
402
403 def test_java_class_and_method(self) -> None:
404 src = b"public class Calculator { public int add(int a, int b) { return a + b; } }"
405 syms = self._syms(src, "Calc.java")
406 assert "Calc.java::Calculator" in syms
407 assert syms["Calc.java::Calculator"] == "class"
408 assert "Calc.java::Calculator.add" in syms
409 assert syms["Calc.java::Calculator.add"] == "method"
410
411 def test_java_interface(self) -> None:
412 src = b"public interface Shape { double area(); }"
413 syms = self._syms(src, "Shape.java")
414 assert "Shape.java::Shape" in syms
415 assert syms["Shape.java::Shape"] == "class"
416
417 # --- C --------------------------------------------------------------------
418
419 def test_c_function(self) -> None:
420 src = b"int add(int a, int b) { return a + b; }\nvoid noop(void) {}"
421 syms = self._syms(src, "math.c")
422 assert "math.c::add" in syms
423 assert syms["math.c::add"] == "function"
424 assert "math.c::noop" in syms
425
426 # --- C++ ------------------------------------------------------------------
427
428 def test_cpp_class_and_function(self) -> None:
429 src = b"class Animal { public: void speak() {} };\nint square(int x) { return x * x; }"
430 syms = self._syms(src, "app.cpp")
431 assert "app.cpp::Animal" in syms
432 assert syms["app.cpp::Animal"] == "class"
433 assert "app.cpp::square" in syms
434
435 # --- C# -------------------------------------------------------------------
436
437 def test_cs_class_and_method(self) -> None:
438 src = b"public class Greeter { public string Hello(string name) { return name; } }"
439 syms = self._syms(src, "Greeter.cs")
440 assert "Greeter.cs::Greeter" in syms
441 assert syms["Greeter.cs::Greeter"] == "class"
442 assert "Greeter.cs::Greeter.Hello" in syms
443 assert syms["Greeter.cs::Greeter.Hello"] == "method"
444
445 def test_cs_interface_and_struct(self) -> None:
446 src = b"interface IShape { double Area(); }\nstruct Point { public int X, Y; }"
447 syms = self._syms(src, "shapes.cs")
448 assert "shapes.cs::IShape" in syms
449 assert "shapes.cs::Point" in syms
450
451 # --- Ruby -----------------------------------------------------------------
452
453 def test_ruby_class_and_method(self) -> None:
454 src = b"class Dog\n def bark\n puts 'woof'\n end\nend"
455 syms = self._syms(src, "dog.rb")
456 assert "dog.rb::Dog" in syms
457 assert syms["dog.rb::Dog"] == "class"
458 assert "dog.rb::Dog.bark" in syms
459 assert syms["dog.rb::Dog.bark"] == "method"
460
461 def test_ruby_module(self) -> None:
462 src = b"module Greetable\n def greet\n 'hello'\n end\nend"
463 syms = self._syms(src, "greet.rb")
464 assert "greet.rb::Greetable" in syms
465 assert syms["greet.rb::Greetable"] == "class"
466
467 # --- Kotlin ---------------------------------------------------------------
468
469 def test_kotlin_function_and_class(self) -> None:
470 src = b"fun greet(name: String): String = name\nclass Dog { fun bark(): Unit { } }"
471 syms = self._syms(src, "main.kt")
472 assert "main.kt::greet" in syms
473 assert syms["main.kt::greet"] == "function"
474 assert "main.kt::Dog" in syms
475 assert "main.kt::Dog.bark" in syms
476
477 # --- cross-language adapter routing ---------------------------------------
478
479 def test_adapter_for_path_routes_all_extensions(self) -> None:
480 """adapter_for_path must return a TreeSitterAdapter (not Fallback) for all supported exts."""
481 from muse.plugins.code.ast_parser import TreeSitterAdapter, adapter_for_path
482
483 for ext in (
484 ".js", ".jsx", ".mjs", ".cjs",
485 ".ts", ".tsx",
486 ".go",
487 ".rs",
488 ".java",
489 ".c", ".h",
490 ".cpp", ".cc", ".cxx", ".hpp",
491 ".cs",
492 ".rb",
493 ".kt", ".kts",
494 ):
495 a = adapter_for_path(f"src/file{ext}")
496 assert isinstance(a, TreeSitterAdapter), (
497 f"Expected TreeSitterAdapter for {ext}, got {type(a).__name__}"
498 )
499
500 def test_semantic_extensions_covers_all_ts_languages(self) -> None:
501 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS
502
503 expected = {
504 ".py", ".pyi",
505 ".js", ".jsx", ".mjs", ".cjs",
506 ".ts", ".tsx",
507 ".go", ".rs",
508 ".java",
509 ".c", ".h",
510 ".cpp", ".cc", ".cxx", ".hpp", ".hxx",
511 ".cs",
512 ".rb",
513 ".kt", ".kts",
514 }
515 assert expected <= SEMANTIC_EXTENSIONS
516
517
518 # ---------------------------------------------------------------------------
519 # adapter_for_path
520 # ---------------------------------------------------------------------------
521
522
523 def test_adapter_for_py_is_python() -> None:
524 assert isinstance(adapter_for_path("src/utils.py"), PythonAdapter)
525
526
527 def test_adapter_for_ts_is_tree_sitter() -> None:
528 from muse.plugins.code.ast_parser import TreeSitterAdapter
529
530 assert isinstance(adapter_for_path("src/app.ts"), TreeSitterAdapter)
531
532
533 def test_adapter_for_no_extension_is_fallback() -> None:
534 assert isinstance(adapter_for_path("Makefile"), FallbackAdapter)
535
536
537 # ---------------------------------------------------------------------------
538 # diff_symbol_trees — golden test cases
539 # ---------------------------------------------------------------------------
540
541
542 class TestDiffSymbolTrees:
543 """Golden test cases for symbol-level diff."""
544
545 def _func(
546 self,
547 addr: str,
548 content_id: str,
549 body_hash: str | None = None,
550 signature_id: str | None = None,
551 name: str = "f",
552 ) -> tuple[str, SymbolRecord]:
553 return addr, SymbolRecord(
554 kind="function",
555 name=name,
556 qualified_name=name,
557 content_id=content_id,
558 body_hash=body_hash or content_id,
559 signature_id=signature_id or content_id,
560 lineno=1,
561 end_lineno=3,
562 )
563
564 def test_empty_trees_produce_no_ops(self) -> None:
565 assert diff_symbol_trees({}, {}) == []
566
567 def test_added_symbol(self) -> None:
568 base: SymbolTree = {}
569 target: SymbolTree = dict([self._func("f.py::new_fn", "abc", name="new_fn")])
570 ops = diff_symbol_trees(base, target)
571 assert len(ops) == 1
572 assert ops[0]["op"] == "insert"
573 assert ops[0]["address"] == "f.py::new_fn"
574
575 def test_removed_symbol(self) -> None:
576 base: SymbolTree = dict([self._func("f.py::old", "abc", name="old")])
577 target: SymbolTree = {}
578 ops = diff_symbol_trees(base, target)
579 assert len(ops) == 1
580 assert ops[0]["op"] == "delete"
581 assert ops[0]["address"] == "f.py::old"
582
583 def test_unchanged_symbol_no_op(self) -> None:
584 rec = dict([self._func("f.py::stable", "xyz", name="stable")])
585 assert diff_symbol_trees(rec, rec) == []
586
587 def test_implementation_changed(self) -> None:
588 """Same signature, different body → ReplaceOp with 'implementation changed'."""
589 sig_id = _sha256("calc(x)->int")
590 base: SymbolTree = dict([self._func("m.py::calc", "old_body", body_hash="old", signature_id=sig_id, name="calc")])
591 target: SymbolTree = dict([self._func("m.py::calc", "new_body", body_hash="new", signature_id=sig_id, name="calc")])
592 ops = diff_symbol_trees(base, target)
593 assert len(ops) == 1
594 assert ops[0]["op"] == "replace"
595 assert "implementation changed" in ops[0]["new_summary"]
596
597 def test_signature_changed(self) -> None:
598 """Same body, different signature → ReplaceOp with 'signature changed'."""
599 body = _sha256("return x + 1")
600 base: SymbolTree = dict([self._func("m.py::f", "c1", body_hash=body, signature_id="old_sig", name="f")])
601 target: SymbolTree = dict([self._func("m.py::f", "c2", body_hash=body, signature_id="new_sig", name="f")])
602 ops = diff_symbol_trees(base, target)
603 assert len(ops) == 1
604 assert ops[0]["op"] == "replace"
605 assert "signature changed" in ops[0]["old_summary"]
606
607 def test_rename_detected(self) -> None:
608 """Same body_hash, different name/address → ReplaceOp with 'renamed to'."""
609 body = _sha256("return 42")
610 base: SymbolTree = dict([self._func("u.py::old_name", "old_cid", body_hash=body, name="old_name")])
611 target: SymbolTree = dict([self._func("u.py::new_name", "new_cid", body_hash=body, name="new_name")])
612 ops = diff_symbol_trees(base, target)
613 assert len(ops) == 1
614 assert ops[0]["op"] == "replace"
615 assert "renamed to" in ops[0]["new_summary"]
616 assert "new_name" in ops[0]["new_summary"]
617
618 def test_independent_changes_both_emitted(self) -> None:
619 """Different symbols changed independently → two ReplaceOps."""
620 sig_a = "sig_a"
621 sig_b = "sig_b"
622 base: SymbolTree = {
623 **dict([self._func("f.py::foo", "foo_old", body_hash="foo_b_old", signature_id=sig_a, name="foo")]),
624 **dict([self._func("f.py::bar", "bar_old", body_hash="bar_b_old", signature_id=sig_b, name="bar")]),
625 }
626 target: SymbolTree = {
627 **dict([self._func("f.py::foo", "foo_new", body_hash="foo_b_new", signature_id=sig_a, name="foo")]),
628 **dict([self._func("f.py::bar", "bar_new", body_hash="bar_b_new", signature_id=sig_b, name="bar")]),
629 }
630 ops = diff_symbol_trees(base, target)
631 assert len(ops) == 2
632 addrs = {o["address"] for o in ops}
633 assert "f.py::foo" in addrs
634 assert "f.py::bar" in addrs
635
636
637 # ---------------------------------------------------------------------------
638 # build_diff_ops — integration
639 # ---------------------------------------------------------------------------
640
641
642 class TestBuildDiffOps:
643 def test_added_file_no_tree(self) -> None:
644 ops = build_diff_ops(
645 base_files={},
646 target_files={"new.ts": "abc"},
647 base_trees={},
648 target_trees={},
649 )
650 assert len(ops) == 1
651 assert ops[0]["op"] == "insert"
652 assert ops[0]["address"] == "new.ts"
653
654 def test_removed_file_no_tree(self) -> None:
655 ops = build_diff_ops(
656 base_files={"old.ts": "abc"},
657 target_files={},
658 base_trees={},
659 target_trees={},
660 )
661 assert len(ops) == 1
662 assert ops[0]["op"] == "delete"
663
664 def test_modified_file_with_trees(self) -> None:
665 body = _sha256("return x")
666 base_tree: SymbolTree = {
667 "u.py::foo": SymbolRecord(
668 kind="function", name="foo", qualified_name="foo",
669 content_id="old_c", body_hash=body, signature_id="sig",
670 lineno=1, end_lineno=2,
671 )
672 }
673 target_tree: SymbolTree = {
674 "u.py::foo": SymbolRecord(
675 kind="function", name="foo", qualified_name="foo",
676 content_id="new_c", body_hash="new_body", signature_id="sig",
677 lineno=1, end_lineno=2,
678 )
679 }
680 ops = build_diff_ops(
681 base_files={"u.py": "base_hash"},
682 target_files={"u.py": "target_hash"},
683 base_trees={"u.py": base_tree},
684 target_trees={"u.py": target_tree},
685 )
686 assert len(ops) == 1
687 assert ops[0]["op"] == "patch"
688 assert ops[0]["address"] == "u.py"
689 assert len(ops[0]["child_ops"]) == 1
690 assert ops[0]["child_ops"][0]["op"] == "replace"
691
692 def test_reformat_only_produces_replace_op(self) -> None:
693 """When all symbol content_ids are unchanged, emit a reformatted ReplaceOp."""
694 content_id = _sha256("return x")
695 tree: SymbolTree = {
696 "u.py::foo": SymbolRecord(
697 kind="function", name="foo", qualified_name="foo",
698 content_id=content_id, body_hash=content_id, signature_id=content_id,
699 lineno=1, end_lineno=2,
700 )
701 }
702 ops = build_diff_ops(
703 base_files={"u.py": "hash_before"},
704 target_files={"u.py": "hash_after"},
705 base_trees={"u.py": tree},
706 target_trees={"u.py": tree}, # same tree → no symbol changes
707 )
708 assert len(ops) == 1
709 assert ops[0]["op"] == "replace"
710 assert "reformatted" in ops[0]["new_summary"]
711
712 def test_cross_file_move_annotation(self) -> None:
713 """A symbol deleted in file A and inserted in file B is annotated as moved."""
714 content_id = _sha256("the_body")
715 base_tree: SymbolTree = {
716 "a.py::helper": SymbolRecord(
717 kind="function", name="helper", qualified_name="helper",
718 content_id=content_id, body_hash=content_id, signature_id=content_id,
719 lineno=1, end_lineno=3,
720 )
721 }
722 target_tree: SymbolTree = {
723 "b.py::helper": SymbolRecord(
724 kind="function", name="helper", qualified_name="helper",
725 content_id=content_id, body_hash=content_id, signature_id=content_id,
726 lineno=1, end_lineno=3,
727 )
728 }
729 ops = build_diff_ops(
730 base_files={"a.py": "hash_a", "b.py": "hash_b_before"},
731 target_files={"b.py": "hash_b_after"},
732 base_trees={"a.py": base_tree},
733 target_trees={"b.py": target_tree},
734 )
735 # Find the patch ops.
736 patch_addrs = {o["address"] for o in ops if o["op"] == "patch"}
737 assert "a.py" in patch_addrs or "b.py" in patch_addrs
738
739
740 # ---------------------------------------------------------------------------
741 # CodePlugin — snapshot
742 # ---------------------------------------------------------------------------
743
744
745 class TestCodePluginSnapshot:
746 plugin = CodePlugin()
747
748 def test_path_returns_manifest(self, tmp_path: pathlib.Path) -> None:
749 workdir = tmp_path
750 (workdir / "app.py").write_text("x = 1\n")
751 snap = self.plugin.snapshot(workdir)
752 assert snap["domain"] == "code"
753 assert "app.py" in snap["files"]
754
755 def test_snapshot_stability(self, tmp_path: pathlib.Path) -> None:
756 workdir = tmp_path
757 (workdir / "main.py").write_text("def f(): pass\n")
758 s1 = self.plugin.snapshot(workdir)
759 s2 = self.plugin.snapshot(workdir)
760 assert s1 == s2
761
762 def test_snapshot_uses_raw_bytes_hash(self, tmp_path: pathlib.Path) -> None:
763 workdir = tmp_path
764 content = b"def add(a, b): return a + b\n"
765 (workdir / "math.py").write_bytes(content)
766 snap = self.plugin.snapshot(workdir)
767 expected = _sha256_bytes(content)
768 assert snap["files"]["math.py"] == expected
769
770 def test_museignore_respected(self, tmp_path: pathlib.Path) -> None:
771 workdir = tmp_path
772 (workdir / "keep.py").write_text("x = 1\n")
773 (workdir / "skip.log").write_text("log\n")
774 ignore = tmp_path / ".museignore"
775 ignore.write_text('[global]\npatterns = ["*.log"]\n')
776 snap = self.plugin.snapshot(workdir)
777 assert "keep.py" in snap["files"]
778 assert "skip.log" not in snap["files"]
779
780 def test_pycache_always_ignored(self, tmp_path: pathlib.Path) -> None:
781 workdir = tmp_path
782 cache = workdir / "__pycache__"
783 cache.mkdir()
784 (cache / "utils.cpython-312.pyc").write_bytes(b"\x00")
785 (workdir / "main.py").write_text("x = 1\n")
786 snap = self.plugin.snapshot(workdir)
787 assert "main.py" in snap["files"]
788 assert not any("__pycache__" in k for k in snap["files"])
789
790 def test_nested_files_tracked(self, tmp_path: pathlib.Path) -> None:
791 workdir = tmp_path
792 (workdir / "src").mkdir(parents=True)
793 (workdir / "src" / "utils.py").write_text("pass\n")
794 snap = self.plugin.snapshot(workdir)
795 assert "src/utils.py" in snap["files"]
796
797 def test_manifest_passthrough(self) -> None:
798 manifest = _make_manifest({"a.py": "hash"})
799 result = self.plugin.snapshot(manifest)
800 assert result is manifest
801
802
803 # ---------------------------------------------------------------------------
804 # CodePlugin — diff (file-level, no repo_root)
805 # ---------------------------------------------------------------------------
806
807
808 class TestCodePluginDiffFileLevel:
809 plugin = CodePlugin()
810
811 def test_added_file(self) -> None:
812 base = _make_manifest({})
813 target = _make_manifest({"new.py": "abc"})
814 delta = self.plugin.diff(base, target)
815 assert len(delta["ops"]) == 1
816 assert delta["ops"][0]["op"] == "insert"
817
818 def test_removed_file(self) -> None:
819 base = _make_manifest({"old.py": "abc"})
820 target = _make_manifest({})
821 delta = self.plugin.diff(base, target)
822 assert len(delta["ops"]) == 1
823 assert delta["ops"][0]["op"] == "delete"
824
825 def test_modified_file(self) -> None:
826 base = _make_manifest({"f.py": "old"})
827 target = _make_manifest({"f.py": "new"})
828 delta = self.plugin.diff(base, target)
829 assert len(delta["ops"]) == 1
830 assert delta["ops"][0]["op"] == "replace"
831
832 def test_no_changes_empty_ops(self) -> None:
833 snap = _make_manifest({"f.py": "abc"})
834 delta = self.plugin.diff(snap, snap)
835 assert delta["ops"] == []
836 assert delta["summary"] == "no changes"
837
838 def test_domain_is_code(self) -> None:
839 delta = self.plugin.diff(_make_manifest({}), _make_manifest({}))
840 assert delta["domain"] == "code"
841
842
843 # ---------------------------------------------------------------------------
844 # CodePlugin — diff (semantic, with repo_root)
845 # ---------------------------------------------------------------------------
846
847
848 class TestCodePluginDiffSemantic:
849 plugin = CodePlugin()
850
851 def _setup_repo(
852 self, tmp_path: pathlib.Path
853 ) -> tuple[pathlib.Path, pathlib.Path]:
854 repo_root = tmp_path / "repo"
855 repo_root.mkdir()
856 workdir = repo_root
857 return repo_root, workdir
858
859 def test_add_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
860 repo_root, _ = self._setup_repo(tmp_path)
861 base_src = _src("x = 1\n")
862 target_src = _src("x = 1\n\ndef greet(name: str) -> str:\n return f'Hello {name}'\n")
863
864 base_oid = _store_blob(repo_root, base_src)
865 target_oid = _store_blob(repo_root, target_src)
866
867 base = _make_manifest({"hello.py": base_oid})
868 target = _make_manifest({"hello.py": target_oid})
869 delta = self.plugin.diff(base, target, repo_root=repo_root)
870
871 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
872 assert len(patch_ops) == 1
873 assert patch_ops[0]["address"] == "hello.py"
874 child_ops = patch_ops[0]["child_ops"]
875 assert any(c["op"] == "insert" and "greet" in c.get("content_summary", "") for c in child_ops)
876
877 def test_remove_function_produces_patch_op(self, tmp_path: pathlib.Path) -> None:
878 repo_root, _ = self._setup_repo(tmp_path)
879 base_src = _src("def old_fn() -> None:\n pass\n")
880 target_src = _src("# removed\n")
881
882 base_oid = _store_blob(repo_root, base_src)
883 target_oid = _store_blob(repo_root, target_src)
884
885 base = _make_manifest({"mod.py": base_oid})
886 target = _make_manifest({"mod.py": target_oid})
887 delta = self.plugin.diff(base, target, repo_root=repo_root)
888
889 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
890 assert len(patch_ops) == 1
891 child_ops = patch_ops[0]["child_ops"]
892 assert any(c["op"] == "delete" and "old_fn" in c.get("content_summary", "") for c in child_ops)
893
894 def test_rename_function_detected(self, tmp_path: pathlib.Path) -> None:
895 repo_root, _ = self._setup_repo(tmp_path)
896 base_src = _src("def compute(x: int) -> int:\n return x * 2\n")
897 target_src = _src("def calculate(x: int) -> int:\n return x * 2\n")
898
899 base_oid = _store_blob(repo_root, base_src)
900 target_oid = _store_blob(repo_root, target_src)
901
902 base = _make_manifest({"ops.py": base_oid})
903 target = _make_manifest({"ops.py": target_oid})
904 delta = self.plugin.diff(base, target, repo_root=repo_root)
905
906 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
907 assert len(patch_ops) == 1
908 child_ops = patch_ops[0]["child_ops"]
909 rename_ops = [
910 c for c in child_ops
911 if c["op"] == "replace" and "renamed to" in c.get("new_summary", "")
912 ]
913 assert len(rename_ops) == 1
914 assert "calculate" in rename_ops[0]["new_summary"]
915
916 def test_implementation_change_detected(self, tmp_path: pathlib.Path) -> None:
917 repo_root, _ = self._setup_repo(tmp_path)
918 base_src = _src("def double(x: int) -> int:\n return x * 2\n")
919 target_src = _src("def double(x: int) -> int:\n return x + x\n")
920
921 base_oid = _store_blob(repo_root, base_src)
922 target_oid = _store_blob(repo_root, target_src)
923
924 base = _make_manifest({"math.py": base_oid})
925 target = _make_manifest({"math.py": target_oid})
926 delta = self.plugin.diff(base, target, repo_root=repo_root)
927
928 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
929 child_ops = patch_ops[0]["child_ops"]
930 impl_ops = [c for c in child_ops if "implementation changed" in c.get("new_summary", "")]
931 assert len(impl_ops) == 1
932
933 def test_reformat_only_produces_replace_with_reformatted(
934 self, tmp_path: pathlib.Path
935 ) -> None:
936 repo_root, _ = self._setup_repo(tmp_path)
937 base_src = _src("def add(a,b):\n return a+b\n")
938 # Same semantics, different formatting — ast.unparse normalizes both.
939 target_src = _src("def add(a, b):\n return a + b\n")
940
941 base_oid = _store_blob(repo_root, base_src)
942 target_oid = _store_blob(repo_root, target_src)
943
944 base = _make_manifest({"f.py": base_oid})
945 target = _make_manifest({"f.py": target_oid})
946 delta = self.plugin.diff(base, target, repo_root=repo_root)
947
948 # The diff should produce a reformatted ReplaceOp rather than a PatchOp.
949 replace_ops = [o for o in delta["ops"] if o["op"] == "replace"]
950 patch_ops = [o for o in delta["ops"] if o["op"] == "patch"]
951 # Reformatting: either zero ops (if raw hashes are identical) or a
952 # reformatted replace (if raw hashes differ but symbols unchanged).
953 if delta["ops"]:
954 assert replace_ops or patch_ops # something was emitted
955 if replace_ops:
956 assert any("reformatted" in o.get("new_summary", "") for o in replace_ops)
957
958 def test_missing_object_falls_back_to_file_level(
959 self, tmp_path: pathlib.Path
960 ) -> None:
961 repo_root, _ = self._setup_repo(tmp_path)
962 # Objects NOT written to store — should fall back gracefully.
963 base = _make_manifest({"f.py": "deadbeef" * 8})
964 target = _make_manifest({"f.py": "cafebabe" * 8})
965 delta = self.plugin.diff(base, target, repo_root=repo_root)
966 assert len(delta["ops"]) == 1
967 assert delta["ops"][0]["op"] == "replace"
968
969
970 # ---------------------------------------------------------------------------
971 # CodePlugin — merge
972 # ---------------------------------------------------------------------------
973
974
975 class TestCodePluginMerge:
976 plugin = CodePlugin()
977
978 def test_only_one_side_changed(self) -> None:
979 base = _make_manifest({"f.py": "v1"})
980 left = _make_manifest({"f.py": "v1"})
981 right = _make_manifest({"f.py": "v2"})
982 result = self.plugin.merge(base, left, right)
983 assert result.is_clean
984 assert result.merged["files"]["f.py"] == "v2"
985
986 def test_both_sides_same_change(self) -> None:
987 base = _make_manifest({"f.py": "v1"})
988 left = _make_manifest({"f.py": "v2"})
989 right = _make_manifest({"f.py": "v2"})
990 result = self.plugin.merge(base, left, right)
991 assert result.is_clean
992 assert result.merged["files"]["f.py"] == "v2"
993
994 def test_conflict_when_both_sides_differ(self) -> None:
995 base = _make_manifest({"f.py": "v1"})
996 left = _make_manifest({"f.py": "v2"})
997 right = _make_manifest({"f.py": "v3"})
998 result = self.plugin.merge(base, left, right)
999 assert not result.is_clean
1000 assert "f.py" in result.conflicts
1001
1002 def test_disjoint_additions_auto_merge(self) -> None:
1003 base = _make_manifest({})
1004 left = _make_manifest({"a.py": "hash_a"})
1005 right = _make_manifest({"b.py": "hash_b"})
1006 result = self.plugin.merge(base, left, right)
1007 assert result.is_clean
1008 assert "a.py" in result.merged["files"]
1009 assert "b.py" in result.merged["files"]
1010
1011 def test_deletion_on_one_side(self) -> None:
1012 base = _make_manifest({"f.py": "v1"})
1013 left = _make_manifest({})
1014 right = _make_manifest({"f.py": "v1"})
1015 result = self.plugin.merge(base, left, right)
1016 assert result.is_clean
1017 assert "f.py" not in result.merged["files"]
1018
1019
1020 # ---------------------------------------------------------------------------
1021 # CodePlugin — merge_ops (symbol-level OT)
1022 # ---------------------------------------------------------------------------
1023
1024
1025 class TestCodePluginMergeOps:
1026 plugin = CodePlugin()
1027
1028 def _py_snap(self, file_path: str, src: bytes, repo_root: pathlib.Path) -> SnapshotManifest:
1029 oid = _store_blob(repo_root, src)
1030 return _make_manifest({file_path: oid})
1031
1032 def test_different_symbols_auto_merge(self, tmp_path: pathlib.Path) -> None:
1033 """Two agents modify different functions → no conflict."""
1034 repo_root = tmp_path / "repo"
1035 repo_root.mkdir()
1036
1037 base_src = _src("""\
1038 def foo(x: int) -> int:
1039 return x
1040
1041 def bar(y: int) -> int:
1042 return y
1043 """)
1044 # Ours: modify foo.
1045 ours_src = _src("""\
1046 def foo(x: int) -> int:
1047 return x * 2
1048
1049 def bar(y: int) -> int:
1050 return y
1051 """)
1052 # Theirs: modify bar.
1053 theirs_src = _src("""\
1054 def foo(x: int) -> int:
1055 return x
1056
1057 def bar(y: int) -> int:
1058 return y + 1
1059 """)
1060
1061 base_snap = self._py_snap("m.py", base_src, repo_root)
1062 ours_snap = self._py_snap("m.py", ours_src, repo_root)
1063 theirs_snap = self._py_snap("m.py", theirs_src, repo_root)
1064
1065 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1066 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1067
1068 result = self.plugin.merge_ops(
1069 base_snap,
1070 ours_snap,
1071 theirs_snap,
1072 ours_delta["ops"],
1073 theirs_delta["ops"],
1074 repo_root=repo_root,
1075 )
1076 # Different symbol addresses → ops commute → no conflict.
1077 assert result.is_clean, f"Expected no conflicts, got: {result.conflicts}"
1078
1079 def test_same_symbol_conflict(self, tmp_path: pathlib.Path) -> None:
1080 """Both agents modify the same function → conflict at symbol address."""
1081 repo_root = tmp_path / "repo"
1082 repo_root.mkdir()
1083
1084 base_src = _src("def calc(x: int) -> int:\n return x\n")
1085 ours_src = _src("def calc(x: int) -> int:\n return x * 2\n")
1086 theirs_src = _src("def calc(x: int) -> int:\n return x + 100\n")
1087
1088 base_snap = self._py_snap("calc.py", base_src, repo_root)
1089 ours_snap = self._py_snap("calc.py", ours_src, repo_root)
1090 theirs_snap = self._py_snap("calc.py", theirs_src, repo_root)
1091
1092 ours_delta = self.plugin.diff(base_snap, ours_snap, repo_root=repo_root)
1093 theirs_delta = self.plugin.diff(base_snap, theirs_snap, repo_root=repo_root)
1094
1095 result = self.plugin.merge_ops(
1096 base_snap,
1097 ours_snap,
1098 theirs_snap,
1099 ours_delta["ops"],
1100 theirs_delta["ops"],
1101 repo_root=repo_root,
1102 )
1103 assert not result.is_clean
1104 # Conflict should be at file or symbol level.
1105 assert len(result.conflicts) > 0
1106
1107 def test_disjoint_files_auto_merge(self, tmp_path: pathlib.Path) -> None:
1108 """Agents modify completely different files → auto-merge."""
1109 repo_root = tmp_path / "repo"
1110 repo_root.mkdir()
1111
1112 base = _make_manifest({"a.py": "v1", "b.py": "v1"})
1113 ours = _make_manifest({"a.py": "v2", "b.py": "v1"})
1114 theirs = _make_manifest({"a.py": "v1", "b.py": "v2"})
1115
1116 ours_delta = self.plugin.diff(base, ours)
1117 theirs_delta = self.plugin.diff(base, theirs)
1118
1119 result = self.plugin.merge_ops(
1120 base, ours, theirs,
1121 ours_delta["ops"],
1122 theirs_delta["ops"],
1123 )
1124 assert result.is_clean
1125
1126
1127 # ---------------------------------------------------------------------------
1128 # CodePlugin — drift
1129 # ---------------------------------------------------------------------------
1130
1131
1132 class TestCodePluginDrift:
1133 plugin = CodePlugin()
1134
1135 def test_no_drift(self, tmp_path: pathlib.Path) -> None:
1136 workdir = tmp_path
1137 (workdir / "app.py").write_text("x = 1\n")
1138 snap = self.plugin.snapshot(workdir)
1139 report = self.plugin.drift(snap, workdir)
1140 assert not report.has_drift
1141
1142 def test_has_drift_after_edit(self, tmp_path: pathlib.Path) -> None:
1143 workdir = tmp_path
1144 f = workdir / "app.py"
1145 f.write_text("x = 1\n")
1146 snap = self.plugin.snapshot(workdir)
1147 f.write_text("x = 2\n")
1148 report = self.plugin.drift(snap, workdir)
1149 assert report.has_drift
1150
1151 def test_has_drift_after_add(self, tmp_path: pathlib.Path) -> None:
1152 workdir = tmp_path
1153 (workdir / "a.py").write_text("a = 1\n")
1154 snap = self.plugin.snapshot(workdir)
1155 (workdir / "b.py").write_text("b = 2\n")
1156 report = self.plugin.drift(snap, workdir)
1157 assert report.has_drift
1158
1159 def test_has_drift_after_delete(self, tmp_path: pathlib.Path) -> None:
1160 workdir = tmp_path
1161 f = workdir / "gone.py"
1162 f.write_text("x = 1\n")
1163 snap = self.plugin.snapshot(workdir)
1164 f.unlink()
1165 report = self.plugin.drift(snap, workdir)
1166 assert report.has_drift
1167
1168
1169 # ---------------------------------------------------------------------------
1170 # CodePlugin — apply (passthrough)
1171 # ---------------------------------------------------------------------------
1172
1173
1174 def test_apply_returns_live_state_unchanged(tmp_path: pathlib.Path) -> None:
1175 plugin = CodePlugin()
1176 workdir = tmp_path
1177 delta = plugin.diff(_make_manifest({}), _make_manifest({}))
1178 result = plugin.apply(delta, workdir)
1179 assert result is workdir
1180
1181
1182 # ---------------------------------------------------------------------------
1183 # CodePlugin — schema
1184 # ---------------------------------------------------------------------------
1185
1186
1187 class TestCodePluginSchema:
1188 plugin = CodePlugin()
1189
1190 def test_schema_domain(self) -> None:
1191 assert self.plugin.schema()["domain"] == "code"
1192
1193 def test_schema_merge_mode(self) -> None:
1194 assert self.plugin.schema()["merge_mode"] == "three_way"
1195
1196 def test_schema_version(self) -> None:
1197 assert self.plugin.schema()["schema_version"] == __version__
1198
1199 def test_schema_dimensions(self) -> None:
1200 dims = self.plugin.schema()["dimensions"]
1201 names = {d["name"] for d in dims}
1202 assert "structure" in names
1203 assert "symbols" in names
1204 assert "imports" in names
1205
1206 def test_schema_top_level_is_tree(self) -> None:
1207 top = self.plugin.schema()["top_level"]
1208 assert top["kind"] == "tree"
1209
1210 def test_schema_description_non_empty(self) -> None:
1211 assert len(self.plugin.schema()["description"]) > 0
1212
1213
1214 # ---------------------------------------------------------------------------
1215 # delta_summary
1216 # ---------------------------------------------------------------------------
1217
1218
1219 class TestDeltaSummary:
1220 def test_empty_ops(self) -> None:
1221 assert delta_summary([]) == "no changes"
1222
1223 def test_file_added(self) -> None:
1224 from muse.domain import DomainOp
1225 ops: list[DomainOp] = [InsertOp(
1226 op="insert", address="f.py", position=None,
1227 content_id="abc", content_summary="added f.py",
1228 )]
1229 summary = delta_summary(ops)
1230 assert "added" in summary
1231 assert "file" in summary
1232
1233 def test_symbols_counted_from_patch(self) -> None:
1234 from muse.domain import DomainOp, PatchOp
1235 child: list[DomainOp] = [
1236 InsertOp(op="insert", address="f.py::foo", position=None, content_id="a", content_summary="added function foo"),
1237 InsertOp(op="insert", address="f.py::bar", position=None, content_id="b", content_summary="added function bar"),
1238 ]
1239 ops: list[DomainOp] = [PatchOp(op="patch", address="f.py", child_ops=child, child_domain="code_symbols", child_summary="2 added")]
1240 summary = delta_summary(ops)
1241 assert "symbol" in summary