gabriel / muse public
Closed #92 Bug
filed by gabriel human · 41 days ago

Code domain's address-keyed merge falsely conflicts when both sides add a trailing comment to different functions in the same file

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

Summary

While researching Build With Muse Episode 08 (The Merge Engine), found that muse merge produces a false, file-level conflict when two branches each add a trailing inline comment to two different functions in the same file — directly contradicting Episode 05's (already-shipped) claim that address-keyed merge means edits at different symbol addresses never conflict.

Confirmed this is narrower than it first looked: substantial body rewrites to different functions in the same file merge cleanly, as expected. The false conflict is specific to comment-only edits.

Reproduction

# calc.py, committed on main
def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

Branch feature/alice: add a trailing comment to add only:

def add(a, b):
    return a + b  # comment

Branch feature/bob (from the same base): add a trailing comment to subtract only:

def subtract(a, b):
    return a - b  # comment
$ muse merge feature/alice   # fast_forward, fine
$ muse merge feature/bob
{"status": "conflict", "conflicts": ["calc.py"]}

$ muse conflicts --json
[{"path": "calc.py", "file": "calc.py", "symbol": null, "kind": "file"}]

"kind": "file", "symbol": null — this is a whole-file conflict, not scoped to either symbol, despite both edits being comment-only changes to two different, uncontested functions.

What does NOT reproduce it (ruled out)

  • Substantial body rewrites (not just comments) to two different functions in the same file — merges cleanly, conflicts: [], exactly as Episode 05 demonstrated and claimed.
  • A comment edit on one side + an entirely unrelated new file on the other side — merges cleanly.

So this is specifically: same file, comment-only edits, two different functions. Real rename/move detection (Episode 07) and real independent body edits (Episodes 01, 05, 06) are all unaffected.

Hypothesis (not confirmed)

Comments likely aren't attached to either function's AST node in the code_symbols diff dimension the way a body change is — they may get represented as a change to the file's own structure/metadata dimension (or dropped from symbol-level identity entirely), which would explain why plugin.merge_ops() can't address-key the two edits apart and falls back to a whole-file conflict.

Ask

  1. Confirm where comment-only edits are represented in the code domain's diff/merge pipeline (symbol body content vs. a separate/no dimension).
  2. If comments are dropped from symbol identity: teach merge_ops() (or the underlying diff) to still address-key a comment-only change to its enclosing symbol, so two independent comment edits merge the same way two independent body edits already do.
  3. Add a regression test: two branches, each adding a trailing comment to a different function in the same file, must merge cleanly with zero conflicts.

Priority

Not urgent — circle back once Build With Muse Season 1 is wrapped, same as #91. Episode 08 will use a genuine same-symbol conflict example instead of this scenario, so this doesn't block the episode.

  • #91 — a different, unrelated inconsistency found the same week (semver classifier on reorder-only diffs). Not the same root cause as far as I can tell, but both surfaced while building out Season 1's merge/diff episodes.

Source

Found by Claude Sonnet 5 while researching Episode 08, 2026-09-12.

◎ Activity1
●
gabriel opened this issue 41 days ago
○
gabriel 2 days ago

Fixed. Root cause and the fix were narrower than this ticket's original scope suggested.

Root cause

content_id (symbol identity, muse/plugins/code/ast_parser.py) is computed via ast.unparse(), which strips comments by design — this is intentional, documented behavior relied on broadly across muse diff/muse log/rename-detection/muse code stable, so it was never going to change. A trailing-comment-only edit is therefore AST-identical to base, build_diff_ops() finds no changed child_ops, and falls back to a whole-file ReplaceOp labeled "reformatted — no semantic change" instead of a symbol-addressed PatchOp.

When two branches each comment a different function in the same file, merge_ops()'s Step 1.5 (independence-aware blob reconstruction) had no PatchOp on either side to work with, so it just skipped reconstruction entirely — and Step 2's coarse OT check then flagged the two competing file-level ReplaceOps as a hard conflict, even though the edits never overlap.

Fix (CodePlugin.merge_ops(), two parts):

  1. When symbol-level patch info isn't available for a candidate changed path, attempt a direct three-way LINE merge on the raw base/ours/theirs blob bytes via cohen_transform.three_way_merge_lines() — the same primitive _independence_merge_blob() already uses for confirmed-independent symbol edits. This is unconditionally safe: a clean, non-overlapping line-level merge is correct regardless of symbol knowledge or file type. Only used when the merge reports zero conflict regions; any real overlap still falls through to the existing conflict path.
  2. A second, independent bug surfaced while verifying (1) live: Step 2's OT check re-flags the path as conflicting from the two file-level ReplaceOps regardless of what Step 1.5 resolved, because conflict_addresses was never filtered against independence_resolved before Step 3 assembled the final conflict list. Filtered conflict_addresses against independence_resolved's keys immediately after Step 2.

This is deliberately narrower than the "thread raw source through ast_parser.py → symbol_diff.py" approach originally scoped — content_id/AST-normalization semantics are untouched, which keeps this a small, low-risk change instead of a cross-file architectural one.

Verification

  • Live-reproduced via muse-dev against a disposable /tmp sandbox: two branches each add a trailing comment to a different function in calc.py. Merge now reports status: merged with zero conflicts, and the committed file contains both comments.
  • New regression test tests/test_comment_only_independence_merge.py reproduces the exact scenario through the real CLI (not synthetic objects). Confirmed genuine RED (reverted the fix, test failed on the pre-fix conflict) before GREEN with the fix restored.
  • Targeted regression suite — test_core_merge_engine, test_cmd_merge, test_code_harmony_plugin, test_phase1_merge_engine, test_phase5_phantom_guard, test_phase6_unified_merge_engine, test_op_merge, test_octopus_merge, test_harmony_merge_commit_integrity, test_code_plugin, test_code_plugin_attributes, test_phase2_or_set_symbol_independence, plus the new test — all pass (636 passed, 19 skipped), zero regressions.

Merged to dev → main, pushed to local/staging/production for both branches.

✓
closed this issue 2 days ago