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