From 3b9f80095c753bcb4d99771b5adabf906fd0eebb Mon Sep 17 00:00:00 2001 From: Steven Obiajulu Date: Mon, 14 Sep 2026 13:31:53 -0500 Subject: [PATCH] fix(docx-compare): remove accepted ghost section breaks Accepting a tracked removal can leave an empty paragraph-owned sectPr after its revision snapshot is discarded. That node remains a semantic section break and causes the exact source-formatting publication gate to reject otherwise faithful comparisons. Remove only that narrowly identified container while preserving live and final section properties, and cover the behavior with invented XML and built-DOCX regressions. Ref: UseJunior/legal-context#918 --- .../design.md | 31 +++++++++ .../proposal.md | 33 ++++++++++ .../specs/docx-comparison/spec.md | 29 +++++++++ .../tasks.md | 7 ++ .../src/tagged/formattingFidelity.test.ts | 64 +++++++++++++++++++ .../tagged/trackChangesAcceptorAst.test.ts | 26 ++++++++ .../src/tagged/trackChangesAcceptorAst.ts | 25 ++++++++ 7 files changed, 215 insertions(+) create mode 100644 openspec/changes/remove-accepted-ghost-section-break/design.md create mode 100644 openspec/changes/remove-accepted-ghost-section-break/proposal.md create mode 100644 openspec/changes/remove-accepted-ghost-section-break/specs/docx-comparison/spec.md create mode 100644 openspec/changes/remove-accepted-ghost-section-break/tasks.md diff --git a/openspec/changes/remove-accepted-ghost-section-break/design.md b/openspec/changes/remove-accepted-ghost-section-break/design.md new file mode 100644 index 00000000..a99336c9 --- /dev/null +++ b/openspec/changes/remove-accepted-ghost-section-break/design.md @@ -0,0 +1,31 @@ +## Context + +`w:sectPrChange` stores the prior section-property state. In the observed removal +shape, the live paragraph-owned `w:sectPr` has no current property children; its +only element child is the change snapshot. Removing only the snapshot converts +that revision record into an empty but semantically active section break. + +## Goals / Non-Goals + +- Goals: make accept-all reflect removal of the paragraph-level section break; + keep reject-all restoration unchanged; preserve exact fidelity enforcement. +- Non-Goals: change section alignment, lower safety thresholds, special-case any + publisher document, or reinterpret nonempty/live section properties. + +## Decisions + +- Record removable section containers before deleting property-change records. + A container qualifies only when it is `w:pPr > w:sectPr`, has a direct + `w:sectPrChange`, and has no other direct element children. +- Never remove `w:body > w:sectPr`; it is the final section-properties container, + not a paragraph-level section break. +- Keep the fidelity oracle and its exact threshold unchanged. The regression + exercises the real accept/reject projections over independently invented XML. + +## Risks / Trade-offs + +- Malformed empty paragraph-owned section properties without a change snapshot + remain untouched and visible to safety checks. +- A paragraph section with any live property child remains a live section break, + even if it also carries a historical snapshot. + diff --git a/openspec/changes/remove-accepted-ghost-section-break/proposal.md b/openspec/changes/remove-accepted-ghost-section-break/proposal.md new file mode 100644 index 00000000..14e9758f --- /dev/null +++ b/openspec/changes/remove-accepted-ghost-section-break/proposal.md @@ -0,0 +1,33 @@ +# Change: Remove accepted ghost section breaks + +## Why + +Accepting a tracked removal of a paragraph-level section break currently removes +the `w:sectPrChange` record but leaves its now-empty `w:sectPr` container. That +container remains a real section break in the accepted projection, so source- +projected formatting fidelity correctly fails even when the revised source has +no corresponding break. + +This was reproduced with wholly invented OOXML and with a private document pair; +the private observation is limited to structural metrics and is not a source of +committed fixture content. + +## What Changes + +- Treat a paragraph-owned `w:sectPr` whose only element child is its + `w:sectPrChange` snapshot as a tracked removal of that section break when + accepting revisions. +- Remove that paragraph-level section-properties container in the accept-all + projection while preserving body-level final section properties and live + section formatting. +- Add an invented regression proving both accept and reject projections retain + exact formatting fidelity without lowering or bypassing the fidelity gate. + +## Impact + +- Affected specs: `docx-comparison` +- Affected code: `packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts` +- Affected tests: tagged accept/reject projection and formatting-fidelity tests +- Dependency: UseJunior/legal-context#918 +- Base: UseJunior/safe-docx `31b1d8fd2e9f0f285cc6167906ef6e2c9f220699` + diff --git a/openspec/changes/remove-accepted-ghost-section-break/specs/docx-comparison/spec.md b/openspec/changes/remove-accepted-ghost-section-break/specs/docx-comparison/spec.md new file mode 100644 index 00000000..0c717a14 --- /dev/null +++ b/openspec/changes/remove-accepted-ghost-section-break/specs/docx-comparison/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Projection-Based Candidate Formatting Comparison + +The system SHALL provide `compareProjectedFormattingFidelity(expectedCandidateXml, actualCandidateXml)` that compares the accept-all projections and the reject-all projections of two tracked-changes candidates and returns both formatting-fidelity reports plus an overall score equal to the minimum of the two projection scores, so that revision-markup granularity differences between reconstruction modes do not register as formatting divergence. Accepting a tracked removal of a paragraph-level section break SHALL remove the paragraph-owned section-properties container when the change snapshot is its only element child; it SHALL NOT remove the body-level final section-properties container or a section-properties container with live formatting children. + +#### Scenario: projected fidelity ignores revision markup granularity differences + +- **WHEN** two candidates encode the same insertion with different `w:ins` wrapper and run granularity but identical formatting +- **THEN** the overall projected score is exactly 1.0 + +#### Scenario: pipeline inplace and rebuild candidates are measurable end-to-end + +- **WHEN** the comparison pipeline produces an inplace candidate and a rebuild candidate for the same original and revised documents +- **THEN** the projected formatting-fidelity comparison of the two candidates returns well-formed accept and reject reports with scores in [0, 1] + +#### Scenario: accepting a tracked section-break removal does not leave a ghost section + +- **GIVEN** an invented candidate whose paragraph-owned `w:sectPr` has a `w:sectPrChange` snapshot and no live section-property child +- **WHEN** accept-all and reject-all projections are compared with their corresponding revised and original source views +- **THEN** both formatting-fidelity scores are exactly 1.0 +- **AND** accept-all contains no paragraph-owned section-properties container for the removed break +- **AND** reject-all restores the prior section properties + +#### Scenario: live and final section properties are preserved + +- **GIVEN** section-properties containers with live formatting children or a body-level final section-properties container +- **WHEN** revisions are accepted +- **THEN** those section-properties containers remain present diff --git a/openspec/changes/remove-accepted-ghost-section-break/tasks.md b/openspec/changes/remove-accepted-ghost-section-break/tasks.md new file mode 100644 index 00000000..2ff2f95f --- /dev/null +++ b/openspec/changes/remove-accepted-ghost-section-break/tasks.md @@ -0,0 +1,7 @@ +## 1. Implementation + +- [x] 1.1 Add conformance-cited accept-all handling for tracked removal of a paragraph-level section break +- [x] 1.2 Add an independently invented projection regression covering accept and reject formatting fidelity +- [x] 1.3 Re-run the private comparison and retain only ignored, metadata-only evidence +- [x] 1.4 Run the documented pre-submit suite and package leakage audit +- [x] 1.5 Conduct and adjudicate supplemental same-model self-review diff --git a/packages/docx-compare/src/tagged/formattingFidelity.test.ts b/packages/docx-compare/src/tagged/formattingFidelity.test.ts index 52b1684d..6eba6a39 100644 --- a/packages/docx-compare/src/tagged/formattingFidelity.test.ts +++ b/packages/docx-compare/src/tagged/formattingFidelity.test.ts @@ -264,6 +264,70 @@ describe('Formatting-fidelity comparison check', () => { expect(result.score).toBe(1); }); + humanReadableTest + .conformance({ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.32' }) + .openspec('accepting a tracked section-break removal does not leave a ghost section')( + 'Scenario: accepting a tracked section-break removal does not leave a ghost section', + (_: AllureBddContext) => { + const original = docXml( + `` + + `Invented first section` + + `Invented continuation` + + ``, + ); + const revised = docXml( + `Invented first section` + + `Invented continuation` + + ``, + ); + const candidate = docXml( + `` + + `Invented first section` + + `Invented continuation` + + ``, + ); + + const result = compareSourceProjectedFormattingFidelity(original, revised, candidate); + + expect(result.accept.score).toBe(1); + expect(result.accept.sectionFormatting).toEqual({ compared: 1, divergent: 0, score: 1 }); + expect(result.reject.score).toBe(1); + expect(result.reject.sectionFormatting).toEqual({ compared: 2, divergent: 0, score: 1 }); + expect(result.score).toBe(1); + }, + ); + + test('invented DOCX section-break removal clears the exact publication gate', async () => { + const originalBody = + `` + + `Invented removable boundary` + + `Invented following section`; + const revisedBody = + `Invented removable boundary` + + `Invented following section`; + const [original, revised] = await Promise.all([ + buildDocxFromBodyXml(originalBody), + buildDocxFromBodyXml(revisedBody), + ]); + + const result = await compareDocumentsAtomizer(original, revised, { + author: 'Invented Fixture', + date: new Date('2026-09-14T00:00:00Z'), + }); + const [originalXml, revisedXml, candidateXml] = await Promise.all([ + DocxArchive.load(original).then((archive) => archive.getDocumentXml()), + DocxArchive.load(revised).then((archive) => archive.getDocumentXml()), + DocxArchive.load(result.document).then((archive) => archive.getDocumentXml()), + ]); + + const fidelity = compareSourceProjectedFormattingFidelity(originalXml, revisedXml, candidateXml); + expect(result.engine).toBe('tagged-tree'); + expect(fidelity.accept.score).toBe(1); + expect(fidelity.reject.score).toBe(1); + }); + humanReadableTest.openspec('pipeline inplace and rebuild candidates are measurable end-to-end')( 'Scenario: pipeline inplace and rebuild candidates are measurable end-to-end', async (_: AllureBddContext) => { diff --git a/packages/docx-compare/src/tagged/trackChangesAcceptorAst.test.ts b/packages/docx-compare/src/tagged/trackChangesAcceptorAst.test.ts index a36a29ec..67d9ba09 100644 --- a/packages/docx-compare/src/tagged/trackChangesAcceptorAst.test.ts +++ b/packages/docx-compare/src/tagged/trackChangesAcceptorAst.test.ts @@ -226,6 +226,32 @@ describe('trackChangesAcceptorAst', () => { }); }); + test + .conformance({ spec: 'ECMA-376', edition: 5, part: 1, section: '17.13.5.32' })( + 'preserves live paragraph and final section properties when accepting revisions', + () => { + const input = ` + + + + + Invented live section + + + `; + + const result = acceptAllChanges(input); + const root = parseDocumentXml(result); + const sections = findAllByTagName(root, 'w:sectPr'); + + expect(sections).toHaveLength(2); + expect(result).toContain('w:pgMar w:top="900"'); + expect(result).toContain('w:pgSz w:w="12240" w:h="15840"'); + expect(result).not.toContain('w:sectPrChange'); + expect(result).not.toContain('w:pgMar w:top="800"'); + }, + ); + describe('rejectAllChanges', () => { test('should remove w:ins elements entirely', async ({ given, when, then, and }: AllureBddContext) => { let input: string; diff --git a/packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts b/packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts index f51b88be..0565b125 100644 --- a/packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts +++ b/packages/docx-compare/src/tagged/trackChangesAcceptorAst.ts @@ -634,6 +634,7 @@ export function acceptAllChanges(documentXml: string): string { removeAllByTagName(root, 'w:pPrChange'); removeAllByTagName(root, 'w:trPrChange'); removeAllByTagName(root, 'w:tcPrChange'); + removeAcceptedSectionBreakRemovals(root); removeAllByTagName(root, 'w:sectPrChange'); removeEmptyTablePropertyContainers(root); @@ -842,6 +843,30 @@ function removeEmptyTablePropertyContainers(root: Element): void { } } +/** + * Remove a paragraph-level section break whose tracked prior state is the only + * content of its live section-properties container. Accepting that revision + * means accepting the absence of the break; retaining an empty `w:sectPr` + * would instead create a ghost section. Body-level final section properties + * and paragraph sections with any live property remain untouched. + * + * @conformance ECMA-376 edition 5, Part 1 ยง 17.13.5.32 + */ +function removeAcceptedSectionBreakRemovals(root: Element): void { + for (const change of findAllByTagName(root, 'w:sectPrChange')) { + const sectionProperties = parentElement(change); + const paragraphProperties = sectionProperties ? parentElement(sectionProperties) : undefined; + if ( + sectionProperties?.tagName === 'w:sectPr' && + paragraphProperties?.tagName === 'w:pPr' && + childElements(sectionProperties).length === 1 && + childElements(sectionProperties)[0] === change + ) { + paragraphProperties.removeChild(sectionProperties); + } + } +} + function restoreSectionPropertiesFromChanges(root: Element): void { for (const change of findAllByTagName(root, 'w:sectPrChange')) { const live = change.parentNode as Element | null;