feat(diff): DocxDiff N-way composite / Consolidate#228
Conversation
…ns/FirstWins/StackAll)
…differ convention); real-differ tests + delete-conflict policy coverage
…ct, insert ordering, delete
…render unchanged)
…viewer-modify + RightSource selector
…nt note-diff drop)
…r + reviewer source)
…t pPr drop); doc + null-guard fixes
…ll) + reject≡base across policies
…d revision, conflict)
…move-modify revision text
…or + adjudicated deviation catalog
…policy-resolved accepted body
…ply-verifier own-oracle (3/4/5-way)
…ypes Add ConflictResolution enum, DocxDiffReviewer/DocxDiffConsolidateSettings/ DocxDiffConflictCompetitor/DocxDiffConflict/DocxDiffConsolidatedRevision types, and docx_diff_consolidate/get_conflicts/get_consolidated_revisions/ get_consolidated_edit_script module functions — mirroring the existing docx_diff_* surface and the npm/WASM wrappers added in T6.3.
…come + limitations
…ble-static empty fallback
…r fixtures The spec passed the same revised doc for both reviewers (identical edits = consensus, not a conflict), so GetConflicts correctly returned 0 and the length>0 assertion failed. Add three minimal fixtures where two reviewers change the same word differently (quick -> SLOW vs FAST), a real cross-reviewer token-overlap conflict, and point the spec at them.
…==base); multi-reviewer table edits route to recorded conflict (no silent drop); doc table limitation
…difies; scope StackAll base-anchor assert
…perlink-internal edits round-trip (accept==right, reject==left)
…reserve adjacent distinct same-target links)
…placeText (B3) ReplaceText replaces a whole block's visible text but already preserves zero-width, semantically-significant inline markers (bookmark/comment/perm ranges) across the replace. That preserved set covered only bare paragraph- child markers; a footnote/endnote reference lives inside a <w:r>, so it slipped through and was silently deleted — orphaning the note definition. This surfaced via the consolidate footnotes-survive battery (two reviewers built with ReplaceText lost the base footnote on accept). Diagnosis confirmed the base-engine DocxDiff 2-way diff round-trips a note-bearing edit cleanly (accept == right, reject == left, footnote survives), so the root cause is in DocxSession.ReplaceText, not the diff engine. Fix: recognize note-reference-only runs (a <w:r> whose only non-rPr content is a footnote/endnote reference) as preserved markers. The accept path re-attaches them around the replacement; the tracked path keeps them out of the w:del/w:ins so they survive on both accept and reject, in their original leading/trailing position. Bare-child markers are untouched. Tests: DS314 (footnote accept), DS315 (footnote tracked: accept==edited, reject==base, ref survives both), DS316 (endnote accept) — red before, green after. Full suite 2094/0/1; 179/179 parity floors and consolidate/composite suites unchanged.
…ite (no silent content loss on accept); doc v1 limitation
…tural-op fuzz coverage; contested-relocation tests + competitor text
LibreOffice manual verification + hardening passRan an exhaustive LibreOffice render verification of the consolidate feature: a battery of multi-reviewer scenarios across DOCX features (headings, bullet/numbered lists, tables, bold/italic/color runs, hyperlinks, footnotes) × merge behaviors (disjoint compose, sub-sentence compose, consensus, all 3 conflict policies, block insert/delete, 5 authors, kitchen-sink), each rendered through LibreOffice and adversarially inspected. The happy path (incl. 3-author kitchen-sink and 5-author attribution) renders correctly with per-author colors and Fixed in this PR (new composite regressions):
Fixed in this PR (pre-existing base-engine bugs surfaced by the battery):
Documented v1 limitations (in Gates: full .NET suite 2114/0/1, parity scoreboard 179/179 (unchanged), Release + WASM + |
…viewer moves render as w:moveFrom/w:moveTo (per-reviewer author, globally-namespaced move ids); collisions stay lowered conflicts
… move misclassified across a merge); two-reviewer move round-trip tests
…s-reviewer cell edits compose inline; same-cell edits conflict per policy; multi-author table rendering
…le-bucket whole-row re-registration); image-in-composed-cell test; BaseAnchor block-conflict doc
Pre-D4 follow-ons landed: native move + per-cell table compositionBoth deferred composite limitations are now implemented (composite-path only; base 2-way engine untouched — parity scoreboard stays 179/179), each TDD'd + reviewed + LibreOffice-smoke-verified. Native move composition — a single reviewer's non-colliding move now renders as a native Per-cell table composition — disjoint cross-reviewer table-cell edits now compose inline into one table with correct per-cell author attribution; only same-cell edits by 2+ reviewers become a recorded conflict (per policy, at a cell-paragraph anchor). The cell-paragraph composition recurses into the existing token/block merge machinery. Fallbacks to a whole-table block conflict (no silent loss): Smoke renders (LibreOffice): native move shows as a green move pair + a separate blue edit; disjoint table cells show one grid with orange/blue per-cell tracked changes; accept applies both, reject restores base. Gates: full .NET suite 2168/0/1, parity 179/179 + Consolidate parity 84/84, Release + WASM clean. Remaining D4-adjacent notes (documented): a paragraph move that crosses a table boundary can contest the table block (no loss; reject≡base); pure |
|
Tracking issues opened for the documented D4-adjacent follow-ons (all are no-silent-loss, reject≡base limitations, not blockers):
|
|
Two more follow-up issues opened (both no-silent-loss, reject≡base):
|
…rlink (#232) (#272) When a w:hyperlink-wrapped run was involved in a redline and its anchor text was fully rewritten with no shared token (same href), the IR markup renderer emitted the base's single w:hyperlink as two elements sharing the same r:id — a pure w:del-link of the old text followed by a pure w:ins-link of the new text. Text, target, and accept/reject round-trip were all correct, but the raw markup diverged from the source's single link (1 -> N), churning any tool that diffs the XML or counts w:hyperlink elements. This was the residual normalization left by the B1 fix (#228): its coalescer merged link fragments only when at least one carried a plain (Equal) run — a proxy for "same target" that missed the no-shared-token case. The coalescer now stamps each fragment with its RESOLVED target (external URI, or #anchor for an internal link — resolved exactly as IrReader does, via the part annotation on the retained source tree) and additively merges a same-source run when every fragment resolves to the same target. Keying on the resolved target (not the r:id string) is what keeps the WC019 whole-anchor RETARGET split into two links: text AND href change means the del/ins fragments carry the same r:id string at coalesce time but different resolved targets, so they must stay separate for the post-assembly r:id remap. Scope: the base two-way IrMarkupRenderer only; WmlComparer is untouched. B1 invariants (accept == right, reject == left) and the adjacent-distinct-same- target case are preserved; base two-way parity stays 179/179; full suite green (2671 passed). Regression coverage: Hyperlink_fully_replaced_same_target_ renders_as_one_hyperlink, Hyperlink_whole_anchor_retarget_stays_two_links, and an updated Hyperlink_single_token_anchor_no_equal_run_replaced_round_trips. Co-authored-by: Claude <noreply@anthropic.com>
Summary
Closes the last gap between the new IR-based comparison engine (
DocxDiff) and the originalWmlComparer— N-way composite / Consolidate — and goes beyond it. Merges N reviewers (each diffed against one shared base) into a single tracked-changes document, with per-reviewer attribution, a composite edit-script-as-data, an attributed revision list, and a structured conflict report.Docxodus/DocxDiff.cs,DocxDiffConsolidate.cs):Consolidate/GetConsolidatedRevisions/GetConsolidatedEditScriptJson/GetConflicts. N reviewers, no cap (supports >3-way / N sets of changed docs). Conflict policyBaseWins(default) /FirstReviewerWins/StackAll.IrCompositeMerger→IrCompositeScript→IrCompositeMarkupRenderer/IrCompositeRevisionRenderer/IrCompositeScriptJson. Rendering reusesIrMarkupRenderervia an additiveRightSource+ per-opAuthorOverrideextension — two-wayCompareoutput is byte-unchanged.DocxDiffBridge), stdio host (Dispatcher.cs), npm (docxDiffConsolidate*), docx-scalpel (docx_diff_consolidate*); CHANGELOG, CLAUDE.md, anddocs/architecture/ir_diff_engine.mdupdated.Parity outcome (a notable finding for the D4 default-engine decision)
A scoreboard over the 84 legacy
WmlComparer.Consolidatecases reports 84/84 reproduce-PASS, 0 deviations, 0 fails. It surfaced that legacyConsolidateis a juxtaposition/triage tool that never inline-merges — it appends each reviewer's labeled copy in colored boxes, even for a single reviewer. So reproduction is measured by sound semantics (single-revieweraccept ≡ reviewerchar-exact on all 74; multi-reviewer added-token composition), and legacy's whole-corpus box shape is the deliberate supersession: the IR engine doesn't merely match Consolidate, it replaces it with true merging.Documented v1 limitations (intentional, surfaced in docs)
Debug.Assertguards against silent drop) — follow-on.Test Plan
-c Release, warnings-as-errors): clean./scripts/build-wasm.sh): cleantsc --noEmit+npm run build: cleancd npm && npm test