rules: flag delete-delete and insert-delete conflicts
delete-delete compares connected regions of deleted O nodes across
branches — regions that overlap without coinciding mean each branch
deleted part of something the other deleted differently, the paper's
f(c) example. insert-delete fires when one branch inserted under an O
node the other branch deleted, leaving the graft nothing to attach to.

insert-delete is stricter than the plan's sketch, which softened it
through node-types.json when a surviving ancestor admits the inserted
kind: the pushout builder cannot re-anchor a graft to a different
ancestor yet, so the softened rule would declare such merges clean and
then silently drop the insertion. Revisit alongside builder
re-anchoring.

Assisted-by: Claude Fable 5 via Claude Code
change upwlwpymnoztwsovvnykvwnpvszlkyqm
commit c8a1ce943bf7e344e5ea7a6d2e078c91bc3ac004
author Alpha Chen <alpha@kejadlen.dev>
date
parent xnqorztw
diff --git a/src/merge.rs b/src/merge.rs
index 1939597..c40355d 100644
--- a/src/merge.rs
+++ b/src/merge.rs
@@ -421,10 +421,9 @@ mod tests {
                 panic!("expected conflicts, got a merge: {:?}", m.shape(&o, &a, &b));
             }
             MergeOutcome::Conflicts(conflicts) => {
-                assert!(!conflicts.is_empty());
                 assert!(
-                    conflicts.iter().all(|c| c.rule == rule),
-                    "expected only {rule}: {conflicts:?}"
+                    conflicts.iter().any(|c| c.rule == rule),
+                    "expected a {rule} conflict: {conflicts:?}"
                 );
                 Ok(conflicts)
             }
@@ -474,6 +473,57 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn overlapping_deletions_conflict() -> Result<(), Error> {
+        // The paper's f(c) example: A rewrites f(c) to x = c, deleting
+        // the call wrapper; B deletes the whole statement. The deletion
+        // regions overlap without coinciding.
+        assert_conflicts(
+            "rust",
+            "fn main() { f(c); }",
+            "fn main() { x = c; }",
+            "fn main() { }",
+            "delete-delete",
+        )?;
+        Ok(())
+    }
+
+    #[test]
+    fn insert_under_a_deleted_node_conflicts() -> Result<(), Error> {
+        // A grows fn a's body; B deletes fn a outright.
+        assert_conflicts(
+            "rust",
+            "fn a() { x(); }\nfn b() { y(); }",
+            "fn a() { x(); z(); }\nfn b() { y(); }",
+            "fn b() { y(); }",
+            "insert-delete",
+        )?;
+        Ok(())
+    }
+
+    #[test]
+    fn insert_into_a_deleted_inner_class_conflicts() -> Result<(), Error> {
+        assert_conflicts(
+            "java",
+            "class O { class I { } }",
+            "class O { class I { void m() { } } }",
+            "class O { }",
+            "insert-delete",
+        )?;
+        Ok(())
+    }
+
+    #[test]
+    fn insert_and_delete_in_different_functions_merge() -> Result<(), Error> {
+        assert_merges(
+            "rust",
+            "fn a() { x(); }\nfn b() { y(); }",
+            "fn a() { x(); z(); }\nfn b() { y(); }",
+            "fn a() { x(); }",
+            "fn a() { x(); z(); }",
+        )
+    }
+
     #[test]
     fn a_wrap_pulls_the_survivor_into_the_graft() -> Result<(), Error> {
         // A wraps the array one level deeper; B leaves it alone.
diff --git a/src/rules.rs b/src/rules.rs
index da3c100..f32a763 100644
--- a/src/rules.rs
+++ b/src/rules.rs
@@ -3,7 +3,7 @@
 //! common edits once; these rules name the combinations that cannot
 //! merge.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 use crate::diff::{Edit, Matching};
 use crate::merge::Conflict;
@@ -21,7 +21,7 @@ pub(crate) struct Ctx<'t> {
 /// A conflict rule over one (edit-from-A, edit-from-B) pair.
 type PairRule = fn(&Edit, &Edit, &Ctx) -> Option<Conflict>;
 
-const PAIR_RULES: &[PairRule] = &[relabel_relabel];
+const PAIR_RULES: &[PairRule] = &[relabel_relabel, insert_delete];
 
 /// Runs the pairwise rules over every cross-branch edit pair and the
 /// aggregate rules over the whole scripts, deduplicating identical
@@ -46,6 +46,7 @@ pub(crate) fn conflicts(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit]) -> Vec<Co
         }
     }
     insert_insert(ctx, edits_a, edits_b, &mut found);
+    delete_delete(ctx, edits_a, edits_b, &mut found);
     found
 }
 
@@ -67,6 +68,99 @@ fn relabel_relabel(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
     })
 }
 
+/// insert-delete (broken dependency): one branch inserted under an O
+/// node the other branch deleted. The graft has no surviving anchor to
+/// re-attach to.
+///
+/// Stricter than the plan's sketch, which softened this through
+/// node-types.json ("no surviving ancestor admits the inserted kind"):
+/// the pushout builder cannot re-anchor a graft to a different
+/// ancestor yet, so a softened rule would declare merges clean and
+/// then silently drop the insertion. Revisit together with builder
+/// re-anchoring.
+fn insert_delete(ea: &Edit, eb: &Edit, ctx: &Ctx) -> Option<Conflict> {
+    match (ea, eb) {
+        (Edit::Insert(insert), Edit::Delete(deleted)) => {
+            let (parent_o, _) = insert_anchor(ctx.a, ctx.f, *insert)?;
+            (parent_o == *deleted).then(|| Conflict {
+                span_o: Some(ctx.o.span(*deleted)),
+                span_a: Some(ctx.a.span(*insert)),
+                span_b: None,
+                rule: "insert-delete",
+            })
+        }
+        (Edit::Delete(deleted), Edit::Insert(insert)) => {
+            let (parent_o, _) = insert_anchor(ctx.b, ctx.g, *insert)?;
+            (parent_o == *deleted).then(|| Conflict {
+                span_o: Some(ctx.o.span(*deleted)),
+                span_a: None,
+                span_b: Some(ctx.b.span(*insert)),
+                rule: "insert-delete",
+            })
+        }
+        _ => None,
+    }
+}
+
+/// delete-delete (split deletion): connected regions of deleted O
+/// nodes that overlap across branches without coinciding. Each branch
+/// deleted part of something the other deleted differently — neither
+/// deletion subsumes cleanly.
+fn delete_delete(ctx: &Ctx, edits_a: &[Edit], edits_b: &[Edit], found: &mut Vec<Conflict>) {
+    let components_a = deletion_components(ctx.o, edits_a);
+    let components_b = deletion_components(ctx.o, edits_b);
+    for ca in &components_a {
+        for cb in &components_b {
+            if ca == cb || ca.is_disjoint(cb) {
+                continue;
+            }
+            let union: Vec<NodeId> = ca.union(cb).copied().collect();
+            let conflict = Conflict {
+                span_o: covering_span(ctx.o, &union),
+                span_a: None,
+                span_b: None,
+                rule: "delete-delete",
+            };
+            if !found.contains(&conflict) {
+                found.push(conflict);
+            }
+        }
+    }
+}
+
+/// Connected components (by parent-child edges within the deleted set)
+/// of one branch's deleted O nodes.
+fn deletion_components(o: &Tree, script: &[Edit]) -> Vec<HashSet<NodeId>> {
+    let deleted: HashSet<NodeId> = script
+        .iter()
+        .filter_map(|edit| match edit {
+            Edit::Delete(n) => Some(*n),
+            _ => None,
+        })
+        .collect();
+    let mut component_of: HashMap<NodeId, usize> = HashMap::new();
+    let mut components: Vec<HashSet<NodeId>> = Vec::new();
+    // Pre-order: a deleted parent's component exists before its
+    // children look it up.
+    for node in o.nodes() {
+        if !deleted.contains(&node) {
+            continue;
+        }
+        let component = o
+            .parent(node)
+            .and_then(|parent| component_of.get(&parent).copied())
+            .unwrap_or_else(|| {
+                components.push(HashSet::new());
+                components.len().saturating_sub(1)
+            });
+        if let Some(set) = components.get_mut(component) {
+            set.insert(node);
+        }
+        component_of.insert(node, component);
+    }
+    components
+}
+
 /// insert-insert: both branches inserted at the same slot — same
 /// parent image, same nearest preceding matched sibling — but the
 /// inserted subtree sequences differ. Equal sequences at the same