Skip to content

fix(merge): textBox/pict object fingerprint asymmetry — every unmodified round-trip false-conflicts #652

Description

@thewrz

This was written agentically; verify its assertions and edit accordingly:

What's wrong

fingerprintBlob (src/merge/object-fingerprint.ts) is used asymmetrically for textBox/pict-kind body objects, so every unmodified round-trip of a captured text-box object falsely reports as an objectConflict.

  • Base side (src/merge/diff.ts, detectObjectConflicts): fingerprintBlob(snapshot.meta.blob) fingerprints the DB-stored ObjectMeta.blob directly. Per src/parser/docx/body-objects.ts's own documented capture convention ("a text box's blob root is the HOST body paragraph (w:p) that carries the drawing run" — see the module comment near line 12-18), a textBox/pict object's blob root is the host w:p wrapping the w:r > w:drawing/w:pict run — never the bare drawing/pict node.
  • Theirs side (src/merge/extract.ts, walkObjectBlocks): when it matches an OBJECT_BLOCK_TAGS tag (w:tbl/w:drawing/w:pict) in the freshly re-parsed round-tripped document, it fingerprints [node] where node is the matched tag itself — for w:drawing/w:pict this is the bare drawing/pict node, with no host paragraph wrapper.

For table-kind objects this is symmetric (a w:tbl IS the whole block-level object, no host paragraph involved), so the existing #520/#648 table-based tests never exercise this — they all pass. For textBox/pict-kind objects the two sides fingerprint structurally different node trees (base includes an extra w:p(w:r(...)) wrapper layer that theirs lacks), so fingerprintsDiverge is always true, even for a byte-identical, unmodified round trip.

Repro

Reproduced directly against generateDocxextractContentControls with a hand-built ObjectMeta of kind: 'textBox' (host-w:p-wrapped blob, matching body-objects.ts's own capture shape) and no edits at all:

original fingerprint:  { kind: 'textBox', hash: '20a226d7...' }
extracted fingerprint: { kind: 'textBox', hash: 'e5e2d27f...' }

Hashes never match for a textBox/pict object, regardless of content.

Where this was found

Discovered while adding #648 regression coverage (draft PR for #648) for collectDrawingAnchors's object-interior vanish handling: adding a real DB → generateDocxextractContentControlscomputeDiff integration test for a textBox-kind object (mirroring the existing table-kind #648 wiring test in src/api/diff.integration.test.ts) immediately produced a spurious objectConflicts entry on an otherwise-untouched round trip. That addition was reverted out of the #648 PR since this bug is unrelated to vanish handling — this issue tracks fixing it separately.

Suggested fix direction (unverified — needs its own investigation)

Either:

  1. walkObjectBlocks fingerprints the enclosing host w:p for w:drawing/w:pict matches (mirroring capture's own convention), or
  2. Capture (body-objects.ts) is changed to store just the bare drawing/pict node as blob root for textBox/pict kind (bigger blast radius — buildObjectBlocks's re-emit and the existing #520/generator tests already depend on the host-w:p-wrapped shape).

Option 1 looks lower-risk but needs verification against the interior-uuid capture path (findInteriorUuids) and the generator's re-emit wiring before committing to it.

Impact

Any real Word document containing a text box or VML shape with anchored SpecR content controls will show a false structural conflict on every diff, even with zero edits — this is a live correctness bug on the round-trip/merge path for a supported object kind (ADR-072).

🤖 Co-authored by Claude Sonnet 5.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions