feat(word-addin): multi-occurrence tracked edits - positional identity, replace-all passes, whole-paragraph deletion
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.