Skip to content

fix(docx-compare): own terminal move breaks inside a story-closing content control - #1122

Merged
stevenobiajulu merged 3 commits into
mainfrom
1101-sdt-terminal-move-lo-20260928
Sep 28, 2026
Merged

stevenobiajulu merged 3 commits into
mainfrom
1101-sdt-terminal-move-lo-20260928

Conversation

@stevenobiajulu

Copy link
Copy Markdown
Member

Why

#1055 gave a whole-paragraph move with one body-story terminal endpoint Word-native break ownership (ordinary w:del/w:ins paragraph-mark revisions on the stable predecessor; moved content stays in paired move ranges) so LibreOffice Accept All / Reject All stop leaving an extra empty paragraph. The rule only recognised endpoints whose parent is w:body. The last paragraph of a block content control that closes the body story is the same terminal mark, so control([A,B,C]) -> control([C,A,B]) (Accept All) and -> control([B,C,A]) (Reject All) still left one empty paragraph in LibreOffice, as #1101 reports.

What

  • taggedTreeSerializer.ts: new closesBodyStory(container); wholeParagraphMoveEndpoint now accepts w:body or a w:sdtContent whose control (and any control it nests in) is followed by nothing but range boundaries and w:sectPr up to the body. The predecessor and terminal checks run among that container's siblings, so the break revision moves to the preceding paragraph inside the same control. A control that another block follows is unchanged: its last paragraph has a real break after it and keeps the middle-move topology.
  • No acceptor change: trackChangesAcceptorAst.ts and docx-core acceptChanges/rejectChanges resolve paragraph marks between direct siblings, which holds inside w:sdtContent, so safe-docx's own projections stay exact.
  • OpenSpec scenario "Terminal content-control paragraph uses body-story break ownership" in the refactor-tracked-paragraph-move-ownership delta; CHANGELOG entry.

Tests

  • packages/docx-core/src/integration/issue-1101-libreoffice-sdt-terminal-move.test.ts (LibreOffice oracle; skips when soffice is unusable): the two reported cases, a middle move inside the control, last-to-middle, a terminal control preceded by a body paragraph, and the two body-level cases. Asserts text and paragraphShape equal identity on Accept and Reject. All 7 pass locally against /opt/homebrew/bin/soffice; on main the two reported cases produced ["C","A","B",""] / ["A","B","C",""].
  • packages/docx-compare/src/tagged/paragraphMoveReview.test.ts: non-LO structural matrix (terminal destination, terminal source, terminal destination after a body paragraph, middle move inside the control, control followed by a body paragraph) pinning the paragraph-mark revisions, range-marker containment, verifySerializedMoveRanges, and exact AST and native Accept/Reject projections including control membership. Fails on main (expected [ Array(4) ] to deeply equal [ [ 'del' ], [], [ 'ins' ], [] ]).
  • Existing gates issue-941-libreoffice-terminal-move, issue-1028-libreoffice-sdt-boundary-move, nativeMoveParagraphParity, taggedTreeSerializer, terminalMarkCleanupParity pass. Full pre-submit (build, lint, test:run, spec-coverage, conformance-citations, conformance-doc, cycles) passed.

Closes #1101

…ntent control

#1055 gave a whole-paragraph move with one body-story terminal endpoint
Word-native break ownership (ordinary del/ins paragraph-mark revisions on
the stable predecessor, moved content in paired move ranges) so LibreOffice
Accept All and Reject All stop leaving an extra empty paragraph. The rule
only recognised endpoints whose parent is w:body. The last paragraph of a
block content control that closes the body story is the same terminal
mark, so control([A,B,C]) -> control([C,A,B]) and -> control([B,C,A]) still
left one empty paragraph after LibreOffice's projections.

wholeParagraphMoveEndpoint now also recognises a w:sdtContent whose control
(and any control it nests in) is followed by nothing but range boundaries
and w:sectPr up to the body. A control that another block follows is left
alone: its last paragraph has a real break after it and keeps the middle-
move topology. The acceptor already resolves paragraph marks between
direct siblings, so safe-docx's own projections stay exact.

Tests: a LibreOffice oracle gate (issue-1101-libreoffice-sdt-terminal-move)
covering the two reported cases plus middle-move, body-level and preceded-
by-body-paragraph controls, skipping when soffice is unusable; and a
structural matrix in paragraphMoveReview that pins the paragraph-mark
revisions, range containment, the serializer verifier and the AST and
native Accept/Reject projections without LibreOffice.

Fixes: #1101
@vercel

vercel Bot commented Sep 28, 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 Sep 28, 2026 10:07pm UTC

Request Review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-28T21:59:32.888516Z a7f1e1a 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 Sep 28, 2026 •

Copy link
Copy Markdown

LLM gate (advisory)

All evaluated rules passed - 6 pass, 0 warn, 0 error, 10 skipped, 16 total

Findings

None.

All 16 rules (6 evaluated, 10 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 only touches tracked paragraph move break ownership inside block content controls 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 does not touch DocxDocument.acceptChanges, DocxDocument.rejectChanges, REVISION_STORY_PART_PATHS, accept_changes, reject_changes, or side-part revision markup, as it only refactors tracked paragraph move ownership serialization and related tests.
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 Test assertions are independent of the SUT and verify correct document states from first principles, comparing against static arrays (e.g., paragraphMoveReview.test.ts:132-235) and identity documents processed through a LibreOffice oracle (issue-1101-libreoffice-sdt-terminal-move.test.ts:44-80) without SUT mocks.
Re-derived facts vs canonical sources PASS The PR generalizes the pre-existing body-level parent checks in packages/docx-compare by introducing the canonical closesBodyStory helper, avoiding any duplicate or redundant derivations of body/story boundaries.
.openspec tag ↔ test-assertion drift PASS The added .openspec tags in packages/docx-compare/src/tagged/paragraphMoveReview.test.ts (lines 136, 252) and packages/docx-core/src/integration/issue-1101-libreoffice-sdt-terminal-move.test.ts (line 57) are fully backed by rigorous, explicit covering assertions that establish each scenario's preconditions and assert their corresponding outputs. The implementation has zero tag-stuffing and successfully validates the GIVEN/WHEN/THEN requirements via direct structural and LibreOffice oracle-level assertions.
Library stays general (no downstream-domain leakage) PASS The PR only modifies or adds integration test files under packages/docx-core/src/integration/ and does not add or rename public APIs, types, recipes, functions, or identifiers.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a7f1e1a25f

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +513 to +515
return index >= 0 && siblings.slice(index + 1).every((candidate) =>
RANGE_BOUNDARY_LOCALS.has(candidate.localName) ||
(candidate.namespaceURI === W_NS && candidate.localName === 'sectPr'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat all block-level range markers as story-neutral

When a terminal content control is followed by a valid body-level w:permEnd, w:proofErr, or custom-XML range marker before w:sectPr, this predicate returns false because RANGE_BOUNDARY_LOCALS contains only bookmark, comment, and move markers. These elements are explicitly included in the repository's full block-sibling range set (RANGE_MARKUP_BLOCK_SIBLING_LOCALS), and they do not add another paragraph break, so the control still closes the story. Such documents therefore retain the legacy move-mark topology and the LibreOffice trailing-empty-paragraph defect this change is intended to fix.

Useful? React with 👍 / 👎.

Codex review of #1122 asked for two coverage additions. The #1028
LibreOffice gate filtered empty paragraphs out of its terminal-control rows,
so it could not see the extra empty paragraph #1101 is about; with a
detected move those "enters" and "leaves" rows are now exact on text and
paragraph shape, and the comment says why the "after it" row stays on the
legacy topology. The structural matrix gains the containers closesBodyStory
must accept (nested terminal controls) and reject (a customXml wrapper, a
terminal control with no in-control predecessor), each with exact AST and
native projections. Table cells are not represented because the comparator
does not detect moves inside a cell.

Ref: #1101
…ersions

The comparator refuses to insert a block content control, so the scenario
now keeps the control in both documents with a nested control in front of
the moved paragraph. Range-marker parents are asserted per direction.

Ref: #1101
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex peer review (gpt-6-sol) — verdict: APPROVE

Prompt: Context / Repository / Affected Files / Summary / Assumptions / Reviewer discipline / Areas of Uncertainty. Codex read all five affected files, ran paragraphMoveReview, nativeMoveParagraphParity, taggedTreeSerializer (74 passed), the #1101, #1028 and #941 LibreOffice gates (20 passed with soffice allowed; the sandboxed run skipped them), a direct compareDocuments probe of the #1028 terminal rows, and check:conformance-citations / check:conformance-doc. No correctness finding. Two LOW coverage findings, both accepted:

# Finding Adjudication Change
1 The #1028 gate's terminal-control rows filter empty paragraphs out before comparing, so they could not see the extra empty paragraph this PR removes; with a detected move the "enters" and "leaves" rows now emit del/ins ownership. Accepted. Verified against LibreOffice: both rows are now exact on paragraph text and paragraphShape for detectMoves=true; the "after it" row (control-side endpoint is not terminal) keeps the legacy topology and the nonEmpty assertion. a5ed8ff: exact assertions for those two rows, comment updated.
2 No structural test for nested controls, a table-cell control, a customXml wrapper, or a terminal control with no in-control predecessor. Accepted with one adjustment: moves inside a table cell are not detected by the comparator (it emits plain del/ins there, on main as well), so a cell-level control cannot exercise closesBodyStory and is not represented. a5ed8ff + e4b132c: new matrix with nested terminal controls (ownership applies), a customXml wrapper (legacy topology), and a terminal control whose moved paragraph follows a nested control (no paragraph predecessor: legacy topology), each asserting marks, per-direction range-marker parents, and exact AST and native projections.

Noted while adding row 2, not changed here (pre-existing, legacy topology, outside #1101): when the moved paragraph is the last block of a story-closing control and follows a table inside it, the AST reject projection keeps one empty paragraph because canSafelyRemoveEmptyParagraph refuses to remove a paragraph that directly follows a table with nothing after it.

Pre-submit was rerun after the fixes (see the checks on the final commit).

@stevenobiajulu
stevenobiajulu enabled auto-merge (squash) September 28, 2026 22:07
@codecov

codecov Bot commented Sep 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.00000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...es/docx-compare/src/tagged/taggedTreeSerializer.ts 75.00% 0 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@stevenobiajulu
stevenobiajulu merged commit 8af933d into main Sep 28, 2026
26 of 27 checks passed
@stevenobiajulu
stevenobiajulu deleted the 1101-sdt-terminal-move-lo-20260928 branch September 28, 2026 22:21
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Post-merge smoke

Squash 8af933d7 confirmed on origin/main (git fetch && git log origin/main --oneline -5), built in the worktree at that commit (git switch --detach origin/main && npm run build, exit 0), LibreOffice /opt/homebrew/bin/soffice via the repo's runLibreOfficeOracle / paragraphShape.

Real document. No public document in tests/test_documents/ or the public real-corpus manifest ends in a block content control, so the smoke uses tests/test_documents/open-agreements/letter-of-intent.docx and wraps its three closing prose paragraphs (Mutual (no disclosure…), Incorporating existing NDA (no disclosure…), One-way: We, the Provider…, all Heading2 + numPr) in a synthetic <w:sdt> as the last body block; the signature table, its trailing empty paragraph, and the plain By signing this LOI… paragraph are dropped so the wrapped paragraphs share one style (synthetic wrapping stated as required). Each variant compares original → revised with detectMoves: true for the two shapes #1101 reports (control([A,B,C]) → control([C,A,B]) and → control([B,C,A])), then runs LibreOffice Accept All / Reject All against identity round-trips of the revised and original documents. Header/footer references are stripped from every oracle job alike because the oracle packs a bare document.xml.

Command per variant (script smoke-1101.test.ts, copied into packages/docx-core/src/__smoke__/, removed afterwards):

cd packages/docx-core && SMOKE_DROP=3 SMOKE_TAG=<tag> [SMOKE_NO_CONTROL=1] [SMOKE_PLAIN=1] \
  node ../../node_modules/vitest/vitest.mjs run src/__smoke__/smoke-1101.test.ts --silent=false
Variant safe-docx accept / reject LO Accept All vs identity(revised) LO Reject All vs identity(original)
control, paragraphs as in the LOI (Heading2 + numPr) exact / exact 18 vs 17 paragraphs, extra "" — shape mismatch 18 vs 17, extra "" — shape mismatch
body level, same paragraphs, no control (the #1055 baseline) exact / exact 18 vs 17, extra "" — identical mismatch 18 vs 17, extra "" — identical mismatch
control, paragraphs with their w:pPr stripped exact / exact 17 vs 17, text and paragraphShape equal 17 vs 17, equal
body level, w:pPr stripped exact / exact 17 vs 17, equal 17 vs 17, equal

Both move shapes gave the same result in every row. Output excerpt (body level, styled, last→front):

paragraphs: LO accept 18 vs identity(revised) 17; LO reject 18 vs identity(original) 17
tail accept   ["Incorporating existing NDA (no disclosur","One-way: We, the Provider, have or may d","Mutual (no disclosure to investors or ac",""]
tail identity ["Incorporating existing NDA: We previousl","Incorporating existing NDA (no disclosur","One-way: We, the Provider, have or may d","Mutual (no disclosure to investors or ac"]

Reading. The content-control rows now behave exactly like the body-level rows, which is what #1101 asks for: the redlines carry identical w:del/w:ins break ownership at both levels (checked in word/document.xml), and with plain paragraphs LibreOffice matches identity on text and paragraphShape inside the control just as at body level. The styled/numbered variant fails at body level too, without any control, so it is a pre-existing #1055-family gap (a terminal move of a paragraph whose mark w:rPr carries run formatting under pStyle + numPr), not something this PR introduced or claims to fix; safe-docx's own projections are exact in all four variants. Reported as a follow-up in the shipping report rather than as a new issue.

The synthetic gate on the merged build: issue-1101-libreoffice-sdt-terminal-move.test.ts — 7 passed (exit 0).

Files for a Word check (regenerate with the commands above; kept under ~/.claude/plans/fanout-safe-docx-2026-09/1101/smoke-merged/): loi-control-plain-last-to-front-redline.docx (the fixed shape) and loi-body-styled-last-to-front-redline.docx (the pre-existing gap, for comparison).

Remote branch 1101-sdt-terminal-move-lo-20260928 was deleted on merge.

This branch was successfully deployed

1 active deployment
Preview — e4b132c9 Deployed Sep 28, 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: LibreOffice leaves an extra empty paragraph for a terminal move inside a block content control

1 participant