Skip to content

fix(docx-compare): apply a layered formatting revision per span of unchanged text, not per word - #1503

Merged
stevenobiajulu merged 3 commits into
mainfrom
1488-layered-formatting-coalesce-20261115
Nov 15, 2026
Merged

stevenobiajulu merged 3 commits into
mainfrom
1488-layered-formatting-coalesce-20261115

Conversation

@stevenobiajulu

@stevenobiajulu stevenobiajulu commented Nov 15, 2026 •

Copy link
Copy Markdown
Member

Fixes #1488

Problem

A run that already carries a pending w:rPrChange (PRESERVE: the original's revision stays and the comparison is layered on top) and then gains bold while one of its words is replaced exploded in the redline. The comparison splits that run word by word because a word changed, and the layered-formatting splitter from #1464 then replaced every word and space with its own deleted-copy/inserted-copy pair.

  • Repro sentence (iso-layered.mjs): 28 w:del, 28 w:ins, 56 w:rPrChange.
  • NVCA Stock Purchase Agreement, one paragraph: about 220 pairs and 438 duplicated prior w:rPrChange records.
  • Accept All and Reject All were already correct; the redline was unreadable.

Approach

splitLayeredRunFormatting (packages/docx-compare/src/tagged/layeredFormattingRevision.ts) now runs in two passes:

  1. It collects every run that needs the lossless deleted/inserted copy. The fallback for runs that cannot be repeated is unchanged.
  2. It joins each maximal sequence of adjacent sibling runs whose run attributes (rsids ignored), original properties (the comparison record's snapshot) and revised properties (without the comparison record) are identical into one span. Each span becomes one deleted copy holding the original w:rPr exactly, pending record included, and one inserted copy holding the revised w:rPr. Each copy holds the span's whole content, with adjacent w:t joined.

The replaced word between two spans stays the ordinary w:del/w:ins the comparison already emitted. Pending records keep their w:id in the span signature, so runs holding distinct prior revisions are never merged and each prior revision keeps its own record. PRESERVE semantics do not change.

Representation: run-level deleted and inserted content (ECMA-376 edition 5, Part 1 §17.13.5.14 w:del, §17.13.5.18 w:ins), each copy carrying at most one run-property revision (§17.13.5.31 w:rPrChange), as #1464 established. The repro becomes three pairs: The Company is not (layer), bound→REVISEDTERM (word), by any agreement … (layer). It previously had 28.

The diff touches only layeredFormattingRevision.ts and its test file. It does not touch pipeline.ts, textBoxRevisionSafety.ts or the serializer.

Evidence

New tests in packages/docx-compare/src/tagged/layeredFormattingRevision.test.ts, describe block "#1488":

Real document (NVCA Stock Purchase Agreement, dogfood "preserve-body" recipe): the paragraph whose run carries a pending w:rPrChange, gains bold and has one word replaced. Both builds compared the same original/revised pair:

build w:del w:ins w:rPrChange
base e8d02e98 219 219 438
this PR 3 3 6

On this PR's build, the dogfood harness reports PASS for compare, schema gate, accept/reject (all parts, both appliers), comparison-layer-only projections keeping pending records, unique revision IDs and every change redlined in place.

Pre-submit: build, lint:workspaces, test:run, check:spec-coverage, check:conformance-citations, check:conformance-doc and check:real-corpus (37/37) all pass, except two odf-core LibreOffice differential tests (lo_paragraph_roundtrip, lo_convert_differential). Those fail on this host with "LibreOffice oracle produced no output". The host's LibreOffice macro fails to launch, and this diff does not touch odf-core, which does not depend on docx-compare.

Left out

Nothing in this shape. Runs whose content cannot be repeated (field characters, drawings, note or comment references) or that sit where a run revision is not allowed still keep the single-record fallback from #1464; this PR does not change it.

…changed text, not per word

A run that already carries a pending w:rPrChange and then gains bold while
one of its words is replaced is compared word by word, so it reaches the
layered-formatting splitter (#1464) as one run per word and space. The
splitter replaced each of those runs with its own deleted/inserted copy pair,
so a single sentence became 28 pairs and 56 rPrChange records (about 220
pairs and 438 duplicated prior records in one NVCA Stock Purchase Agreement
paragraph). Accept All and Reject All were correct but the redline was
unreadable.

The splitter now joins each maximal sequence of adjacent sibling runs whose
original and revised properties are identical into one deleted copy and one
inserted copy holding the whole span; the replaced word between two spans
stays the ordinary w:del/w:ins the comparison already emitted. The repro
drops to three pairs. Runs holding distinct pending records (different IDs)
are never joined, so each prior revision keeps its own record and PRESERVE
semantics are unchanged.

Fixes: #1488
@vercel

vercel Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
site Ready Ready Preview Nov 15, 2026 4:00pm UTC

Request Review

@github-actions github-actions Bot added the fix PR type: bug fix label Nov 15, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 15, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T15:23:29.589154Z 413b509 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@usejunior-llm-gate

usejunior-llm-gate Bot commented Nov 15, 2026 •

Copy link
Copy Markdown

LLM gate (advisory)

All evaluated rules passed - 5 pass, 0 warn, 0 error, 11 skipped, 16 total

Findings

None.

All 16 rules (5 evaluated, 11 skipped)
Rule Verdict Detail
read_file response metadata parity SKIPPED paths not touched by this PR
Live DOM namespace-safe OOXML writes SKIPPED paths not touched by this PR
Complex-field revisions preserve complete accept/reject state machines PASS The PR modifies layered run formatting revision split coalescing in packages/docx-compare/src/tagged/layeredFormattingRevision.ts and does not touch field atomization, validateFieldStructure, w:fldChar, w:instrText, w:delInstrText, or collapsed-field comparison logic.
Field validation per story, not global SKIPPED paths not touched by this PR
Revision IDs seeded from all revision-bearing side parts SKIPPED paths not touched by this PR
Accept/reject sweep side parts and caches PASS The PR only modifies layered run formatting revision handling in packages/docx-compare/src/tagged/layeredFormattingRevision.ts and its tests, and does not touch DocxDocument.acceptChanges, DocxDocument.rejectChanges, REVISION_STORY_PART_PATHS, accept_changes, reject_changes, or side-part revision markup.
DocumentViewNode.heading stays canonical SKIPPED paths not touched by this PR
AI-author parity across entry points SKIPPED paths not touched by this PR
Property-change wrapper discipline SKIPPED paths not touched by this PR
SUPPORT.md Table A drift vs. implementation SKIPPED paths not touched by this PR
Table A / Table B boundary on side-part revisions SKIPPED paths not touched by this PR
Canonical-emission surface completeness SKIPPED paths not touched by this PR
Unit-test quality (avoid tautological / change-detector tests) PASS The tests added in layeredFormattingRevision.test.ts for issue #1488 verify exact text content spans and accept/reject projection round-trips against first-principle literal expected values without mocking the system under test.
Re-derived facts vs canonical sources PASS The PR updates splitLayeredRunFormatting in packages/docx-compare/src/tagged/layeredFormattingRevision.ts:101 to coalesce adjacent runs sharing identical properties and pending records; it does not re-derive any facts computed elsewhere or re-implement existing helpers.
.openspec tag ↔ test-assertion drift PASS The PR modifies tests in packages/docx-compare/src/tagged/layeredFormattingRevision.test.ts but does not add, move, or change any .openspec tags.
Library stays general (no downstream-domain leakage) SKIPPED paths not touched by this PR

…copies and guard adjacent spans

Review of #1503 found that the opt-in LibreOffice block asserted a positive
result that LibreOffice 26.2 cannot give. Accept All and Reject All both keep
the deleted and the inserted copy when each carries its own w:rPrChange. The
same thing happens with the one-pair shape #1464 emits for bold alone and with
the per-word pairs before this change. The block now pins that behaviour as a
characterization, beside a plain deletion/insertion control that resolves
correctly, so it fails when LibreOffice changes.

Also add the review's stronger signature guard: directly adjacent bold and
italic spans that share one pending record. A signature that ignored revised
properties would join them, and this test fails on that mutation.

Refs: #1488
@codecov

codecov Bot commented Nov 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.80488% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...cx-compare/src/tagged/layeredFormattingRevision.ts 87.80% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex peer review, round 1: CHANGES_REQUIRED (reviewed 413b509b)

Reviewer: codex exec -m gpt-6.1-sol (medium). It confirmed HEAD at both the start and end of the run and left the tree clean.

Finding P2: the opt-in LibreOffice block asserts a positive result that LibreOffice 26.2.5.2 cannot give. Accepted, fixed in 458235eb.

  • I reproduced the reviewer's result independently. LibreOffice keeps both the deleted copy and the inserted copy on Accept All and on Reject All whenever each copy carries its own w:rPrChange. The cases tested were:
  • An identity round trip keeps the copies (ins=2, del=3, rPrChange=6). This means LibreOffice reads them but does not resolve them. A plain w:del/w:ins control in the same batch resolves correctly (NEW/OLD).
  • The reviewer also attributed the limitation to the existing fix(docx-compare): layer a formatting change onto a pending formatting revision without nesting #1464 representation, not to this PR.
  • The fix turns the block into a characterization pin with the plain-revision control: the control must resolve, and the layered span keeps both copies. The pin fails if LibreOffice starts resolving the shape. Microsoft Word, the behavioural oracle for this shape, is DEFERRED on this host. I will report the LibreOffice gap as a follow-up and not file it here.
  • Ran through heavy-run.sh with SAFE_DOCX_LAYERED_FORMATTING_READER_REQUIRED=1: 1 passed.

Suggestion (non-blocking): add a guard for directly adjacent spans of different revised formatting. Adopted in 458235eb.

  • New test: bold and italic spans that share one pending record, with no replacement breaking adjacency between them.
  • Mutation check: a signature that ignores revised properties fails this test and only this test (× keeps directly adjacent spans of different revised formatting apart).

The reviewer found no defect in the coalescing algorithm itself. Its own dynamic checks passed:

  • schema gate plus 4 projections for adjacent formats, mixed content (Hebrew/w:rtl/w:cs/w:tab/w:sym/w:br), a bookmark between runs, and multiple w:t with leading, trailing and internal spaces
  • both groupings

A confirmation round follows.

@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex peer review, round 2 (confirmation): APPROVE (reviewed f2427eb8)

No P1, P2 or P3 findings. The round 1 P2 is resolved: the LibreOffice block is now a characterization with a plain-revision control. The reviewer ran it on LibreOffice 26.2.5.2 and then ran all 26 tests with the reader required.

The reviewer also killed the mutation that drops revised properties from layeredRunSignature: the directly adjacent bold/italic guard fails on it, through the pipeline's own formattingFidelity safety check.

Scratch checks confirmed that the following split spans: bookmarks, comment boundaries, whitespace nodes and differing run attributes. They also confirmed that coalescing keeps RTL/complex-script properties and the order of tab/symbol/break content. The worktree was clean after the review, with HEAD unchanged.

@stevenobiajulu
stevenobiajulu enabled auto-merge (squash) November 15, 2026 16:04
@stevenobiajulu
stevenobiajulu merged commit afc6b79 into main Nov 15, 2026
29 checks passed
@stevenobiajulu
stevenobiajulu deleted the 1488-layered-formatting-coalesce-20261115 branch November 15, 2026 16:24
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Post-merge smoke: PASS

Merged as afc6b79c. The squash tree (d59d5dd6) equals PR head f2427eb8's tree, so the smoke ran from the PR worktree with docx-compare rebuilt. CI: every required check passed on the first run, including workspace-test (20), with no reruns.

Before/after build: the PR build with the pre-fix layeredFormattingRevision.

(a) Original reproduction (iso-layered.mjs)

case grouping this build w:del/w:ins/w:rPrChange before
word replaced + bold readable-semantic 3 / 3 / 6 28 / 28 / 56
word replaced + bold token-minimal 3 / 3 / 6 28 / 28 / 56
bold only readable-semantic 1 / 1 / 2 1 / 1 / 2
bold only token-minimal 1 / 1 / 2 1 / 1 / 2

(b) Real documents: the layered paragraph in the dogfood "preserve-body" variant

The paragraph's run has a pending w:rPrChange, gains bold, and has one word replaced.

document this build before
NVCA Stock Purchase Agreement 3 / 3 / 6 219 / 219 / 438
NVCA Voting Agreement 3 / 3 / 6 112 / 112 / 224
Common Paper CSA 3 / 3 / 6 99 / 99 / 198

On all three, the schema gate (check_emitted_document_schema.mjs) passes. Accept All reproduces the revised document and Reject All reproduces the original, for every story part, with both docx-compare's acceptor and docx-core acceptChanges/rejectChanges. In the "full" variant, NVCA SPA and NVCA VA also give 3 / 3 / 6.

(c) Full dogfood harness (run-all.sh, compare phase)

criterion full: SPA / VA / CSA boxes: SPA / VA / CSA boxes + header pending: SPA / VA / CSA preserve-body: SPA / VA / CSA
C1 compare succeeds PASS / PASS / FAIL (#1502) PASS ×3 PASS ×3 PASS ×3
C2 unrepresented empty/expected FAIL / FAIL (#1499) / – PASS ×3 PASS ×3 PASS ×3
C3 schema gate PASS / PASS / – PASS ×3 PASS ×3 PASS ×3
C4 accept/reject faithful (all parts, both appliers) PASS / PASS / – FAIL / FAIL (#1489) / PASS FAIL / FAIL (#1489) / PASS PASS ×3
C4b comparison-layer-only projections keep pending records PASS / PASS / – PASS ×3 PASS ×3 PASS ×3
C5 revision IDs unique PASS / PASS / – PASS ×3 PASS ×3 PASS ×3
C6 each change redlined in place PASS / PASS / – PASS ×3 PASS ×3 PASS ×3
C7 LibreOffice not run (see below) not run not run not run
C8 Word DEFERRED DEFERRED DEFERRED DEFERRED

None of the failures come from this PR:

LibreOffice: not run. On this host the LibreOffice oracle returned "produced no output for job0.odt" for all three variants: the macro fails to launch, as already noted in the PR's pre-submit. The PR body records the reader characterization from before.

Word: DEFERRED. The staged files are in ~/.cache/safe-docx-compare-fanout/word-staged/1488/.

This branch was successfully deployed

1 active deployment
Preview — f2427eb8 Deployed Nov 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix PR type: bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docx-compare: layered formatting revision splits every word into its own deleted/inserted pair when a word also changes

1 participant