"""musehub#219 — code domain's imports/variables dimension didn't honor its own "unordered set" merge semantics: a pure reorder on one branch, combined with a pure addition on another, produced a false file-level conflict. Root cause, found via live investigation (two bugs stacked): 1. `_canonical_key` (the symbol tree's dict key) bakes in `node.lineno` for every symbol kind, including imports/variables -- directly contradicting their own dimension schema's declared ``identity="by_content"`` (order is semantically irrelevant). A pure reorder changes every import's key, so `diff_symbol_trees` treated them as "moved" (via its content-match rename-detection pass), emitting a spurious ReplaceOp for a change that should be invisible. 2. Even after suppressing that spurious op, the actual blob reconstruction still fell back to `three_way_merge_lines` (line-based 3-way diff), which *cannot* reconcile a pure reorder on one side with an insertion on the other -- reordering moves every line, so the edit regions always overlap, regardless of how the structured diff reports it. Fix: `_merge_unordered_set_blob` reconstructs the merged file via a real three-way SET merge (survivors = base entries neither side removed, plus either side's new entries) whenever every symbol in the file is an unordered-set kind and the file's content *outside* those symbols' line ranges is byte-identical across base/ours/theirs. Verified live against the real ticket repro via muse-dev before writing these tests (base: sys/os/re; ours: alphabetized os/re/sys; theirs: sys/os/re + json) -- confirmed FF+merge succeeds with the correct deduplicated, ordered result and no --force needed. """ from __future__ import annotations import pathlib from collections.abc import Mapping from typing import TYPE_CHECKING import pytest from muse.core.types import blob_id from muse.plugins.code.plugin import CodePlugin if TYPE_CHECKING: from muse.domain import MergeResult def _oid(content: bytes) -> str: return blob_id(content) def _write_blob(root: pathlib.Path, content: bytes) -> str: from muse.core.object_store import write_object oid = _oid(content) write_object(root, oid, content) return oid def _snap(root: pathlib.Path, files: Mapping[str, bytes]) -> Mapping[str, object]: return { "files": {path: _write_blob(root, content) for path, content in files.items()}, "domain": "code", "directories": [], } def _read_merged_blob(root: pathlib.Path, result: "MergeResult", path: str) -> str: from muse.core.object_store import read_object oid = result.merged["files"][path] raw = read_object(root, oid) assert raw is not None, f"merged blob for {path} not in object store" return raw.decode("utf-8") class TestReorderPlusAddition: """The ticket's exact scenario: pure reorder on one side, pure addition on the other.""" def test_reorder_plus_addition_does_not_conflict(self, tmp_path: pathlib.Path) -> None: plugin = CodePlugin() base_src = b"import sys\nimport os\nimport re\n\nprint('hello')\n" ours_src = b"import os\nimport re\nimport sys\n\nprint('hello')\n" # pure reorder theirs_src = b"import sys\nimport os\nimport re\nimport json\n\nprint('hello')\n" # pure add base = _snap(tmp_path, {"app.py": base_src}) ours = _snap(tmp_path, {"app.py": ours_src}) theirs = _snap(tmp_path, {"app.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "app.py" not in result.conflicts, ( "a pure reorder on one side and a pure addition on the other " "must not conflict -- imports are an unordered set" ) def test_reorder_plus_addition_merged_content_is_correct( self, tmp_path: pathlib.Path ) -> None: plugin = CodePlugin() base_src = b"import sys\nimport os\nimport re\n\nprint('hello')\n" ours_src = b"import os\nimport re\nimport sys\n\nprint('hello')\n" theirs_src = b"import sys\nimport os\nimport re\nimport json\n\nprint('hello')\n" base = _snap(tmp_path, {"app.py": base_src}) ours = _snap(tmp_path, {"app.py": ours_src}) theirs = _snap(tmp_path, {"app.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) merged_text = _read_merged_blob(tmp_path, result, "app.py") # Ours' reordering is preserved, theirs' new import is appended, no # duplicates, print statement untouched. assert merged_text == "import os\nimport re\nimport sys\nimport json\n\nprint('hello')\n" def test_addition_plus_reorder_is_symmetric(self, tmp_path: pathlib.Path) -> None: """Swapping which side reorders vs. adds must behave the same way.""" plugin = CodePlugin() base_src = b"import sys\nimport os\nimport re\n\nprint('hello')\n" ours_src = b"import sys\nimport os\nimport re\nimport json\n\nprint('hello')\n" # add theirs_src = b"import os\nimport re\nimport sys\n\nprint('hello')\n" # reorder base = _snap(tmp_path, {"app.py": base_src}) ours = _snap(tmp_path, {"app.py": ours_src}) theirs = _snap(tmp_path, {"app.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "app.py" not in result.conflicts merged_text = _read_merged_blob(tmp_path, result, "app.py") for expected in ("import sys", "import os", "import re", "import json"): assert expected in merged_text assert merged_text.count("import sys") == 1 assert merged_text.count("import os") == 1 assert merged_text.count("import re") == 1 class TestReorderPlusRemoval: def test_reorder_plus_removal_does_not_conflict(self, tmp_path: pathlib.Path) -> None: plugin = CodePlugin() base_src = b"import sys\nimport os\nimport re\n\nprint('x')\n" ours_src = b"import os\nimport re\nimport sys\n\nprint('x')\n" # reorder theirs_src = b"import sys\nimport os\n\nprint('x')\n" # removed re base = _snap(tmp_path, {"app.py": base_src}) ours = _snap(tmp_path, {"app.py": ours_src}) theirs = _snap(tmp_path, {"app.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "app.py" not in result.conflicts merged_text = _read_merged_blob(tmp_path, result, "app.py") assert "import re" not in merged_text, "theirs' explicit removal must be respected" assert "import os" in merged_text assert "import sys" in merged_text class TestPureReorderIsANoOp: def test_pure_reorder_alone_produces_no_moved_op(self, tmp_path: pathlib.Path) -> None: """A commit that only reorders imports must never show a 'moved' op for each one -- matching the dimension's own declared 'order is semantically irrelevant' contract. build_diff_ops' existing formatting-only convention (a single file-level ReplaceOp labeled 'reformatted -- no semantic change') is the correct, accurate signal here -- it is not the misleading per-import 'moved' op this ticket reported.""" from muse.plugins.code.symbol_diff import build_diff_ops from muse.plugins.code.ast_parser import parse_symbols base_src = b"import sys\nimport os\nimport re\n" target_src = b"import os\nimport re\nimport sys\n" base_tree = parse_symbols(base_src, "app.py") target_tree = parse_symbols(target_src, "app.py") ops = build_diff_ops( {"app.py": _oid(base_src)}, {"app.py": _oid(target_src)}, {"app.py": base_tree}, {"app.py": target_tree}, ) assert not any("moved to" in op.get("new_summary", "") for op in ops), ( f"a pure import reorder must not produce a 'moved' op per import, got {ops!r}" ) assert len(ops) == 1 assert "no semantic change" in ops[0]["new_summary"] class TestVariablesDimensionToo: """musehub#219's own suggested follow-up: check whether this affects the variables dimension too, since it declares the same unordered-set semantics.""" def test_variable_reorder_plus_addition_does_not_conflict( self, tmp_path: pathlib.Path ) -> None: plugin = CodePlugin() base_src = b"FOO = 1\nBAR = 2\n" ours_src = b"BAR = 2\nFOO = 1\n" # pure reorder theirs_src = b"FOO = 1\nBAR = 2\nBAZ = 3\n" # pure add base = _snap(tmp_path, {"config.py": base_src}) ours = _snap(tmp_path, {"config.py": ours_src}) theirs = _snap(tmp_path, {"config.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "config.py" not in result.conflicts merged_text = _read_merged_blob(tmp_path, result, "config.py") assert "FOO = 1" in merged_text assert "BAR = 2" in merged_text assert "BAZ = 3" in merged_text class TestGenuineChangesStillFallThrough: """The reconstruction must never engage when the file has a real, non-import/variable change -- those must still go through the existing merge path untouched.""" def test_function_body_change_is_not_affected_by_import_reorder( self, tmp_path: pathlib.Path ) -> None: """A genuine same-function conflict must still surface -- the new reconstruction path must decline to engage just because imports also changed.""" plugin = CodePlugin() base_src = b"import os\n\ndef f():\n return 1\n" ours_src = b"import os\nimport sys\n\ndef f():\n return 2\n" theirs_src = b"import os\nimport sys\n\ndef f():\n return 3\n" base = _snap(tmp_path, {"app.py": base_src}) ours = _snap(tmp_path, {"app.py": ours_src}) theirs = _snap(tmp_path, {"app.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "app.py::f" in result.conflicts or "app.py" in result.conflicts, ( "a genuine conflicting function-body change must still surface " "as a conflict, not be silently resolved by the new import " "reconstruction path" ) def test_unparseable_divergent_file_still_surfaces_conflict( self, tmp_path: pathlib.Path ) -> None: """Regression guard for the exact IC_01 scenario this fix could have broken: a genuine SyntaxError on both sides, with divergent content, must never be silently treated as 'zero imports changed'.""" plugin = CodePlugin() base_src = b"value = 1\n" ours_src = b"(((\nvalue = 2\n" theirs_src = b")))\nvalue = 3\n" base = _snap(tmp_path, {"config.py": base_src}) ours = _snap(tmp_path, {"config.py": ours_src}) theirs = _snap(tmp_path, {"config.py": theirs_src}) result = plugin.merge_ops(base, ours, theirs, [], [], repo_root=tmp_path) assert "config.py" in result.conflicts, ( "divergent unparseable content must surface a conflict, not be " "silently merged as 'no variables changed'" )