Skip to content

fix(docx-core): expose unresolved run formatting - #758

Merged
stevenobiajulu merged 3 commits into
mainfrom
752-express-unresolved-run-formatting-20260729
Oct 4, 2026
Merged

stevenobiajulu merged 3 commits into
mainfrom
752-express-unresolved-run-formatting-20260729

Conversation

@stevenobiajulu

@stevenobiajulu stevenobiajulu commented Jul 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Makes "not resolved" representable in docx-core's effective run formatting (#752). extractEffectiveRunFormatting used to return '', 0 and false both for properties declared nowhere and for properties declared only in layers it does not read, so a caller could not tell a known default from missing resolver coverage.

Rebased onto main (195 commits since the July draft) and reworked. The draft made every undeclared property null. That would have turned every plain run's bold into null and silenced the #687 formatting-convention warning permanently, because that warning must not fire from an unresolved value.

Contract (option A from #752: nullable per property)

RunFormatting fields are nullable, and null means only unresolved:

  • no consulted layer (direct rPr, rStyle chain, paragraph-mark rPr, pStyle chain) declares the property, and an unread layer declares a non-default value for it. The unread layers are w:docDefaults/w:rPrDefault/w:rPr, and any table style's rPr / tblStylePr/rPr when the run is inside a table; or
  • the property is fontName / fontSizePt, which have no OOXML default, and nothing consulted declares it.

A property declared nowhere resolves to its OOXML default: false for toggles and underline, highlightVal: false (was null), and colorHex: 'auto' (was null). A toggle that a direct layer sets is absolute and stays resolved. A toggle reached only through style-level parity over an unread "on" base is null.

StylesModel gains optional docDefaultsRPr and tableStyleRPrs. They are probes only and resolve no values. Hand-built { byId } models still type-check. #759 can replace the docDefaultsRPr probe with a real resolution layer under the same field name.

Consumers

  • formatting tags (read_file output): unresolved values form their own modal bucket, so tags for resolved values are unchanged, and unresolved values are never emitted. An unresolved run inside a paragraph whose modal size is resolved no longer gets <font size="0">.
  • heading detectors: only a resolved true counts as bold or underline.
  • formatting-convention check (docx-mcp: no formatting-convention check for inserted runs — a new defined term can land off-convention and pass every gate #687): tuples carry null. A divergence is reported only on a member that is resolved on both sides and differs, and the message prints an unresolved member as unresolved.
  • formatting-loss script: unresolved fields project as 'unresolved'.
  • comments.ts, footnotes.ts, document_view.ts, docx-markdoc/inspect.ts: these pass the values through to the tag emitter or to JSON with no coercion, so their code is unchanged.

Breaking / client-visible

  • docx-core public type RunFormatting: all fields are nullable (see CHANGELOG, Unreleased).
  • read_file JSON body_run_formatting: fontName / fontSizePt are null instead of "" / 0 when unresolved, highlightVal is false instead of null, and colorHex is "auto" instead of null.

Validation

  • npm run build, lint:workspaces, check:cycles, check:spec-coverage, check:conformance-citations, check:allure-labels, check:allure-quality, check:allure-filenames, check:tool-docs
  • docx-core 1593 passed, docx-mcp 1085 passed, docx-compare 810 passed, docx-markdoc 197 passed, test:docx-formatting-loss passed
  • New tests cover: docDefaults-only declarations; declared-nowhere defaults; explicit-off versus unresolved; a default restated in docDefaults; toggle parity over a docDefaults base; table-style and conditional probes; the hand-built model; the modal null bucket; the unresolved b/i/u baseline; highlight; heading detectors (including the Codecov partials); and convention silence on unresolved members.

Fixes #752

@vercel

vercel Bot commented Jul 29, 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 Oct 4, 2026 12:26am UTC

Request Review

@github-actions github-actions Bot added the fix PR type: bug fix label Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 3, 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-10-03T23:42:28.319757Z 2e66725 Draft marked ready
ℹ️ 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 Oct 3, 2026 •

Copy link
Copy Markdown

LLM gate (advisory)

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

Findings

None.

All 16 rules (8 evaluated, 8 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 does not touch field atomization, validateFieldStructure, w:fldChar, w:instrText, w:delInstrText, or collapsed-field comparison logic, focusing instead on resolving effective run formatting properties (#752).
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, so the precondition for this checklist question is not met.
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 PASS The PR does not touch packages/docx-core/SUPPORT.md or modify OOXML revision emission behavior in packages/docx-core/src/primitives/**; the modified files styles.ts, formatting_tags.ts, and document_view-headings.ts only implement read-only style extraction and rendering tag helpers, which are documented as internal utilities under SUPPORT.md L139 and L151.
Table A / Table B boundary on side-part revisions SKIPPED paths not touched by this PR
Canonical-emission surface completeness PASS The PR only modifies effective style resolution and formatting convention checks, and does not add or change any tracked-edit surface in docx-core primitives or MCP tools; thus, the precondition is not met.
Unit-test quality (avoid tautological / change-detector tests) PASS Tests in packages/docx-core/src/primitives/styles-unresolved.test.ts:60 and packages/docx-core/test-primitives/formatting_tags.test.ts:417 use static, first-principles expected values to verify unresolved property behavior under #752 without SUT mocks or change-detecting structures.
Re-derived facts vs canonical sources PASS The PR updates effective formatting resolution and baseline modal calculations to support unresolved property states (Issue #752) in packages/docx-core/src/primitives/styles.ts:602 and does not introduce any duplicate derivations or node walks.
.openspec tag ↔ test-assertion drift PASS The PR does not add, move, or change any .openspec() tags on any tests, so the precondition is not met.
Library stays general (no downstream-domain leakage) PASS The PR only modifies formatting-related types and methods (such as RunFormatting and StylesModel in packages/docx-core/src/primitives/styles.ts) to handle unresolved states, and strictly adheres to standard OOXML vocabulary without introducing any downstream-specific concepts.

@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: 2e667255b6

ℹ️ 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 thread scripts/check_docx_formatting_loss.mjs Outdated
*/
function runEmphasis(run, paragraphPPr, paragraphStyleId, styles, theme) {
const formatting = extractEffectiveRunFormatting({ run, paragraphPPr, paragraphStyleId, styles, theme });
const resolved = (value) => value === null ? 'unresolved' : value;

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 Use a collision-free unresolved sentinel

When a run explicitly uses a typeface named unresolved, this helper projects that valid fontName to the same value as a run whose font is actually unresolved. Consequently, removing that explicit font declaration can leave the before/after emphasis arrays identical, causing the formatting-loss check to miss a real font change. Preserve null or use a tagged value that cannot collide with arbitrary OOXML font names.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepted, fixed in 4ea1db1. Unresolved fields now project as null, which no resolved value ever is, so they cannot collide with a font literally named unresolved. A regression test checks that dropping such a font is still reported by D1.

stevenobiajulu added a commit that referenced this pull request Oct 3, 2026
- An unresolvable theme colour/font reference is unresolved (null) and
  stops inheritance, instead of reading as 'auto' or showing a lower
  layer through.
- Modal b/i/u baseline and the convention vote drop a member that any
  run/instance leaves unresolved, so uncertainty in one member cannot
  split the tuple and change tags or erase a convention on another.
- CHANGELOG enumerates the intentional output changes (explicit auto
  colour over Hyperlink style; unknown-norm tagging).
@stevenobiajulu
stevenobiajulu force-pushed the 752-express-unresolved-run-formatting-20260729 branch from 2e66725 to b40e05d Compare October 3, 2026 23:59
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex peer review, round 1: CHANGES_REQUIRED

Reviewer: Codex (codex-review role, gpt-6.1-sol, medium effort), reviewing 2e66725. It actually ran the build, the focused tests, lint, the spec and conformance checks, synthetic probes, and a tagged-text diff of origin/main 8070a8b against the PR over 19 corpus docs × 4,448 nodes × 2 modes.

Adjudication (all four accepted; fixed in b40e05d)

# Finding Decision Fix
1 (P2) Convention check: a null in one tuple member split the vote, so a unanimous bold convention was lost and a provable bold divergence went unreported Accept resolveConvention leaves out any member that some instance leaves unresolved. That member becomes unresolved in the convention, and the vote runs over the remaining members. Regression test: 10/10 bold with italic half-unresolved now warns. Control: an insertion whose differing member is unresolved stays silent.
2 (P2) Compact tags: an unresolved member split the b/i/u modal tuple and un-suppressed a resolved bold Accept computeModalBaseline uses the same rule: a member any body run leaves unresolved is dropped from the modal key and has a null baseline. Reviewer's case now gives aaaaabbbbb, identical to main. I considered imputing the majority value instead and rejected it: it could hide a real bold behind a minority guess. The remaining tradeoff is pinned in a test and stated in the CHANGELOG: a member with no known norm gets its resolved values tagged rather than suppressed.
3 (P2) w:color w:val="auto" w:themeColor=… with no resolvable theme was reported as 'auto' Accept A layer whose colour or Latin font is declared only through an unresolvable theme reference now yields unresolved (null). Resolution stops at that layer, so it neither reads as 'auto' nor falls through to a lower layer. An explicit hex w:val is still the fallback. Tests cover direct and docDefaults declarations, with no theme, an empty theme, and a resolving theme.
4 (P3) Explicit auto / none now stops inheritance, so "resolved output unchanged" was overstated Accept; keep the behaviour I traced the 3 corpus diffs to <w:rStyle w:val="Hyperlink"/><w:color w:val="auto"/>. Word renders that run in automatic colour, so dropping <font color="0000FF"> is a correctness fix. CHANGELOG now lists the intentional output changes, and an inherited-off regression test is added.

Main also moved (#1143, #1145) during review, so the branch is rebased again. The only conflict was in CHANGELOG, resolved by keeping both entries. All local gates are green after the rebase: build, lint, cycles, spec-coverage, conformance-citations, allure-labels/quality/filenames, tool-docs, formatting-loss, docx-core, docx-mcp.

A confirmation round follows.

@stevenobiajulu

Copy link
Copy Markdown
Member Author

Codex peer review, confirmation round: APPROVED

Reviewer: Codex (codex-review), reviewing b40e05d against origin/main 81600aa. It re-ran all four round-1 reproductions, and each now behaves as requested:

  • The convention case keeps its 10/10 bold convention and reports the bold-off insertion. The control, where the differing member is itself unresolved, stays silent.
  • The mixed-italic paragraph tags as aaaaabbbbb, identical to main.
  • An unresolvable theme colour is null, not 'auto'.
  • The explicit-off correction is retained and documented.

Additional checks it ran:

  • Fully resolved inputs: a randomized differential found zero mismatches against main, over 4,000 compact/full tag cases and 48 convention populations.
  • Corpus: the tagged-text diff is unchanged at 19 docs × 4,448 paragraphs × 2 modes. The only deltas are the 3 paragraphs whose run sets Hyperlink style with w:color w:val="auto"; that change is intended and in the CHANGELOG.
  • Fonts: w:rFonts probes confirm that hint-only, eastAsia-only, cstheme-only and eastAsiaTheme-only declarations do not block an inherited Latin font.

No remaining findings.

stevenobiajulu added a commit that referenced this pull request Oct 4, 2026
A string sentinel could collide with a real font literally named
'unresolved', hiding a dropped font declaration from D1. null never
occurs as a resolved value. (#758 review)
extractEffectiveRunFormatting returned '', 0 and false both for properties
declared nowhere and for properties declared only in layers it does not
read (w:docDefaults, table styles), so callers could not tell a known
default from missing resolver coverage.

RunFormatting fields are now nullable, and null means only "unresolved":
a non-default declaration exists in an unread layer (docDefaults, or a
table style for a run inside a table), or the property has no OOXML
default and nothing declares it (fontName, fontSizePt). A property
declared nowhere resolves to its OOXML default (false, highlightVal
false, colorHex 'auto'). A toggle set by a direct layer is absolute and
stays resolved; one reached only through style-level parity over an
unread base is unresolved. StylesModel gains optional docDefaultsRPr and
tableStyleRPrs probes (same docDefaultsRPr field #759 will resolve from).

Consumers:
- formatting tags: unresolved values form their own modal bucket (so
  tag output for resolved values is unchanged) and are never emitted;
  an unresolved size no longer produces <font size="0">.
- heading detectors: only a resolved true counts as bold/underline.
- formatting-convention check: tuples carry null; a divergence is only
  reported on a member resolved on both sides.
- formatting-loss script: unresolved projects as 'unresolved'.

Fixes #752
- An unresolvable theme colour/font reference is unresolved (null) and
  stops inheritance, instead of reading as 'auto' or showing a lower
  layer through.
- Modal b/i/u baseline and the convention vote drop a member that any
  run/instance leaves unresolved, so uncertainty in one member cannot
  split the tuple and change tags or erase a convention on another.
- CHANGELOG enumerates the intentional output changes (explicit auto
  colour over Hyperlink style; unknown-norm tagging).
A string sentinel could collide with a real font literally named
'unresolved', hiding a dropped font declaration from D1. null never
occurs as a resolved value. (#758 review)
@stevenobiajulu
stevenobiajulu force-pushed the 752-express-unresolved-run-formatting-20260729 branch from 4ea1db1 to 7920735 Compare October 4, 2026 00:26
@stevenobiajulu
stevenobiajulu merged commit 6e60dcf into main Oct 4, 2026
27 checks passed
@stevenobiajulu
stevenobiajulu deleted the 752-express-unresolved-run-formatting-20260729 branch October 4, 2026 00:44
@stevenobiajulu

Copy link
Copy Markdown
Member Author

Post-merge smoke: PASS

This compares the built merge commit 6e60dcfb (origin/main after this PR) with a build of 8070a8b7 (pre-merge main). The harness drives each build's real safe-docx MCP server over stdio and calls read_file with show_formatting: true in json and toon formats. The inputs are four real NVCA forms from ~/.open-agreements/cache/<recipe>/source.docx: stock-purchase, voting, investors-rights and certificate-of-incorporation.

Check Result
Paragraph nodes compared 1,151, node counts identical per document
tagged_text differences 1, in SPA _bk_d752cfc13c29: the <font color="0000FF"> wrapper is gone. The run sets the Hyperlink character style with w:color w:val="auto", which Word renders automatic. This is the intended change listed in the CHANGELOG.
TOON output byte-identical for voting, IRA and COI; the SPA differs by that one paragraph
<font size="0"> in output 0
Resolved body_run_formatting values (fonts, sizes, bold/italic/underline, hex colours) that changed 0
Resolved fonts / sizes still reported Times New Roman ×456, TimesNewRomanPSMT ×1; 11pt ×249
Unresolved, now null instead of a sentinel fontName 694 (was ""), fontSizePt 902 (was 0)
Known defaults now spelled explicitly highlightVal null→false (1,151); colorHex null→"auto" (1,110)

stevenobiajulu added a commit that referenced this pull request Oct 4, 2026
extractEffectiveRunFormatting now reads w:docDefaults/w:rPrDefault/w:rPr
as the lowest-precedence run-property layer (#753). Rebuilt on top of the
nullable RunFormatting contract from #758:

- StylesModel.docDefaultsRPr becomes a resolution layer: the last source
  for ordinary properties, and a 'default' toggle step that seeds the
  starting value before style parity and absolute direct formatting
  (MS-OI29500 note on 17.7.3; MS-OE376 2.7.7 documents no deviation that
  makes docDefaults another style level).
- docDefaults leaves the unread-layer list. Table styles remain unread:
  for a run in a table, a property nothing above docDefaults declares is
  null when a table style declares a value different from the base.
- fontName/fontSizePt resolve from docDefaults when declared; a theme
  font there resolves through the theme part and is null without one.
- Comment and footnote tagged_text use the document defaults as their
  font baseline, so the inherited default font is not emitted as face=
  on every run (which also broke docx-markdoc annotation import).

Fixes #753
stevenobiajulu added a commit that referenced this pull request Oct 4, 2026
* fix(docx-core): resolve run formatting from document defaults

extractEffectiveRunFormatting now reads w:docDefaults/w:rPrDefault/w:rPr
as the lowest-precedence run-property layer (#753). Rebuilt on top of the
nullable RunFormatting contract from #758:

- StylesModel.docDefaultsRPr becomes a resolution layer: the last source
  for ordinary properties, and a 'default' toggle step that seeds the
  starting value before style parity and absolute direct formatting
  (MS-OI29500 note on 17.7.3; MS-OE376 2.7.7 documents no deviation that
  makes docDefaults another style level).
- docDefaults leaves the unread-layer list. Table styles remain unread:
  for a run in a table, a property nothing above docDefaults declares is
  null when a table style declares a value different from the base.
- fontName/fontSizePt resolve from docDefaults when declared; a theme
  font there resolves through the theme part and is null without one.
- Comment and footnote tagged_text use the document defaults as their
  font baseline, so the inherited default font is not emitted as face=
  on every run (which also broke docx-markdoc annotation import).

Fixes #753

* fix(docx-core): address #759 review on annotation formatting and table-style probe

- Comment/footnote tagged_text report colour, size and font only where a
  layer above w:docDefaults declares them (extractAnnotationRunFormatting),
  instead of suppressing values equal to the document defaults. The
  equality baseline dropped a direct colour that restated the default over
  a named character style, which docx-markdoc then re-imported as the
  style's colour.
- The table-style probe compares hex colours and font names
  case-insensitively, so a restatement in other casing stays resolved.

This branch was successfully deployed

1 active deployment
Preview — 79207353 Deployed Oct 4, 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-core: run-formatting resolver returns "", 0 and false for properties it never resolved

1 participant