Skip to content

fix(replace): tracked span inside another author's insertion splits it Word-style - #20

Merged
kklimuk merged 3 commits into
mainfrom
claude/triage-issue-13-minimal
Oct 8, 2026
Merged

kklimuk merged 3 commits into
mainfrom
claude/triage-issue-13-minimal

Conversation

@kklimuk

@kklimuk kklimuk commented Oct 8, 2026

Copy link
Copy Markdown
Owner

🤖 docx-cli triage

Replaces #15 (closed): a from-scratch, narrowly gated fix instead of the rewrite.

What was wrong

replace --track --author Editor (and edit --at pN:S-E --track) on text inside Reviewer A's pending <w:ins> left the replacement as a bare run inside A's insertion, so Word credited it to A. If A's insertion also held Reviewer B's nested <w:del> before the match, the cut slid left by its length and the wrong words were deleted:

$ docx replace nested.docx "disputed amount" "disputed sum" --track --author Editor
replace 1 occurrence replaced          # but "The Client may " was deleted, match untouched

What changed

  • One gate, one new file. replaceSpanInParagraph gets a single early branch: under tracking, when a non-empty span lies wholly inside a <w:ins>/<w:moveTo> that is a direct child of the paragraph, it dispatches to src/core/find/replace-in-revision.tsx (new, ~250 lines). Every other input runs the existing code untouched.
  • Different author: Word's shape. The cut becomes Editor's <w:del> nested inside A's insertion; the replacement is Editor's own <w:ins> as a sibling; A's tail continues in a copy with a fresh w:id (same author/date). Nested children (B's deletion, bookmarks, comment markers) keep their position and are never re-wrapped; a tail holding only markers isn't minted as a revision.
  • Same author (your decision): no split, no <w:del>; the text is edited in place inside the existing <w:ins> (same id/date). Zero-width anchors in the cut (footnote/comment references, images, text-box anchors) survive; counted characters (a matched tab) go with the text; a comment range that opened in the cut brackets the replacement. Exact string match on w:author. An author's own <w:moveTo> takes the split path (moved text is relocated original text, not new words).
  • revN pairing (cli/track-changes/groups.ts, +1 condition): a del+ins pair must share an author, so A's head insertion no longer pairs with Editor's nested deletion into one acting handle.

How it works

flowchart TD
  A[tracked span, all runs under one parent] --> B{parent is w:ins / w:moveTo directly in the paragraph?}
  B -- no --> C[existing code, byte-identical to main]
  B -- yes, other author --> D[nest Editor w:del in it · Editor w:ins beside it · tail copy with fresh id]
  B -- yes, same author, w:ins --> E[edit in place inside the insertion, keep anchors]
Loading

Verification

  • Issue cases 1 and 2 now produce exactly the expected XML; read --accepted → "Fees are due. The Client may withhold disputed sum."; B's deletion byte-identical; all outputs pass docx validate. Same-author case → one insertion, may withhold a disputed sum.
  • Golden comparison vs main: 18 commands × 36 fixtures (untracked/tracked replace, --all, span edits, empty-span inserts, --current/--baseline, hyperlinks) = 648 runs; 636 byte-identical, and all 12 differing runs are gated cases (tracked matches inside an existing insertion/move in tracked-changes, multi-tracked, tracked-moves). track-changes list over all fixtures: 72/72 identical.
  • bun run check ✅ bun run test:unit ✅ (1740 tests, +16 new, no existing expectation changed).
  • Three independent /code-review xhigh passes on the full diff: no regression against main found in any pass. Every finding inside the new path was fixed (anchors, moveTo, phantom tail, revN authors, counted characters, range ordering, page-break marker).

Pre-existing issues the reviews surfaced (deliberately NOT touched here)

Each can be its own issue; say which you want filed:

Decisions for you

  • Which of the pre-existing items above to file as issues (I'd start with the first).

Fixes #13

🤖 Generated with Claude Code

https://claude.ai/code/session_012GoxD36rnNfg6uArkVxtJL


Generated by Claude Code

claude added 3 commits October 8, 2026 20:52
…t Word-style (#13)

`replace --track` and `edit --at pN:S-E --track` share `replaceSpanInParagraph`.
When the match lay wholly inside another author's pending `<w:ins>`, the
replacement landed as a bare run inside that ins (credited to the other
author), and when the match sat in a later run of the ins (e.g. after a nested
`<w:del>`) the offset walk started at that run and cut the wrong words.

Add ONE early branch in `replaceSpanInParagraph` that dispatches to the new
`core/find/replace-in-revision.tsx` when: the replace is tracked, the span is
non-empty, every overlapping run shares one immediate parent, and that parent
is a `<w:ins>`/`<w:moveTo>` directly under the paragraph. Another author's
wrapper is split the way Word does it (wrapper keeps its id + pre text + the
editor's nested `<w:del>` of the cut; the editor's own top-level `<w:ins>`
carries the replacement; the tail rides a fresh-id copy of the wrapper). The
editor's own wrapper (exact `w:author` match) is rebuilt in place — same id,
no `<w:del>`, no split. Nested non-run children move whole at their
view-visible length and keep their document position (one `<w:del>` per
contiguous cut group).

Every other input takes the existing walkers unchanged: a golden comparison of
648 (fixture, command) pairs against main differs only on the 12 that hit the
gate. No existing test expectation changed.

Triage-Routine: true
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GoxD36rnNfg6uArkVxtJL
… per-author revN (#13)

Four follow-ups on the in-revision span replace:

- The editor's own `<w:ins>` rebuild no longer discards the cut runs outright:
  every non-text child (footnote/endnote/comment reference, drawing, text-box
  anchor, object) is kept in place, so what was anchored on the replaced words
  survives (`cutRemnants`).
- The in-place merge is restricted to `<w:ins>`. A same-author `<w:moveTo>`
  takes the split path — moved text is relocated original text, not the
  author's new words.
- A tail that holds only markers (comment range end + its reference run,
  bookmark end) stays in the wrapper after the cut instead of riding a
  fresh-id copy that would list as an empty revision (`isPureMarker`).
- `revN` pairing (`cli/track-changes/groups.ts`) requires both halves to share
  an author, so the other author's head ins never pairs with the editor's
  nested del. `track-changes list` output is unchanged on every fixture.

Triage-Routine: true
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GoxD36rnNfg6uArkVxtJL
…; page-break tail is a marker (#13)

Final pass on the in-revision span replace:

- `cutRemnants` keeps only ZERO-width children of the cut runs (note/comment
  references, drawings, text-box anchors — `inlineMarkerWidth === 0`, now
  exported from the parser barrel); a tab/break/sym the offsets counted as a
  character goes with the matched text instead of surviving the replacement.
- Range-start markers, and anything ahead of the first cut run, are emitted
  BEFORE the replacement; range ends, reference runs and kept remnants AFTER
  it — a comment that opened inside the cut brackets the new words instead of
  collapsing to a point.
- `isPureMarker` treats a run of nothing but `w:rPr` / `w:commentReference` /
  `w:lastRenderedPageBreak` as a marker, so such a tail no longer mints a
  phantom fresh-id `<w:ins>`.
- Docs: drop the stale "pairs without checking authors" sentence; state the
  author-equality rule in the revN section of cli/track-changes/CLAUDE.md.

Triage-Routine: true
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GoxD36rnNfg6uArkVxtJL
@kklimuk
kklimuk merged commit 4cd0388 into main Oct 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

replace --track inside another author's pending insertion credits the new text to that author, and misses the target next to a nested deletion

2 participants