Skip to content

feat(document-api): create named paragraph and character styles - #3976

Open
Nathaniel-260 wants to merge 4 commits into
superdoc:mainfrom
Nathaniel-260:feat/styles-define
Open

feat(document-api): create named paragraph and character styles#3976
Nathaniel-260 wants to merge 4 commits into
superdoc:mainfrom
Nathaniel-260:feat/styles-define

Conversation

@Nathaniel-260

@Nathaniel-260 Nathaniel-260 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Relates to #3975

What changed

styles.create โ€” define or redefine a named paragraph or character style. It is
the missing half of styles.getCatalog: what the catalogue can describe, this
can author. #3975 has the full argument for why the gap exists and why
templates.apply is the wrong tool for it; this PR is the contract.

Additive, 20 files. New operation in
packages/document-api/src/styles/create.ts, registered the way every other
operation is: operation-definitions.ts (via mutationOperation()),
operation-registry.ts, hand-written schemas in contract/schemas.ts,
invoke/invoke.ts, and the four sites in src/index.ts. Nothing generated is
committed.

api.styles.create({
  id: 'Quote',
  name: 'Quote',
  type: 'paragraph',
  basedOn: 'Normal',
  next: 'Normal',
  qFormat: true,
  priority: 29,
  paragraph: { indent: { left: 720 }, spacing: { before: 240 } },
  run: { italic: true },
});

The decisions worth arguing with are in the commit message; the two that touch
existing code are below.

The exclusion list is now a property of the destination

EXCLUDED_KEYS was the docDefaults exclusion list and the only list, so
every caller inherited a restriction only one of them is subject to. Four of its
run entries โ€” w:cs, w:highlight, w:oMath, w:rtl โ€” are disallowed in
w:docDefaults and legal on a named w:style; the header comment in
registry.ts already says so ("intentionally disallowed in Word
docDefaults"), and all four are read back off a w:style by
StyleDefinition.runProperties in @superdoc/style-engine.

w:rtl is the property that makes a run right-to-left. Without the split, no
right-to-left style is expressible through this API โ€” the operation would ship
unable to author a style for Hebrew, Arabic, Persian or Urdu.

So EXCLUDED_KEYS_BY_SCOPE keys the list by 'docDefaults' | 'style', the
docDefaults list is byte-for-byte what it was, and the four new registry
entries are reachable under the style scope alone.

styles.apply is unchanged, and two things had to be fixed to keep it that way

Both were caught by tests written for this PR, and both fail without the fix:

  1. buildStateSchema is scoped as well as buildPatchSchema. Otherwise the
    four keys land in the published before / after maps of a docDefaults
    receipt โ€” the exact keys that scope rejects, in objects carrying
    additionalProperties: false, so receipt validation would have loosened too.
  2. classifyPatchKey asks whether a key is reachable on the other channel
    in this scope.
    Putting the four in ALLOWED_KEYS_BY_CHANNEL.run meant a
    run property sent to the paragraph channel started answering cross_channel
    instead of excluded, dropping the excluded_docdefaults_key reason a
    caller branches on.

Verified rather than asserted. Generating document-api-contract.json on this
branch and on the merge-base and deep-diffing per operation:

base head
operations 427 428
added styles.create
removed none
changed capabilities.get only, and only because it enumerates operation ids
styles.* inherited from main, changed 0 of 5

styles.apply is byte-identical. Same accepted keys, same rejection messages,
same detail codes, same published input and output schemas.

Two more gates run against the generated contract:

  • All four styles.create schemas compile in a 2020-12 validator, with no
    dangling $ref. This is why the failure schema types code as a string
    rather than an enum derived from possibleFailureCodes: that list is empty
    while the operation ships without an adapter, and enum: [] does not compile
    โ€” it takes the whole output oneOf with it, so a consumer could not validate
    even a success receipt. styles.apply publishes the same open shape.
  • 37 inputs run through the hand-written validator and the published input
    schema side by side: 0 disagreements. That gate is why one rule is not
    enforced โ€” an alias equal to the style's own name. It is redundant rather than
    corrupting, and JSON Schema cannot compare sibling fields, so the rule would
    live only in the validator and a caller pre-validating against the published
    contract would get a green light and then a throw. The comma rule, which does
    corrupt w:aliases, is enforced on both sides and expressed as a pattern.

What is not in this PR

The adapter. The mutation lives in @superdoc/docx-engine, which is not in
this repository, so executeStylesCreate throws CAPABILITY_UNAVAILABLE until
the engine side lands โ€” the same way styles.getCatalog fails closed when its
optional hook is absent.

Two consequences are deliberate, and I would rather state them than have them
discovered after merge:

  • possibleFailureCodes is NONE_FAILURES, because with no adapter there is no
    code a host can currently produce and the contract must not over-declare.
  • capabilities.get() reports it unavailable while the hook is absent. An
    earlier revision of this description said the opposite, and the review was
    right to catch it: createDocumentApi already post-processes the engine
    snapshot through ADAPTER_GATED_PREFIXES, which could not reach this
    operation because adapters.styles is present and only its optional create
    hook is not. There is now a second gate at hook granularity, using the
    existing OPERATION_UNAVAILABLE reason code. So the operation is not
    advertised as available before it can succeed.

What remains is smaller, and still yours to decide: a feat: release publishes
the operation into the JSON schemas, the agent artifacts and the reference site,
and OperationDefinitionEntry has no status / planned field, so the
contract cannot say "defined, not yet implemented" even though the runtime
now can. That is the question at the bottom of #3975. Please do not merge this
until it is answered.
If you would rather implement contract and adapter
together and drop this, say so and I will close it; the shape is yours to use.

Review round

Four findings from the automated review; three changed the code.

  • Capability snapshot โ€” fixed as described above, five tests, each confirmed
    to fail against the broken code.
  • token-sets.ts โ€” the new ST_HighlightColor block had landed between the
    ST_ThemeColor header and its doc comment, orphaning both. Moved below
    ST_THEME_COLOR_VALUE_SET.
  • Hardcoded xmlPath in schemas.ts โ€” the drift risk is real, the fix is a
    test rather than a reference. schemas.ts is a hand-written wire format and
    styles.apply spells out its own paths two entries above; a schema that
    follows a constant changes the published contract silently whenever the
    constant moves. The literal stays and a test pins it to STYLE_XML_PATH, with
    the same assertion added for styles.apply against XML_PATH_BY_CHANNEL.
  • returnsReceipt: true โ€” not added, and I think this one is mistaken. The
    field's doc says it marks a result that "follows the Receipt success/failure
    envelope", and every one of the 16 operations that sets it does, TextMutationReceipt
    included. StylesCreateReceiptSuccess deliberately does not extend
    ReceiptSuccess, because inserted / updated take EntityAddress and a
    style definition is not a document entity. 93 receipt-returning operations
    omit the marker, styles.apply among them. Happy to be overruled.

CI also failed on Docs โ†’ check:redirects: apps/docs/config/routes.json was
missing /document-api/reference/styles/create/. Added in the exact form
generateRouteManifest writes โ€” sorted union, two-space JSON, trailing newline โ€”
and verified byte-identical to that serialisation rather than hand-placed.

Checks

pnpm run <script> does not run on Windows in this repo (the root prepare
hook is POSIX, and scripts/test.mjs calls spawnSync('pnpm.cmd') without
shell: true), so each stage was invoked directly. CI runs the real scripts.

  • tsc -b packages/document-api โ€” clean
  • bun test packages/document-api/src โ€” 1807 pass, 1808 run. The one failure,
    executeParagraphsSetTabStop > rejects clear as a setTabStop alignment, fails
    identically on the merge-base and is untouched by this branch
  • check-contract-parity โ€” 428 operations, 428 API members
  • check-contract-outputs โ€” 5 generated files
  • check-agent-artifacts โ€” 3 files
  • check-stable-schemas โ€” 2 files
  • check-documented-operations โ€” 428 operations, 468 pages
  • vp fmt --check on all 20 files โ€” clean
  • vp lint on all 20 files โ€” 0 errors; the 24 warnings are pre-existing in
    index.ts and schemas.ts and name no symbol this PR adds
  • tests/consumer-typecheck/src/document-api-styles-create.ts โ€” new fixture,
    asserting both the parameter and the return shapes; compiles clean against the
    built .d.ts under the fixture's own strict settings

Tests: validation and routing in styles/create.test.ts, scope filtering of
both schema builders in styles/schema.test.ts, direct coverage of the public
classifyPatchKey export, an invoke parity case, and the SD-2018 registry
gate pinned per scope. Every branch was checked by mutation โ€” each test was
confirmed to fail against the corresponding broken code, not merely to pass
against the working code.

Nothing in the Document API brings a named style into existence. styles.apply
writes w:docDefaults and is validated as such (target.scope must be
"docDefaults"); styles.paragraph.setStyle and setStyleRef apply a style that is
already in the document, by styleId or by one of four semantic roles;
styles.getCatalog reads. So a caller who wants a "Question" or a "Quote" style
has one route left: synthesize a whole .docx and hand it to templates.apply,
whose input is { source, bodyPolicy } with no scope selector โ€” which parts of
the document it also adopts is then decided by the shape of the synthesized
package rather than by the caller's request.

styles.create is the missing half of styles.getCatalog: what the catalogue can
describe, this can author.

Contract only. The adapter that writes word/styles.xml is not in this
repository, so the operation reports CAPABILITY_UNAVAILABLE until the engine
side lands โ€” the same way styles.getCatalog fails closed when its optional
catalogue hook is absent. Two consequences are deliberate and worth stating
rather than discovering: possibleFailureCodes is NONE_FAILURES, because with no
adapter there is no code a host can currently produce and the contract must not
over-declare; and a merged contract is advertised by capabilities.get() before
it can succeed, which is a decision for the maintainers, not a side effect to
be discovered after merge.

Design notes, each of which had a plausible alternative:

- Named .create, not .define. The create.* namespace is body content, but the
  .create leaf is already how this codebase names a durable object that lives
  in an auxiliary part and is referenced from the body โ€” lists.create writes a
  numbering definition into word/numbering.xml, which is structurally the same
  operation on a different part.

- The input is a discriminated union rather than one flat interface with
  cross-field checks, matching StylesApplyInput and ListsCreateInput. A
  character style cannot carry next or paragraph properties, and that is now a
  compile error rather than a runtime one.

- Fields are named id and priority, not styleId and uiPriority, so that what
  StyleCatalogItem reads back is what this writes. priority stays number | null
  and is not capped at Word's 0..99 UI band, because the catalogue can return
  values outside it and a cap here would break the round trip.

- The exclusion list is now a property of the destination. EXCLUDED_KEYS was
  the docDefaults list and the only list, so every caller inherited a
  restriction only one of them was subject to. Its four run entries that Word
  forbids in docDefaults and allows on a named style โ€” w:cs, w:highlight,
  w:oMath, w:rtl โ€” join the registry and are reachable under the new style
  scope alone. w:rtl is the property that makes a run right-to-left, so
  without this split no right-to-left style could be authored through this
  API at all. Each of the four is already read back off a w:style by
  StyleDefinition.runProperties in the style engine.

  styles.apply is unchanged in every observable way, and two of those ways took
  a fix to keep. buildStateSchema is scoped as well as buildPatchSchema, or the
  four keys would have landed in the published before/after maps of a
  docDefaults receipt โ€” the exact keys that scope rejects, in objects carrying
  additionalProperties: false, so receipt validation would have loosened too.
  And classifyPatchKey now asks whether a key is reachable on the other channel
  *in this scope*: putting the four in ALLOWED_KEYS_BY_CHANNEL.run meant a run
  property sent to the paragraph channel started answering cross_channel
  instead of excluded, dropping the excluded_docdefaults_key reason a caller
  branches on. Both are covered by tests that fail without the fix.

  What remains: same accepted keys, same rejection messages, same detail codes,
  same published input and output schemas.

- before/after are per channel, in the published schema as well as in the
  types. styles.apply can use a flat map because resolution.channel says which
  channel it describes; one w:style carries both at once, and snapToGrid,
  shading and borders exist on both โ€” borders with genuinely different shapes
  (w:bdr, one border, against w:pBdr, six edges). buildStateSchema therefore
  takes an optional channel: without it the receipt schema would have folded
  the two back together and accepted rtl under `after.paragraph`, which is the
  exact confusion the split exists to prevent.

- The one alias rule that is not enforced: an alias equal to the style's own
  name. It is redundant rather than corrupting, and JSON Schema cannot compare
  sibling fields โ€” so the rule would live only in the validator, and a caller
  pre-validating against the published contract would get a green light and
  then a throw. The comma rule, which does corrupt, is enforced in both and
  expressed as a pattern.

- No new failure codes. DUPLICATE_ID, PRECONDITION_FAILED, LOCK_VIOLATION and
  STYLE_CONFLICT already cover every case; STYLE_CONFLICT in particular was
  unclaimed and is exactly the name collision this operation has to report.
  The published failure schema types `code` as a string rather than an enum
  derived from possibleFailureCodes: that list is empty while the operation
  ships without an adapter, and an empty enum does not compile in a
  2020-12 validator โ€” it would have taken the whole output oneOf with it, so a
  consumer could not have validated even a success receipt. styles.apply
  publishes the same open shape.

- idempotency is conditional, not idempotent. Under the default
  conflictPolicy 'fail' a second identical call fails; only 'replace' makes it
  repeatable. The value is published to the reference page and the agent
  artifacts, so declaring it idempotent would invite an orchestrator to replay
  the call after a transport timeout and take a hard conflict โ€” or, under
  replace, silently clobber a style someone edited in between.

- highlight is the closed ST_HighlightColor enumeration, not a free string.
  A free string writes an invalid w:highlight into styles.xml, which Word
  reports as a damaged document rather than as a rejected call. The token list
  moves to inline-semantics/token-sets.ts beside the other ST_* sets, and
  SDHighlightColor is now derived from it so the two cannot drift.

- An empty run: {} or paragraph: {} is rejected, matching styles.apply and the
  published minProperties: 1. Omitting the channel is how you say "no
  properties"; an empty object asked the adapter to write an empty w:rPr and
  disagreed with the contract a caller may pre-validate against.

- conflictPolicy is decided on both id and name. Word keys its Styles gallery
  on w:name: two styles with distinct ids and one name are two identically
  labelled entries, and a name that collides with a latent style is resolved
  by Word against w:latentStyles, which can inherit w:semiHidden and leave a
  successful call with an invisible style.

Out of scope for this first version, and deliberately: merge semantics, since
the patch types cannot express removal and the registry attaches a per-property
merge strategy on a second axis; linked pairs, since each half names the other
and one call cannot satisfy the first; table and numbering styles, which have
no patch surface here; and w:default, which is a singleton per type.

Tests: validation and routing in styles/create.test.ts, scope filtering of both
schema builders in styles/schema.test.ts, direct coverage of the public
classifyPatchKey export, an invoke parity case, and a consumer-typecheck
fixture asserting both the parameter and the return shape. The SD-2018 registry
gate pins each scope separately. Every branch was checked by mutation: each
test was confirmed to fail against the corresponding broken code, not merely to
pass against the working code.

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid โ€” if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/document-api/src/contract/schemas.ts">

<violation number="1" location="packages/document-api/src/contract/schemas.ts:5169">
P3: The published schema hardcodes `xmlPath: { const: 'w:styles/w:style' }`, duplicating the `STYLE_XML_PATH` constant exported from create.ts and pinned as the `StylesCreateResolution.xmlPath` type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports `STYLE_XML_PATH`), reference the constant here instead so the schema and the resolution type cannot diverge.</violation>
</file>

<file name="packages/document-api/src/contract/operation-definitions.ts">

<violation number="1" location="packages/document-api/src/contract/operation-definitions.ts:1290">
P2: `styles.create` returns a `StylesCreateReceipt`, but its command metadata omits `returnsReceipt: true`; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/document-api/src/index.ts
throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'],
// Writes word/styles.xml outside the document history, exactly as
// styles.apply does.
historyUnsafe: true,

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: styles.create returns a StylesCreateReceipt, but its command metadata omits returnsReceipt: true; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.

Prompt for AI agents
Check if this issue is valid โ€” if so, understand the root cause and fix it. At packages/document-api/src/contract/operation-definitions.ts, line 1290:

<comment>`styles.create` returns a `StylesCreateReceipt`, but its command metadata omits `returnsReceipt: true`; metadata consumers cannot identify the result as a receipt envelope. Add the receipt marker to this operation definition.</comment>

<file context>
@@ -1264,6 +1264,34 @@ export const OPERATION_DEFINITIONS = {
+      throws: ['INVALID_INPUT', 'CAPABILITY_UNAVAILABLE', 'REVISION_MISMATCH'],
+      // Writes word/styles.xml outside the document history, exactly as
+      // styles.apply does.
+      historyUnsafe: true,
+    }),
+    referenceDocPath: 'styles/create.mdx',
</file context>
Suggested change
historyUnsafe: true,
historyUnsafe: true,
returnsReceipt: true,

Comment thread packages/document-api/src/inline-semantics/token-sets.ts
id: { type: 'string', minLength: 1 },
type: { enum: ['paragraph', 'character'] },
xmlPart: { type: 'string' },
xmlPath: { const: 'w:styles/w:style' },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The published schema hardcodes xmlPath: { const: 'w:styles/w:style' }, duplicating the STYLE_XML_PATH constant exported from create.ts and pinned as the StylesCreateResolution.xmlPath type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports STYLE_XML_PATH), reference the constant here instead so the schema and the resolution type cannot diverge.

Prompt for AI agents
Check if this issue is valid โ€” if so, understand the root cause and fix it. At packages/document-api/src/contract/schemas.ts, line 5169:

<comment>The published schema hardcodes `xmlPath: { const: 'w:styles/w:style' }`, duplicating the `STYLE_XML_PATH` constant exported from create.ts and pinned as the `StylesCreateResolution.xmlPath` type. If the constant is ever updated, the schema will silently drift from the type and the resolution contract. Since schemas.ts already imports from '../styles/index.js' (which re-exports `STYLE_XML_PATH`), reference the constant here instead so the schema and the resolution type cannot diverge.</comment>

<file context>
@@ -5122,6 +5122,101 @@ const operationSchemas: Record<OperationId, OperationSchemaSet> = {
+        id: { type: 'string', minLength: 1 },
+        type: { enum: ['paragraph', 'character'] },
+        xmlPart: { type: 'string' },
+        xmlPath: { const: 'w:styles/w:style' },
+      },
+      ['scope', 'id', 'type', 'xmlPart', 'xmlPath'],
</file context>

Review found that capabilities.get() reported the operation available on a
host that cannot run it. createDocumentApi already gates operations on adapter
presence -- ADAPTER_GATED_PREFIXES marks a whole namespace unavailable with
NAMESPACE_UNAVAILABLE -- but it could not reach this one: adapters.styles is
present, and only its optional create hook is missing.

A second gate now runs at hook granularity with the existing
OPERATION_UNAVAILABLE reason code. It is scoped to the operation this branch
adds. styles.getCatalog and capabilities.check have the same shape, but
changing what an already-shipped operation advertises is a separate change,
not a side effect of this one.

Two details are load-bearing and pinned by tests. The check reads
adapters.styles?.create, because the namespace loop above already tolerates a
missing adapter and a JavaScript host can pass one. And an operation the
engine's snapshot does not mention is left alone rather than invented: an
absent entry already says unavailable, and adding one would claim the engine
reported something it did not.

This also corrects the branch's own claim that a merged contract would be
advertised before it can succeed. It no longer is. What remains is that a
feat: release publishes the operation into the schemas, the agent artifacts
and the reference site regardless, and OperationDefinitionEntry still has no
status field -- the runtime can now say "not implemented here", the contract
cannot.

Also from the review:

- The ST_HighlightColor block had landed between the ST_ThemeColor section
  header and its doc comment, orphaning both. Moved below
  ST_THEME_COLOR_VALUE_SET, and the set carries the runtime-set doc comment
  its neighbours have.

- schemas.ts keeps its xmlPath literal rather than referencing
  STYLE_XML_PATH, and gains a test instead. A published wire format that
  follows a constant changes whenever the constant moves, silently, which is
  the failure it most needs to avoid; styles.apply spells out its own two
  paths for the same reason. What a schema must not do is drift from the
  type, so the test asserts const === STYLE_XML_PATH, and the same assertion
  is added for styles.apply against XML_PATH_BY_CHANNEL.

- returnsReceipt is deliberately still unset. The field marks a result that
  follows the shared ReceiptSuccess/ReceiptFailure envelope, which every one
  of the sixteen operations carrying it does -- TextMutationReceipt is
  (ReceiptSuccess & { resolution }) | (ReceiptFailureResult & { resolution }).
  StylesCreateReceiptSuccess deliberately does not extend ReceiptSuccess,
  because inserted/updated take EntityAddress and a style definition is not
  addressable as a document entity. 93 receipt-returning operations omit the
  marker, styles.apply among them.

And from CI rather than review: apps/docs/config/routes.json was missing
/document-api/reference/styles/create/, which failed Docs check:redirects.
The generator discovers routes by scanning the built export, so producing it
the usual way needs a full docs build; the manifest is append-only and
written as a sorted union through JSON.stringify with two-space indent, so
the entry was produced through that same serialisation and verified
byte-identical to it rather than placed by hand. The docs redirect suite
passes, 32 of 32.

Seven mutations, each confirmed to fail the corresponding test.
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Thanks โ€” four findings, two fixed, one fixed differently, one I want to push back on with evidence.

index.ts:2113 โ€” capability snapshot advertises an unavailable operation. Fixed, and you were right that the mechanism already existed. createDocumentApi already post-processes the engine's snapshot: ADAPTER_GATED_PREFIXES marks operations unavailable with NAMESPACE_UNAVAILABLE when a namespace adapter is absent. It could not reach styles.create, because adapters.styles is present โ€” only the optional create hook is not. Added a second gate at hook granularity with the existing OPERATION_UNAVAILABLE reason code:

for (const { operationId, hasHook } of HOOK_GATED_OPERATIONS) {
  if (hasHook(adapters)) continue;
  const cap = caps.operations[operationId];
  if (!cap) continue; // an engine predating the operation reports no entry at all
  cap.available = false;
  cap.tracked = false;
  cap.dryRun = false;
  cap.reasons = [...(cap.reasons ?? []), 'OPERATION_UNAVAILABLE'];
}

Scoped to the operation this PR adds. Other optional hooks (styles.getCatalog, capabilities.check) have the same shape, but changing what an already-shipped operation advertises is a separate change, not a side effect of this one โ€” say the word and I will widen it.

Two details the tests pin: adapters.styles?.create, because the namespace loop above already tolerates a missing adapter and a JavaScript host can pass one; and the if (!cap) continue, because inventing an entry would claim the engine reported something it did not. Five tests, each confirmed to fail against the corresponding broken code.

This also corrects the PR description, which said a merged contract would be advertised before it can succeed. With the gate it is not โ€” that objection is now smaller than I wrote it, and the description is updated.

token-sets.ts:70 โ€” misplaced block. Fixed. Exactly right: the new section landed between the ST_ThemeColor header and its doc comment, orphaning both. Moved below ST_THEME_COLOR_VALUE_SET, and the set now carries the /** Runtime set for O(1) โ€ฆ */ doc the neighbouring sections have.

schemas.ts:5169 โ€” hardcoded xmlPath. Fixed, but with a test rather than a reference. The drift you describe is real; where I disagree is the direction of the fix. schemas.ts is a hand-written published wire format, and two entries above this one styles.apply spells out its own paths as literals rather than referencing XML_PATH_BY_CHANNEL. That is worth keeping: a schema that follows a constant changes the published contract silently whenever the constant moves, which is the failure a wire format most needs to avoid. What it must not do is drift from the type. So the literal stays and a test asserts schema.const === STYLE_XML_PATH, with the same assertion added for styles.apply against XML_PATH_BY_CHANNEL. Both die under mutation. A future change to the constant now fails the build and requires a deliberate contract edit.

operation-definitions.ts:1290 โ€” returnsReceipt: true. Not adding it; I believe this one is incorrect. The field's own doc says "When true, the operation result follows the Receipt success/failure envelope", and that is how it is used: every one of the 16 operations that sets it returns the shared envelope, including the bespoke-looking ones โ€” TextMutationReceipt is literally (ReceiptSuccess & { resolution }) | (ReceiptFailureResult & { resolution }).

StylesCreateReceiptSuccess deliberately does not extend ReceiptSuccess: inserted / updated take EntityAddress, and a style definition is not addressable as a document entity, so the receipt carries changed / created / per-channel before / after instead. create.ts documents that choice at the type.

The convention bears it out: 93 operations whose expectedResult mentions a receipt do not set the marker, including styles.apply โ€” the sibling operation, in the same namespace, with the same bespoke receipt shape โ€” plus templates.apply and all of sections.*. Setting it here would make styles.create the only styles operation claiming an envelope it does not implement, and a metadata consumer trusting the marker would look for inserted and find nothing.

Happy to be overruled if the field means something broader than its doc says.

Also in this push, from CI rather than review: apps/docs/config/routes.json was missing /document-api/reference/styles/create/, which failed Docs โ†’ check:redirects. Added in the exact form generateRouteManifest writes (sorted union, two-space JSON, trailing newline) and verified byte-identical to that serialisation; the docs redirect suite passes, 32/32.

apps/docs/tests/export.test.mjs pins the reference landing copy, and the page
renders the count from the contract -- Object.keys(model.operations).length in
components/document-api-reference/index.tsx. Adding an operation moves it from
427 to 428, so the assertion has to move with it.

This is the third CI failure on this branch and all three were the same
mistake, not three mistakes: every one of them lived in the Docs job, and every
one of them was invisible without running that job. The document-api gates I
did run locally passed on all three pushes, which is exactly why they were
reassuring and wrong -- what they measured was "the gates I know how to run
pass", not "the branch is green".

Five of the fourteen Docs steps read apps/docs/out and therefore need a full
docs build: check:links, check:redirects, test:export,
test:migration-agent-prompt, and the build itself, which writes _redirects.
All three failures were in that group. test:migration-agent-prompt is the one
worth naming: nothing about its name says it reads build output, and run
without a build it fails with ENOENT on out/md/**, which reads as a regression
rather than a missing precondition.

The manifest entry in the previous commit and this count are both facts about
the operation set that live in committed files, so adding an operation to this
package is not a packages/document-api change alone.
@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Three red runs on this branch, all the same mistake rather than three of them โ€” worth a note since the history looks flaky otherwise.

Every failure was in the Docs job, and every one was invisible without running that job. Five of its fourteen steps read apps/docs/out and therefore need the full docs build: check:links, check:redirects, test:export, test:migration-agent-prompt, and the build itself, which writes _redirects. All three failures were in that group. The document-api gates I was running locally passed on all three pushes, which is exactly why they were reassuring and wrong โ€” they measured "the gates I know how to run pass", not "the branch is green".

The two facts that had to change, both in committed files, neither in packages/document-api:

  • apps/docs/config/routes.json โ€” the new reference page route. Added in the exact form generateRouteManifest writes, verified byte-identical to that serialisation.
  • apps/docs/tests/export.test.mjs:1088 โ€” Search all 427 operations is pinned as a string while the page renders Object.keys(model.operations).length, so adding an operation moves it to 428.

I have now run the whole Docs job locally, in order, against a real build: 22 of 23 steps pass, including all five build-dependent ones. The exception is vp fmt --check apps/docs, which flags 287 of 288 files on my machine because the Windows checkout is CRLF โ€” git ls-files --eol apps/docs reports i/lf for all of them and w/crlf for 371, and the same files taken from HEAD at LF pass the formatter. The two files this branch touches are w/lf and pass either way.

test:migration-agent-prompt is worth naming for anyone else doing this: nothing in its name says it reads build output, and without a build it fails with ENOENT on out/md/**, which reads as a regression rather than a missing precondition.

One unrelated thing I noticed and could not un-notice. packages/document-api/package.json has no scripts block at all โ€” on main as well as here โ€” so the Packages job's bun filter is a silent no-op for that package and its suite never runs in CI. It is 1808 tests; I run them locally, and they are green apart from one pre-existing failure (paragraphs.test.ts:194, rejects clear as a setTabStop alignment, which also fails on main: TAB_STOP_ALIGNMENTS already contains 'clear' while the test expects rejection). Compare packages/layout-engine/package.json, which does declare "test": "bun test". Happy to open a separate issue for either if useful โ€” both are out of scope here.

@Nathaniel-260

Copy link
Copy Markdown
Contributor Author

Docs is green now โ€” thanks, that one was mine.

The three jobs red on this run are not, and I think they will be red on every PR until a release stamp lands, so flagging it rather than sitting on it.

Core, Packages and examples / CI Examples all fail at the same step, Build SuperDoc, with the same error:

EngineInputError: installed engine is missing dist/collaboration-worker.js
    at verifyInstalledEngine (scripts/engine-prepared-input.mjs:892)

What I can see from this repo:

  • 3c3cbba (feat(collaboration): support custom worker providers (SD-4815), fix: doc api consistencyย #2505) is the tip of main and the commit that added dist/collaboration-worker.js to the verifier โ€” git log -S "collaboration-worker.js" -- scripts/engine-prepared-input.mjs returns only that commit, +2 lines.
  • It did not move the engine pin. main:packages/superdoc/package.json still reads "@superdoc/docx-engine": "0.12.0-next.2", stamped one commit earlier in 2155b65.
  • The published tarball for 0.12.0-next.2 does not contain dist/collaboration-worker.js. 0.12.0-next.3 is on the registry and does.

So the requirement landed one commit after the engine it needs was pinned, and a stamp to 0.12.0-next.3 looks like all it wants. CI builds refs/pull/N/merge, so this reaches every open PR regardless of what the PR changes โ€” this branch touches neither packages/superdoc nor the engine pin.

I have deliberately not bumped the pin here. That is release automation's job and doing it in a feature PR would just collide with the next stamp. Say the word if you would rather I did.

For what it is worth on this branch: Docs, Preflight, document-api / check, declarations, CLA and the cubic review are all passing, and the three failures are the single upstream one above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant