fix(spec): one sub-table per variant for a property that opens more than one object shape - #12592
Conversation
…han one object shape (#12316) The measured remainder of #11601: 28 property rows repo-wide open two or more object shapes, and #12309 refused all of them because a heading had no single shape to name. The accessor grammar it introduced extends to cover them — a selector segment spliced in where the union sits, `[type='sidebar']` where the union has a discriminant and `[option 2]` where it does not. `nestedShapeOf` now reads its single-shape answer out of a general `nestedShapesOf` walk, so the two cannot drift apart about what one shape level means, and the section renderer loops over every shape instead of the one. Regenerated tree: 15 files, +1397 / -0 lines. All 1469 single-shape headings are byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
PM review — ACCEPT ( Contract verdict: Clause-② re-checked against the real file census — no on both limbs. 19 files exactly as declared: renderer pair + test suite + changeset + 15 regenerated pages; zero Both fences held and are pinned: Changeset divergence accepted: the dispatch pointed at skip-changeset; the dev shipped a patch changeset instead, on #12309's own recorded rationale for the identical change class ("this PR changes what the docs site publishes" — 1397 authored lines land on the published reference). The precedent controls; divergence was flagged, not silent — exactly right. Reverse verification is the strongest of this shift: ablation to #12309's exact refusal, mutation proved on disk, predicted asymmetry held (7 rendering cases red, the 9 structural walk cases and #12309's own 7 cases green), no-rebuild-leg asserted with the reason rather than skipped, restore proved byte-identical. Instrument disclosures (blind check:generated discarded-and-rebuilt; the fixture-not-code correction on the depth-limit test; sanitizer erratum kept unmarked so the report scan finds exactly one) — all the discipline we keep. #12590 (pre-existing duplicate anchors from #12309's schema-root union branch, byte-identical before/after this PR) correctly filed on its own axis rather than ridden. Landing: waiting for every check green on Generated by Claude Code |
Fixes #12316
The remainder this closes
#12309 gave a property that opens exactly ONE nested object shape a
### Nested Shape:table, so the.describe()text on that shape's keys finally reached the reference page. It deliberately refused a property whose type is a union of two or more object shapes, and said why: there is no single "the shape of this property" to name, and naming one would need a variant index — a second addressing notation. Those rows kept the collapsed signature cell, and that cell has no description column, so their nested describes stayed exactly as unreachable as #11601 found them.Census, re-verified on
origin/main@7bd6447Re-measured before anything was written, counting the property rows the section renderer actually emits (main object table plus union-variant tables):
The card's census holds: 28, unchanged. All 28 carry publishable text —
ui/App.navigation,ui/NavigationArea.navigationandui/NavigationContribution.itemswith nine variants each,system/ChangeSet.operations/.rollbackwith seven,data/ConditionalValidation.then/.otherwiseandsystem/CRDTMergeResult.statewith five, down to four two-variant rows. (The1293and8615in #12309's table have drifted to1469and8604as the schemas moved; the 28 has not.)What makes the variant index unnecessary
The accessor is a composition, not a name. Every segment #12309 introduced answers which way down did the walk go —
[number]for an array element,[string]for aRecordvalue, composed left to right. A union is one more way down. So the variant segment joins the composition at the union's own position instead of being appended to the finished path, and the grammar gains a segment rather than a notation:The second reads left to right as the record value, its third option, an element of it — the reading
[string][number]already had. Appending would have producedon[string][number][option 3], which claims the union sits below the array. It does not.Two spellings, in preference order
[type='sidebar']— where the union has a discriminant. A key that every shape-bearing variant pins to a different literal. It states what the author writes to select that variant, in the sameformatLiteralspelling the Type cell two lines above prints (quotes on a string, none on a number — the #5729 rule), so a reader copyingtype: 'object'off the heading is copying the schema's own answer. It is also stable: reordering the union, or adding a tenth navigation variant, moves no existing heading and therefore breaks no existing anchor. 22 of the 28 rows.[option 2]— for the six that do not. Deliberately the word the union branch has printed under### Union Optionssince long before this, rather than a bare[2], which in a stack of[number]/[string]segments would read as a tuple index into the property's own type. It counts position in the union including arms that open no shape, sostring | { … } | { … }[]numbers its objects 2 and 3 — checkable by the reader against the very cell the table sits under, which is the only thing that makes a positional selector honest.The distinctness half of the discriminant test is load-bearing, not decoration. A union whose arms pin the same
consthas no discriminant by this rule, because answering one would emit two identical headings — two identical anchors on one page, the defect theSchema.keyqualification exists to prevent. That case falls back to positional, and is pinned.The discriminant is read off the variant node, never off the shape found beneath it: a variant may be an array or a record whose element carries the keys, and
Transition[]has noconstanywhere on the node an author selects.Every #12309 bound still holds
SHAPE_DEPTH_LIMITis untouched — the triage fence. What is lifted is the multi-shape refusal at level 1, not the budget: a shape nested inside a variant is exactly as unreachable as it was inside a lone shape. Pinned.ui/FormView.submitBehavioropens four shapes and one carries prose, so it gets one table, not four.### Allowed Values项目符号只对「整个 schema 是枚举」生效,对「某个属性是枚举」从不生效 #6225's relocation budget is only spendable where the vocabulary's authoritative copy lives. SameexpandNested = falseflag, one more caller.###under the schema's##, the position### Allowed Values:has used since gen:docs 顶层长枚举仍是单个 6092 字符的表格单元格 ——### Allowed Values项目符号只对「整个 schema 是枚举」生效,对「某个属性是枚举」从不生效 #6225 — so the single-h1 invariant and fix(spec-docs): a module header's headings start at the page's section level #12551's module-header numbering are untouched.Structure of the change
nestedShapeOfno longer walks; it reads its answer out of a new generalnestedShapesOfand returns the single-shape case ornull. One walk, two readings — they cannot drift apart about what "one shape level" means, the same constructionformatPropertyTypeuses for its cell and its relocation.nestedShapeOfkeeps itsnullon a multi-shape union deliberately: its whole signature is "the ONE shape", and that is the truthful answer to that question. The section renderer simply loops.The regenerated tree is purely additive
15 files, +1397 / −0 lines. Not "every pre-existing line survives" — nothing was deleted or reordered at all, because the stamp is conditioned on the union's own yield and never on the row's, so all 1469 single-shape headings are byte-identical. The changed-page set tracks the census exactly: every one of the 15 pages carries at least one of the 28 rows, and no page without one moved. Largest movers:
ui/app.mdx+462 (the three nine-variant navigation rows),data/validation.mdx+242,system/migration.mdx+140,integration/connector.mdx+124.Verification — all at
HEADEvery command was run through
scripts/pm/os-verify-lock.sh, with the exit code captured before any pipe; the verdict quoted is the gate's own line, never a bare$?.pnpm --filter @objectstack/spec exec vitest runTest Files 431 passed | 1 skipped (432)·Tests 11492 passed | 1 skipped (11493)pnpm --filter @objectstack/spec run check:docs✅ 229 generated files in sync with packages/specpnpm --filter @objectstack/spec run check:generated✓ All 14 generated artifacts are up to date.(see the instrument note below)pnpm --filter @objectstack/spec typecheckcheck:test-typecheck: OK — … 55 file(s) / 263 error(s) held in test-typecheck-debt.json(unchanged, shrink-only)pnpm check:docs-single-h1✓ check-docs-single-h1: 394 page(s) under content/docs/ carry no body-level headingpnpm check:doc-anchors✅ 278 internal #fragment link(s) across 408 source file(s) all resolve to a real headingpnpm check:nul-bytescheck-nul-bytes: OK (scanned 6940 text file(s) … no raw ASCII control bytes)pnpm lint(eslint . --no-inline-config, whole repo)Plus the derived gate union for this change set —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived from the real diff after the final commit, 176 families discovered, 26 matched — all exit 0:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:doc-anchors,check:doc-authoring,check:docs-audit-scope,check:docs-redirects,check:docs-single-h1,check:driver-memory-census,check:engine-double-contract,check:merge-driver,check:objectql-double-limit,check:objectui-changeset,check:page-declaration-shape,check:pm-governed-merges,check:published-files,check:published-readme-links,check:query-options-erasure,check:quick-reference-counts,check:react-page-adapter-contract,check:role-word,check:slot-lookup,check:test-source-alias,check:type-check-coverage,check:type-check-debt,check:type-source-resolution,check:where-matcher.One gate in that union is declared rather than claimed:
check:type-check-debtischeck-type-check-coverage --re-measure, and it refuses on an unbuilt workspace closure — "55 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … measuring now would not fail, it would silently measure a DIFFERENT WORLD". That is aPREREQUISITE NOT METrefusal, not a red gate, and it is the same one #12309 declared. Its non-re-measure siblingcheck:type-check-coverageran green here:check-type-check-coverage: OK — 65/78 workspace packages type-checked … 13 in the DEBT ledger. CI builds the closure before it gates.Instrument disclosure. On the first pass
check:generatedreportedapi-surface/stale — and named itself blind in the same breath: "packages/spec/dist holds no .d.ts declarations — the package is not built … the removals above are phantoms. Build first, then re-run." The reading was discarded,pnpm --filter @objectstack/spec buildwas run withoutOS_SKIP_DTS(which is precisely what makes this gate blind), and the gate re-run. It then reported its own line:✓ All 14 generated artifacts are up to date.This PR touches zero files underpackages/spec/src/**, so it cannot move a surface derived from that tree.Reverse verification
Run from the committed state, with a restore trap on absolute paths, and with the mutation proved on disk before anything was read. The ablation restores #12309's multi-shape refusal at the rendering call with every other line in place —
nestedShapesOf(prop, typeCtx)consumed aslength === 1 ? … : [].2ce0169→ec82e25,git diff --stat2 insertions / 1 deletion. (No build ordistis involved — the renderer is consumed from source bytsxand by vitest — so there is no stale-artifact leg to prove.)Tests 7 failed | 143 passed (150). The 7 are exactly therenderSchemaSection … (#12316)rendering cases.nestedShapesOfstructural cases stayed green, because the ablation targets the rendering call and not the walk; so did all 7 of fix(spec): render a nested item shape as a table so its describe text reaches the reference page #12309's ownrenderSchemaSectioncases, which is itself evidence the mutation is scoped to the multi-shape path. This is the same both-directions asymmetry fix(spec): render a nested item shape as a table so its describe text reaches the reference page #12309 documented for its own pins.check:docs→ exit 1, its own line:✗ content/docs/references/ is out of date with packages/spec:, listing exactly the 15 pages. The pages now depend on the multi-shape rendering.2ce0169(byte-identical to theHEADblob),git diff HEAD→ 0 files, marker occurrences back to 0.Changeset — a deliberate divergence from the dispatch note, flagged for review
The dispatch called this renderer-internal and pointed at
skip-changeset. I did not take that route, because #12309 — the same files, the same change class — shipped@objectstack/spec: patchwith the rationale written out: "skip-changesetwould have been wrong here: this PR changes what the docs site publishes." That is true verbatim of this PR, which adds 1397 lines of authored documentation to the published reference..changeset/docs-multishape-variant-subtables.mdfollows that precedent. If the PM prefers the label, dropping the changeset is a one-file revert — flagging rather than deciding silently, since the two instructions genuinely conflict.Out-of-scope finding, filed not fixed
#12590 — 12 duplicate
### Nested Shape:anchors across 4 pages, where a schema's own### Union Optionsbranch renders two variants that share a shape-opening key (ViewItem.configtwice under one## ViewItem). Pre-existing and untouched: byte-identical counts before and after this PR, which adds 113 sub-tables and zero duplicates. It is a different axis — a union at the schema root, not under a property — so it is not this card, and the in-place exemption does not apply: the right fix is a design choice about a second owner qualifier, not a mechanical one.Scope
SHAPE_DEPTH_LIMITuntouched (the triage fence). No change underpackages/spec/src/**,packages/spec/json-schema/**emission (#12315 stays decision-boxed), orcontent/docs/releases/**. H17 files read only, never edited.Generated by Claude Code