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