feat(word-addin): multi-occurrence tracked edits - positional identity, replace-all passes, whole-paragraph deletion

↗ view on GitHub · Amal · 2026-08-19 · 3c651a6b

WHY THIS MATTERS
Three user-visible failures shared one root cause - the add-in identified
everything (search targets, revisions, deletions) by TEXT alone:
1. "Replace X with Y" on a document with several X's silently did nothing
   (the model's prose claimed success while the card skipped).
2. Two pending edits that shared replacement text - the same correction in
   two places - broke each other: Accept on either card errored with
   "The revisions in this passage changed...". This was a LIVE bug on main.
3. "Remove point 2" from a numbered list emptied the paragraph but left
   the numbered husk behind, so Word kept counting a blank item.

WHAT IS POSITIONAL REVISION IDENTITY
On Word for the web, retained range proxies under-report revisions (an
anchor on the inserted text sees only the Added half), so resolution falls
back to the document-wide collection and identifies Mike's revisions by
text with an exactly-one-per-side safety rule: if two pending revisions
carry the same text, nothing is touched. Correct, but it made identical
corrections mutually un-resolvable. The fix narrows candidates by LOCATION
first: each candidate revision's range is compared against the edit's own
retained anchors with Range.compareLocationWith (WordApi 1.3, read-only -
expandTo/intersectWith pollute the web undo stack, office-js#5715), and
only then must exactly one per side remain. "Exactly one inside my own
passage" is strictly stronger than "exactly one in the document".
Containment accepts the full relation set {equal, inside*, contains*}
because hosts disagree on boundary relations (office-js#2527); the Deleted
half also accepts adjacency, since split authoring writes the insertion
immediately after the deleted original. Each anchor gets its own Word.run:
a stale proxy fails its whole batch, and the next anchor still deserves
its chance.

HOW REPLACE-ALL WORKS
An edit carrying <occurrence>all</occurrence> reuses the single-match
pipeline unchanged, once per occurrence:
- Each pass targets the LAST revision-free match in document order.
  Reverse order sidesteps office-js#2800 (Word-web corrupts later match
  positions once an earlier one changes length); "revision-free" is the
  natural bookkeeping - a pass's own tracked changes make that occurrence
  drop out of the next pass's candidate filter.
- Every pass produces its own handle, verification, and hidden bookmark
  (stableEditId `${key}#${pass}`), so ALL existing exactly-one safety
  guards apply per occurrence without modification. The alternative -
  teaching every guard "exactly N" - was rejected as unsound: N identical
  revisions from OTHER cards can satisfy a count, and a replace-all card
  could then resolve someone else's revisions.
- The controller aggregates the passes under ONE card whose Accept/Reject
  resolves every occurrence together, in Review and Direct modes, and a
  reloaded pane rediscovers the passes from the anchor registry prefix.
- matchWholeWord guards substring over-reach ("Bank" in "Banking"), but
  only for ASCII-word-bounded originals - the option is broken on the web
  for accented text and @/#-prefixed terms (office-js#985, #3360, #5223).
- Occurrences that already carry revisions are FILTERED, not fatal, so a
  replace-all coexists with pending redlines and reports a partial apply
  honestly ("Applied N of M occurrences...").

HOW WHOLE-PARAGRAPH DELETION WORKS
A delete-only edit whose <original> equals its paragraph's ENTIRE text
escalates from deleting the text range to deleting the paragraph's Whole
range - the paragraph mark included, which is the character whose removal
makes Word renumber the surviving list items. The byte-equality gate means
the escalation can never remove text the model did not quote; table cells
stay text-only (removing a cell's lone paragraph is a structural table
change); deletedTextMatchesOriginal() tolerates the trailing paragraph
mark that Word appends to such a Deleted revision's reported text. The
paragraph proxy also feeds every card's location hint, and is taken from
the SELECTED target match - never blindly items[0] - so replace-all passes
compare against the right paragraph.

TEST STRATEGY
The Office.js mock gains what real Word already had: position memory.
Revisions written through a search match are remembered per position
(query + match index), so a re-search "sees" them - without this, a
replace-all's second pass would re-apply the same occurrence forever.
compareLocationWith is mocked as revision-group identity, and
Office.context.requirements now answers WordApi (desktop sets stay
unsupported, keeping the suite on the web-shaped paths). New specs:
replace-all.spec.ts (review/reject/direct/reload), the
identical-replacement regression in chat.spec.ts - verified to FAIL with
narrowing disabled - and list-item-deletion.spec.ts for the renumbering
escalation. 292/292 across chromium + webkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5yxFV2YCFmR61KLyB33yi
Repository open-legal-products/mike
Author Amal <mamalanand3@gmail.com>
Authored
Committed
Parents 30758b55
Stats 7 files changed , +1556 , -262
Part of Make Word edit approval reliable across hosts

Capture this commit into my fork

Download a Markdown prompt that tells Claude how to port this exact commit into your working tree. Run it via claude -p < capture-commit-3c651a6b.md from inside the repo you want the change in.

⬇ Download capture-commit-3c651a6b.md