gabriel / muse public
test_phase2_or_set_symbol_independence.py python
470 lines 17.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Phase 2 — OR-Set CRDT semantics + symbol-level independence-aware merge.
2
3 Tier 1 — OR-Set for imports and variables
4 ------------------------------------------
5 Import and variable additions from concurrent branches are always independent:
6 add-wins, never conflict. This is the OR-Set guarantee: union of additions,
7 with tombstones only when *both* sides agree on a deletion.
8
9 Tier 2 — Symbol-level independence for functions and classes
10 -------------------------------------------------------------
11 Two branches that add or modify *different* named symbols in the same file
12 should produce a clean merged file containing all changes. Currently they
13 produce a spurious file-level conflict because the raw blob IDs diverge.
14
15 The fix: when merge_ops() finds that all child ops across PatchOps for the
16 same file commute (no symbol-level conflict), it reconstructs the merged
17 blob via three_way_merge_lines() and writes it to the object store. A clean
18 text merge removes the file from the conflict list and updates the manifest.
19
20 Test categories
21 ---------------
22 TestORSetImports — concurrent import adds never conflict (Tier 1)
23 TestORSetVariables — concurrent variable adds never conflict (Tier 1)
24 TestSymbolIndependence — concurrent adds/edits of different symbols are clean (Tier 2)
25 TestSymbolConflictPreserved — genuine same-symbol conflicts still surface (Tier 2)
26 TestMergedBlobCorrectness — merged file content is complete and well-formed
27 """
28
29 from __future__ import annotations
30 from collections.abc import Mapping
31
32 import pathlib
33 import textwrap
34
35 import pytest
36
37 from muse.plugins.code.plugin import CodePlugin
38 from muse.core._types import blob_id
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45 def _oid(content: bytes) -> str:
46 return blob_id(content)
47
48
49 def _write_blob(root: pathlib.Path, content: bytes) -> str:
50 from muse.core.object_store import write_object
51 oid = _oid(content)
52 write_object(root, oid, content)
53 return oid
54
55
56 def _snap(root: pathlib.Path, files: Mapping[str, bytes]) -> Mapping[str, object]:
57 return {
58 "files": {path: _write_blob(root, content) for path, content in files.items()},
59 "domain": "code",
60 "directories": [],
61 }
62
63
64 def _read_merged_blob(root: pathlib.Path, result, path: str) -> str:
65 from muse.core.object_store import read_object
66 oid = result.merged["files"][path]
67 raw = read_object(root, oid)
68 assert raw is not None, f"merged blob for {path} not in object store"
69 return raw.decode("utf-8")
70
71
72 # ---------------------------------------------------------------------------
73 # Tier 1 — OR-Set for imports
74 # ---------------------------------------------------------------------------
75
76 class TestORSetImports:
77 """Concurrent import additions from two branches must never conflict."""
78
79 def test_concurrent_import_adds_no_conflict(self, tmp_path: pathlib.Path) -> None:
80 """Branch A adds 'import os', branch B adds 'import sys' → clean merge."""
81 plugin = CodePlugin()
82
83 base_src = b"# utils.py\n\ndef process(): pass\n"
84 ours_src = b"# utils.py\nimport os\n\ndef process(): pass\n"
85 theirs_src = b"# utils.py\nimport sys\n\ndef process(): pass\n"
86
87 base = _snap(tmp_path, {"src/utils.py": base_src})
88 ours = _snap(tmp_path, {"src/utils.py": ours_src})
89 theirs = _snap(tmp_path, {"src/utils.py": theirs_src})
90
91 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
92
93 assert "src/utils.py" not in result.conflicts, (
94 "Concurrent import additions must not conflict — OR-Set semantics"
95 )
96
97 def test_concurrent_import_adds_both_present_in_merged(self, tmp_path: pathlib.Path) -> None:
98 """The merged file must contain both imports."""
99 plugin = CodePlugin()
100
101 base_src = b"# utils.py\n\ndef process(): pass\n"
102 ours_src = b"# utils.py\nimport os\n\ndef process(): pass\n"
103 theirs_src = b"# utils.py\nimport sys\n\ndef process(): pass\n"
104
105 base = _snap(tmp_path, {"src/utils.py": base_src})
106 ours = _snap(tmp_path, {"src/utils.py": ours_src})
107 theirs = _snap(tmp_path, {"src/utils.py": theirs_src})
108
109 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
110
111 merged_text = _read_merged_blob(tmp_path, result, "src/utils.py")
112 assert "import os" in merged_text, "ours import must survive in merged blob"
113 assert "import sys" in merged_text, "theirs import must survive in merged blob"
114
115 def test_three_concurrent_import_adds_all_survive(self, tmp_path: pathlib.Path) -> None:
116 """Even with multiple imports added per side, all survive."""
117 plugin = CodePlugin()
118
119 base_src = b"def fn(): pass\n"
120 ours_src = b"import os\nimport pathlib\n\ndef fn(): pass\n"
121 theirs_src = b"import sys\nimport json\n\ndef fn(): pass\n"
122
123 base = _snap(tmp_path, {"lib.py": base_src})
124 ours = _snap(tmp_path, {"lib.py": ours_src})
125 theirs = _snap(tmp_path, {"lib.py": theirs_src})
126
127 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
128
129 assert "lib.py" not in result.conflicts
130 merged_text = _read_merged_blob(tmp_path, result, "lib.py")
131 for imp in ("import os", "import pathlib", "import sys", "import json"):
132 assert imp in merged_text, f"{imp} missing from merged blob"
133
134 def test_same_import_added_on_both_sides_deduplicates(self, tmp_path: pathlib.Path) -> None:
135 """Both branches adding the same import → one copy in merged file, no conflict."""
136 plugin = CodePlugin()
137
138 base_src = b"def fn(): pass\n"
139 both_src = b"import os\n\ndef fn(): pass\n"
140
141 base = _snap(tmp_path, {"lib.py": base_src})
142 ours = _snap(tmp_path, {"lib.py": both_src})
143 theirs = _snap(tmp_path, {"lib.py": both_src})
144
145 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
146
147 assert "lib.py" not in result.conflicts
148 merged_text = _read_merged_blob(tmp_path, result, "lib.py")
149 assert merged_text.count("import os") == 1, "duplicate import must be deduplicated"
150
151
152 # ---------------------------------------------------------------------------
153 # Tier 1 — OR-Set for variables
154 # ---------------------------------------------------------------------------
155
156 class TestORSetVariables:
157 """Concurrent top-level variable additions must never conflict."""
158
159 def test_concurrent_variable_adds_no_conflict(self, tmp_path: pathlib.Path) -> None:
160 """Branch A adds MAX=100, branch B adds MIN=0 → clean merge."""
161 plugin = CodePlugin()
162
163 base_src = b"def fn(): pass\n"
164 ours_src = b"MAX = 100\n\ndef fn(): pass\n"
165 theirs_src = b"MIN = 0\n\ndef fn(): pass\n"
166
167 base = _snap(tmp_path, {"config.py": base_src})
168 ours = _snap(tmp_path, {"config.py": ours_src})
169 theirs = _snap(tmp_path, {"config.py": theirs_src})
170
171 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
172
173 assert "config.py" not in result.conflicts, (
174 "Concurrent variable additions must not conflict"
175 )
176
177 def test_concurrent_variable_adds_both_present(self, tmp_path: pathlib.Path) -> None:
178 """Both variables appear in the merged file."""
179 plugin = CodePlugin()
180
181 base_src = b"def fn(): pass\n"
182 ours_src = b"MAX = 100\n\ndef fn(): pass\n"
183 theirs_src = b"MIN = 0\n\ndef fn(): pass\n"
184
185 base = _snap(tmp_path, {"config.py": base_src})
186 ours = _snap(tmp_path, {"config.py": ours_src})
187 theirs = _snap(tmp_path, {"config.py": theirs_src})
188
189 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
190
191 merged_text = _read_merged_blob(tmp_path, result, "config.py")
192 assert "MAX = 100" in merged_text
193 assert "MIN = 0" in merged_text
194
195
196 # ---------------------------------------------------------------------------
197 # Tier 2 — Symbol independence for functions and classes
198 # ---------------------------------------------------------------------------
199
200 class TestSymbolIndependence:
201 """Non-overlapping symbol changes in the same file must produce a clean merge."""
202
203 def test_concurrent_function_adds_no_conflict(self, tmp_path: pathlib.Path) -> None:
204 """Branch A adds def foo(), branch B adds def bar() → clean merge."""
205 plugin = CodePlugin()
206
207 base_src = textwrap.dedent("""\
208 def existing():
209 pass
210 """).encode()
211
212 ours_src = textwrap.dedent("""\
213 def existing():
214 pass
215
216 def foo():
217 return 1
218 """).encode()
219
220 theirs_src = textwrap.dedent("""\
221 def existing():
222 pass
223
224 def bar():
225 return 2
226 """).encode()
227
228 base = _snap(tmp_path, {"module.py": base_src})
229 ours = _snap(tmp_path, {"module.py": ours_src})
230 theirs = _snap(tmp_path, {"module.py": theirs_src})
231
232 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
233
234 assert "module.py" not in result.conflicts, (
235 "Concurrent additions of different functions must not conflict"
236 )
237
238 def test_concurrent_function_adds_both_present(self, tmp_path: pathlib.Path) -> None:
239 """Both added functions appear in the merged file."""
240 plugin = CodePlugin()
241
242 base_src = b"def existing(): pass\n"
243 ours_src = b"def existing(): pass\n\ndef foo():\n return 1\n"
244 theirs_src = b"def existing(): pass\n\ndef bar():\n return 2\n"
245
246 base = _snap(tmp_path, {"module.py": base_src})
247 ours = _snap(tmp_path, {"module.py": ours_src})
248 theirs = _snap(tmp_path, {"module.py": theirs_src})
249
250 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
251
252 merged_text = _read_merged_blob(tmp_path, result, "module.py")
253 assert "def foo" in merged_text, "ours function must appear in merged file"
254 assert "def bar" in merged_text, "theirs function must appear in merged file"
255 assert "def existing" in merged_text, "base function must be preserved"
256
257 def test_different_functions_modified_no_conflict(self, tmp_path: pathlib.Path) -> None:
258 """Branch A modifies foo(), branch B modifies bar() → clean merge."""
259 plugin = CodePlugin()
260
261 base_src = textwrap.dedent("""\
262 def foo():
263 return 0
264
265 def bar():
266 return 0
267 """).encode()
268
269 ours_src = textwrap.dedent("""\
270 def foo():
271 return 1
272
273 def bar():
274 return 0
275 """).encode()
276
277 theirs_src = textwrap.dedent("""\
278 def foo():
279 return 0
280
281 def bar():
282 return 2
283 """).encode()
284
285 base = _snap(tmp_path, {"module.py": base_src})
286 ours = _snap(tmp_path, {"module.py": ours_src})
287 theirs = _snap(tmp_path, {"module.py": theirs_src})
288
289 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
290
291 assert "module.py" not in result.conflicts, (
292 "Modifications to different functions must not conflict"
293 )
294
295 def test_different_functions_modified_both_present(self, tmp_path: pathlib.Path) -> None:
296 """The merged file has ours' version of foo() and theirs' version of bar()."""
297 plugin = CodePlugin()
298
299 base_src = b"def foo():\n return 0\n\ndef bar():\n return 0\n"
300 ours_src = b"def foo():\n return 1\n\ndef bar():\n return 0\n"
301 theirs_src = b"def foo():\n return 0\n\ndef bar():\n return 2\n"
302
303 base = _snap(tmp_path, {"module.py": base_src})
304 ours = _snap(tmp_path, {"module.py": ours_src})
305 theirs = _snap(tmp_path, {"module.py": theirs_src})
306
307 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
308
309 merged_text = _read_merged_blob(tmp_path, result, "module.py")
310 assert "return 1" in merged_text, "ours change to foo() must be in merged file"
311 assert "return 2" in merged_text, "theirs change to bar() must be in merged file"
312
313 def test_concurrent_class_adds_no_conflict(self, tmp_path: pathlib.Path) -> None:
314 """Branch A adds class Foo, branch B adds class Bar → clean merge."""
315 plugin = CodePlugin()
316
317 base_src = b"# module\n"
318 ours_src = b"# module\n\nclass Foo:\n pass\n"
319 theirs_src = b"# module\n\nclass Bar:\n pass\n"
320
321 base = _snap(tmp_path, {"module.py": base_src})
322 ours = _snap(tmp_path, {"module.py": ours_src})
323 theirs = _snap(tmp_path, {"module.py": theirs_src})
324
325 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
326
327 assert "module.py" not in result.conflicts
328
329 def test_independent_changes_in_multiple_files_all_clean(self, tmp_path: pathlib.Path) -> None:
330 """Multiple files with independent changes all merge cleanly."""
331 plugin = CodePlugin()
332
333 base = _snap(tmp_path, {
334 "a.py": b"def fa(): pass\n",
335 "b.py": b"def fb(): pass\n",
336 })
337 ours = _snap(tmp_path, {
338 "a.py": b"def fa(): pass\n\ndef fa2(): pass\n",
339 "b.py": b"def fb(): pass\n",
340 })
341 theirs = _snap(tmp_path, {
342 "a.py": b"def fa(): pass\n",
343 "b.py": b"def fb(): pass\n\ndef fb2(): pass\n",
344 })
345
346 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
347
348 assert result.conflicts == [], f"Expected no conflicts, got: {result.conflicts}"
349
350
351 # ---------------------------------------------------------------------------
352 # Tier 2 — Genuine conflicts still surface
353 # ---------------------------------------------------------------------------
354
355 class TestSymbolConflictPreserved:
356 """Genuine same-symbol conflicts must still be detected and reported."""
357
358 def test_same_function_modified_both_sides_conflicts(self, tmp_path: pathlib.Path) -> None:
359 """Both branches modified the same function body → real conflict."""
360 plugin = CodePlugin()
361
362 base_src = b"def compute():\n return 0\n"
363 ours_src = b"def compute():\n return 1\n"
364 theirs_src = b"def compute():\n return 2\n"
365
366 base = _snap(tmp_path, {"ops.py": base_src})
367 ours = _snap(tmp_path, {"ops.py": ours_src})
368 theirs = _snap(tmp_path, {"ops.py": theirs_src})
369
370 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
371
372 assert any("ops.py" in c for c in result.conflicts), (
373 "Same-function conflict must still be detected"
374 )
375
376 def test_mixed_file_some_symbols_conflict_some_independent(
377 self, tmp_path: pathlib.Path
378 ) -> None:
379 """When one symbol conflicts, the file is in conflicts — independent ones don't suppress it."""
380 plugin = CodePlugin()
381
382 base_src = textwrap.dedent("""\
383 def shared():
384 return 0
385
386 def independent_a():
387 pass
388 """).encode()
389
390 ours_src = textwrap.dedent("""\
391 def shared():
392 return 1
393
394 def independent_a():
395 pass
396
397 def only_on_ours():
398 pass
399 """).encode()
400
401 theirs_src = textwrap.dedent("""\
402 def shared():
403 return 2
404
405 def independent_a():
406 pass
407
408 def only_on_theirs():
409 pass
410 """).encode()
411
412 base = _snap(tmp_path, {"mixed.py": base_src})
413 ours = _snap(tmp_path, {"mixed.py": ours_src})
414 theirs = _snap(tmp_path, {"mixed.py": theirs_src})
415
416 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
417
418 # The shared() conflict must surface — even though independent symbols exist
419 assert any("mixed.py" in c for c in result.conflicts), (
420 "File with a genuine symbol conflict must still appear in conflicts"
421 )
422
423
424 # ---------------------------------------------------------------------------
425 # Merged blob correctness
426 # ---------------------------------------------------------------------------
427
428 class TestMergedBlobCorrectness:
429 """Reconstructed blobs must be syntactically valid and not contain conflict markers."""
430
431 def test_merged_blob_has_no_conflict_markers(self, tmp_path: pathlib.Path) -> None:
432 """Blobs auto-resolved via independence must not contain <<<<<<< markers."""
433 plugin = CodePlugin()
434
435 base_src = b"def existing(): pass\n"
436 ours_src = b"def existing(): pass\n\ndef foo(): return 1\n"
437 theirs_src = b"def existing(): pass\n\ndef bar(): return 2\n"
438
439 base = _snap(tmp_path, {"m.py": base_src})
440 ours = _snap(tmp_path, {"m.py": ours_src})
441 theirs = _snap(tmp_path, {"m.py": theirs_src})
442
443 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
444
445 merged_text = _read_merged_blob(tmp_path, result, "m.py")
446 assert "<<<<<<<" not in merged_text, "auto-resolved blob must not contain conflict markers"
447 assert "=======" not in merged_text
448 assert ">>>>>>>" not in merged_text
449
450 def test_merged_blob_is_valid_python(self, tmp_path: pathlib.Path) -> None:
451 """Reconstructed blob must parse without SyntaxError."""
452 import ast
453
454 plugin = CodePlugin()
455
456 base_src = b"def existing(): pass\n"
457 ours_src = b"def existing(): pass\n\ndef foo():\n return 1\n"
458 theirs_src = b"def existing(): pass\n\ndef bar():\n return 2\n"
459
460 base = _snap(tmp_path, {"m.py": base_src})
461 ours = _snap(tmp_path, {"m.py": ours_src})
462 theirs = _snap(tmp_path, {"m.py": theirs_src})
463
464 result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path)
465
466 merged_text = _read_merged_blob(tmp_path, result, "m.py")
467 try:
468 ast.parse(merged_text)
469 except SyntaxError as exc:
470 pytest.fail(f"Merged blob is not valid Python: {exc}\n\n{merged_text}")
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago