diff --git a/.changeset/wild-pugs-shave.md b/.changeset/wild-pugs-shave.md new file mode 100644 index 0000000000..134aa24699 --- /dev/null +++ b/.changeset/wild-pugs-shave.md @@ -0,0 +1,11 @@ +--- +'@objectstack/spec': patch +--- + +Reference docs: qualify a union variant's `### Nested Shape:` and `### Allowed Values:` headings by which variant they belong to. + +The `### Union Options` renderer calls the property-table renderer once per variant, and both halves of the `Schema.key` qualifier those headings carry are shared by every sibling variant of one schema. Two `ViewItem` variants each declaring a shape-opening `config` therefore emitted `### Nested Shape: \`ViewItem.config\`` twice — two identical anchors on one page, on the very heading whose qualifier exists to prevent that. Measured on the published tree: 12 excess occurrences, 10 distinct headings, 4 pages, all under schemas rendering `### Union Options`. + +The heading now names the variant with the accessor grammar the page already prints — `ViewItem[viewKind='list'].config`, or `[option 2]` where the union pins no distinct discriminant — reusing the same `variantSelector` that stamps the variant segment into a property accessor, so a page carries one variant notation rather than two. `### Allowed Values:` is covered in the same change (no page collides there today; the exposure is identical). Headings outside a union variant, and unions with a single heading-emitting arm, are byte-identical: regenerating the 214-page tree changes 39 heading lines and nothing else. + +This changes what the docs site publishes, hence a release-visible patch rather than a skipped changeset. diff --git a/content/docs/references/api/websocket.mdx b/content/docs/references/api/websocket.mdx index 3d026c4ffd..4d8825fcb2 100644 --- a/content/docs/references/api/websocket.mdx +++ b/content/docs/references/api/websocket.mdx @@ -474,7 +474,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | | **subscription** | `{ subscriptionId: string; events: string[]; objects?: string[]; filters?: any; … }` | ✅ | Subscription configuration | -### Nested Shape: `WebSocketMessage.subscription` +### Nested Shape: `WebSocketMessage[type='subscribe'].subscription` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -499,7 +499,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | | **request** | `{ subscriptionId: string }` | ✅ | Unsubscribe request | -### Nested Shape: `WebSocketMessage.request` +### Nested Shape: `WebSocketMessage[type='unsubscribe'].request` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -539,7 +539,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | | **presence** | `{ userId: string; sessionId: string; status: Enum<'online' \| 'away' \| 'busy' \| 'offline'>; lastSeen: string; … }` | ✅ | Presence state | -### Nested Shape: `WebSocketMessage.presence` +### Nested Shape: `WebSocketMessage[type='presence'].presence` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -567,7 +567,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | | **cursor** | `{ userId: string; sessionId: string; documentId: string; position?: object; … }` | ✅ | Cursor position | -### Nested Shape: `WebSocketMessage.cursor` +### Nested Shape: `WebSocketMessage[type='cursor'].cursor` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -595,7 +595,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime when message was sent | | **operation** | `{ operationId: string; documentId: string; userId: string; sessionId: string; … }` | ✅ | Edit operation | -### Nested Shape: `WebSocketMessage.operation` +### Nested Shape: `WebSocketMessage[type='edit'].operation` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 045183e46d..ff3d23bef1 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -543,7 +543,7 @@ This schema accepts one of the following structures: | **object** | `string` | ✅ | | | **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: object[]; … }` | optional | | -### Nested Shape: `DataEngineRequest.query` +### Nested Shape: `DataEngineRequest[method='find'].query` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -577,7 +577,7 @@ This schema accepts one of the following structures: | **object** | `string` | ✅ | | | **query** | `{ context?: object; where?: Record \| any; fields?: string[]; orderBy?: object[]; … }` | optional | | -### Nested Shape: `DataEngineRequest.query` +### Nested Shape: `DataEngineRequest[method='findOne'].query` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -626,7 +626,7 @@ This schema accepts one of the following structures: | **id** | `string \| number` | optional | ID for single update, or use where in options | | **options** | `{ context?: object; where?: Record \| any; multi?: boolean; returning?: boolean; … }` | optional | | -### Nested Shape: `DataEngineRequest.options` +### Nested Shape: `DataEngineRequest[method='update'].options` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -650,7 +650,7 @@ This schema accepts one of the following structures: | **id** | `string \| number` | optional | ID for single delete, or use where in options | | **options** | `{ context?: object; where?: Record \| any; multi?: boolean; filter?: Record \| any }` | optional | | -### Nested Shape: `DataEngineRequest.options` +### Nested Shape: `DataEngineRequest[method='delete'].options` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -671,7 +671,7 @@ This schema accepts one of the following structures: | **object** | `string` | ✅ | | | **query** | `{ context?: object; where?: Record \| any; filter?: Record \| any }` | optional | | -### Nested Shape: `DataEngineRequest.query` +### Nested Shape: `DataEngineRequest[method='count'].query` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -691,7 +691,7 @@ This schema accepts one of the following structures: | **object** | `string` | ✅ | | | **query** | `{ context?: object; where?: Record \| any; groupBy?: (string \| object)[]; aggregations?: object[]; … }` | ✅ | | -### Nested Shape: `DataEngineRequest.query` +### Nested Shape: `DataEngineRequest[method='aggregate'].query` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/system/collaboration.mdx b/content/docs/references/system/collaboration.mdx index 7dc255263c..bb2292e4aa 100644 --- a/content/docs/references/system/collaboration.mdx +++ b/content/docs/references/system/collaboration.mdx @@ -195,7 +195,7 @@ This schema accepts one of the following structures: | **replicaId** | `string` | ✅ | ID of replica that performed last write | | **vectorClock** | `{ clock: Record }` | optional | Optional vector clock for causality tracking | -### Nested Shape: `CRDTState.vectorClock` +### Nested Shape: `CRDTState[type='lww-register'].vectorClock` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -241,7 +241,7 @@ This schema accepts one of the following structures: | **type** | `'or-set'` | ✅ | | | **elements** | `{ value: any; timestamp: string; replicaId: string; uid: string; … }[]` | ✅ | Set elements with metadata | -### Nested Shape: `CRDTState.elements[number]` +### Nested Shape: `CRDTState[type='or-set'].elements[number]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -268,7 +268,7 @@ This schema accepts one of the following structures: | **lamportClock** | `integer` | ✅ | Current Lamport clock value | | **vectorClock** | `{ clock: Record }` | ✅ | Vector clock for causality | -### Nested Shape: `CRDTState.operations[number]` +### Nested Shape: `CRDTState[type='text'].operations[number]` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -280,7 +280,7 @@ This schema accepts one of the following structures: | **timestamp** | `string` | ✅ | ISO 8601 datetime of operation | | **lamportTimestamp** | `integer` | ✅ | Lamport timestamp for ordering | -### Nested Shape: `CRDTState.vectorClock` +### Nested Shape: `CRDTState[type='text'].vectorClock` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index c8ecd886c4..5fa2e557ce 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -465,7 +465,7 @@ Add a new field to an existing object | **fieldName** | `string` | ✅ | Name of the field to add | | **field** | `{ name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }` | ✅ | Full field definition to add | -### Nested Shape: `MigrationOperation.field` +### Nested Shape: `MigrationOperation[type='add_field'].field` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -590,7 +590,7 @@ Create a new object | **type** | `'create_object'` | ✅ | | | **object** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full object definition to create | -### Nested Shape: `MigrationOperation.object` +### Nested Shape: `MigrationOperation[type='create_object'].object` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/system/tenant.mdx b/content/docs/references/system/tenant.mdx index 66dacfb9f1..4e5171bd7b 100644 --- a/content/docs/references/system/tenant.mdx +++ b/content/docs/references/system/tenant.mdx @@ -249,7 +249,7 @@ This schema accepts one of the following structures: | **database** | `{ enableRLS: boolean; contextMethod: Enum<'session_variable' \| 'search_path' \| 'application_name'>; contextVariable: string; applicationValidation: boolean }` | optional | Database configuration | | **performance** | `{ usePartialIndexes: boolean; usePartitioning: boolean; poolSizePerTenant?: integer }` | optional | Performance settings | -### Nested Shape: `TenantIsolationConfig.database` +### Nested Shape: `TenantIsolationConfig[strategy='shared_schema'].database` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -258,7 +258,7 @@ This schema accepts one of the following structures: | **contextVariable** | `string` | optional (default: `"app.current_tenant"`) | Session variable name | | **applicationValidation** | `boolean` | optional (default: `true`) | Application-level tenant validation | -### Nested Shape: `TenantIsolationConfig.performance` +### Nested Shape: `TenantIsolationConfig[strategy='shared_schema'].performance` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -279,7 +279,7 @@ This schema accepts one of the following structures: | **migrations** | `{ strategy: Enum<'parallel' \| 'sequential' \| 'on_demand'>; maxConcurrent: integer; rollbackOnError: boolean }` | optional | Migration configuration | | **performance** | `{ poolPerSchema: boolean; schemaCacheTTL: integer }` | optional | Performance settings | -### Nested Shape: `TenantIsolationConfig.schema` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_schema'].schema` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -288,7 +288,7 @@ This schema accepts one of the following structures: | **sharedSchema** | `string` | optional (default: `"public"`) | Schema for shared resources | | **autoCreateSchema** | `boolean` | optional (default: `true`) | Auto-create schema | -### Nested Shape: `TenantIsolationConfig.migrations` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_schema'].migrations` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -296,7 +296,7 @@ This schema accepts one of the following structures: | **maxConcurrent** | `integer` | optional (default: `10`) | Max concurrent migrations | | **rollbackOnError** | `boolean` | optional (default: `true`) | Rollback on error | -### Nested Shape: `TenantIsolationConfig.performance` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_schema'].performance` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -317,7 +317,7 @@ This schema accepts one of the following structures: | **backup** | `{ strategy: Enum<'individual' \| 'consolidated' \| 'on_demand'>; frequencyHours: integer; retentionDays: integer }` | optional | Backup configuration | | **encryption** | `{ perTenantKeys: boolean; algorithm: string; keyManagement?: Enum<'aws_kms' \| 'azure_key_vault' \| 'gcp_kms' \| 'hashicorp_vault' \| 'custom'> }` | optional | Encryption configuration | -### Nested Shape: `TenantIsolationConfig.database` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].database` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -326,7 +326,7 @@ This schema accepts one of the following structures: | **separateCredentials** | `boolean` | optional (default: `true`) | Separate credentials per tenant | | **autoCreateDatabase** | `boolean` | optional (default: `true`) | Auto-create database | -### Nested Shape: `TenantIsolationConfig.connectionPool` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].connectionPool` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -335,7 +335,7 @@ This schema accepts one of the following structures: | **idleTimeout** | `integer` | optional (default: `300`) | Idle pool timeout | | **usePooler** | `boolean` | optional (default: `true`) | Use connection pooler | -### Nested Shape: `TenantIsolationConfig.backup` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].backup` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -343,7 +343,7 @@ This schema accepts one of the following structures: | **frequencyHours** | `integer` | optional (default: `24`) | Backup frequency | | **retentionDays** | `integer` | optional (default: `30`) | Backup retention days | -### Nested Shape: `TenantIsolationConfig.encryption` +### Nested Shape: `TenantIsolationConfig[strategy='isolated_db'].encryption` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index ec784a6d1e..5b8c2ec7ca 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -892,7 +892,7 @@ This schema accepts one of the following structures: | **type** | `'action'` | ✅ | | | **actionDef** | `{ actionName: string; params?: Record }` | ✅ | Action definition to execute when clicked | -### Nested Shape: `NavigationItem.actionDef` +### Nested Shape: `NavigationItem[type='action'].actionDef` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index dea958c218..e1e17ee671 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -1873,7 +1873,7 @@ This schema accepts one of the following structures: | **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | | **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | -### Nested Shape: `ViewData.read` +### Nested Shape: `ViewData[provider='api'].read` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1883,7 +1883,7 @@ This schema accepts one of the following structures: | **params** | `Record` | optional | Query parameters | | **body** | `any` | optional | Request body for POST/PUT/PATCH | -### Nested Shape: `ViewData.write` +### Nested Shape: `ViewData[provider='api'].write` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -1990,7 +1990,7 @@ This schema accepts one of the following structures: | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -### Nested Shape: `ViewItem.config` +### Nested Shape: `ViewItem[viewKind='list'].config` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2044,7 +2044,7 @@ This schema accepts one of the following structures: | **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -### Nested Shape: `ViewItem.protection` +### Nested Shape: `ViewItem[viewKind='list'].protection` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2079,7 +2079,7 @@ This schema accepts one of the following structures: | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | -### Nested Shape: `ViewItem.config` +### Nested Shape: `ViewItem[viewKind='form'].config` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2109,7 +2109,7 @@ This schema accepts one of the following structures: | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | | **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -### Nested Shape: `ViewItem.protection` +### Nested Shape: `ViewItem[viewKind='form'].protection` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2165,7 +2165,7 @@ This schema accepts one of the following structures: | **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | | **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored). #9933 | -### Nested Shape: `ViewItemWire.config` +### Nested Shape: `ViewItemWire[viewKind='list'].config` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2219,7 +2219,7 @@ This schema accepts one of the following structures: | **bordered** | `never` | optional | [REMOVED] `view.bordered` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no renderer ever applied it (the grid frame is the renderer's own constant, not authorable). Delete the key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **virtualScroll** | `never` | optional | [REMOVED] `view.virtualScroll` was removed in @objectstack/spec 17.0.0 (#7176, ADR-0049 enforce-or-remove) — every measured reader only copied it forward and no grid ever virtualized off it; authoring it was a parse-clean no-op. Delete the key; large datasets page via `pagination`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -### Nested Shape: `ViewItemWire.protection` +### Nested Shape: `ViewItemWire[viewKind='list'].protection` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2227,7 +2227,7 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | -### Nested Shape: `ViewItemWire.columnState` +### Nested Shape: `ViewItemWire[viewKind='list'].columnState` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2264,7 +2264,7 @@ This schema accepts one of the following structures: | **sortOrder** | `integer` | optional | Studio round-trip: position within the switcher (per-user state, written by the console — not authored). | | **columnState** | `{ order?: string[]; widths?: Record }` | optional | Studio round-trip: per-user column order/widths (runtime-only state, written by the console grid — not authored). #9933 | -### Nested Shape: `ViewItemWire.config` +### Nested Shape: `ViewItemWire[viewKind='form'].config` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2294,7 +2294,7 @@ This schema accepts one of the following structures: | **defaults** | `Record` | optional | Initial field values for create-mode forms (folded into ObjectUI ObjectForm initial values; framework#1894 / #2998). | | **aria** | `never` | optional | [REMOVED] `form.aria` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — no form renderer ever applied it, so declared ARIA attributes silently did not reach the DOM. Delete the key. The form renderer emits its own semantic markup; report gaps as renderer issues rather than per-view attribute overrides. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -### Nested Shape: `ViewItemWire.protection` +### Nested Shape: `ViewItemWire[viewKind='form'].protection` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -2302,7 +2302,7 @@ This schema accepts one of the following structures: | **reason** | `string` | ✅ | User-visible reason shown when the lock blocks an action. | | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | -### Nested Shape: `ViewItemWire.columnState` +### Nested Shape: `ViewItemWire[viewKind='form'].columnState` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts index b101a4b34b..875864de20 100644 --- a/packages/spec/scripts/lib/format-type.ts +++ b/packages/spec/scripts/lib/format-type.ts @@ -667,9 +667,26 @@ export function nestedShapeOf(prop: any, ctx?: TypeContext): NestedShape | null * First qualifying key in declaration order. Zod emits the discriminator first * for `z.discriminatedUnion()`, so declaration order is the author's own answer * to "which key tells these apart" rather than a guess this function makes. + * + * ## Why `contributes` is a boolean per variant, and why this is exported + * + * Two callers ask this question about the same union, from two positions, and + * they MUST agree — a selector spelled one way in a property accessor and + * another way in a section heading would be two notations for one fact. The + * walk below asks it of a union met inside a property's type (contributing = + * "this variant opened at least one shape"); `schema-section.ts`'s + * `### Union Options` branch asks it of a schema's TOP-LEVEL union + * (contributing = "this variant renders a property table", i.e. is a heading- + * emitting context at all). Only the *membership test* differs, so only that + * is the parameter; the discriminant rule itself is this one function. + * + * A non-contributing variant is skipped entirely rather than counted absent: + * a `string` arm of `string | { type: 'a' … } | { type: 'b' … }` has no + * `properties` for the selector to read, and letting it veto the discriminant + * would push a perfectly discriminated union onto the positional fallback. */ -function discriminantKeyOf(variants: any[], perVariant: NestedShape[][]): string | null { - const contributing = variants.filter((_, index) => perVariant[index].length > 0); +export function discriminantKeyOf(variants: any[], contributes: boolean[]): string | null { + const contributing = variants.filter((_, index) => contributes[index]); if (contributing.length < 2) return null; const first = contributing[0]; @@ -691,6 +708,39 @@ function discriminantKeyOf(variants: any[], perVariant: NestedShape[][]): string return null; } +/** + * The one segment that names WHICH variant of a union was selected — + * `[type='sidebar']` when `discriminantKeyOf` found a key, `[option 2]` when it + * did not. + * + * Exported for the same reason `discriminantKeyOf` is: the accessor inside a + * property's type and the qualifier on a section heading emitted from inside a + * top-level union variant are the SAME statement about the same union, and a + * page carrying two spellings of it would be a page with two notations. Both + * callers compose the string this returns; neither builds one. + * + * `index` is the variant's position in the union as DECLARED — including + * variants that contribute nothing — because the positional fallback is only + * honest if the number can be counted off the rendering the reader already has + * (`#### Option 2` under `### Union Options`, or the Type cell's union arms). + * Renumbering to skip non-contributors would produce a number matching nothing + * on the page. + * + * Callers decide WHETHER a selector is warranted before calling; this function + * always returns one. The two gates are deliberately not folded in here + * because they are not the same gate: the walk suppresses the segment when the + * union yields fewer than two SHAPES (so `string | { … }` keeps the accessor + * #12309 gave it), while the section renderer suppresses it when fewer than two + * variants emit HEADINGS (so a union with a single object arm keeps the heading + * it has). One function with two membership rules baked in would have to guess + * which caller it has. + */ +export function variantSelector(variant: any, index: number, discriminant: string | null): string { + return discriminant + ? `[${discriminant}=${formatLiteral(variant.properties[discriminant].const)}]` + : `[option ${index + 1}]`; +} + /** * EVERY shape a property opens at depth 0, each with the accessor that selects * it — the general answer `nestedShapeOf` reads its single-shape case out of. @@ -775,7 +825,7 @@ export function nestedShapesOf(prop: any, ctx?: TypeContext): NestedShape[] { // it, so it contributes no segment. `string | { … }` keeps `Schema.key`. if (total < 2) return perVariant.flat(); - const discriminant = discriminantKeyOf(variants, perVariant); + const discriminant = discriminantKeyOf(variants, perVariant.map(shapes => shapes.length > 0)); const selected: NestedShape[] = []; perVariant.forEach((shapes, index) => { // A variant that opens no shape gets no selector — and the guard is not @@ -784,9 +834,7 @@ export function nestedShapesOf(prop: any, ctx?: TypeContext): NestedShape[] { // `string | { type: 'a' … } | { type: 'b' … }` has no `properties` for // the selector to read and computing one here would throw. if (shapes.length === 0) return; - const selector = discriminant - ? `[${discriminant}=${formatLiteral(variants[index].properties[discriminant].const)}]` - : `[option ${index + 1}]`; + const selector = variantSelector(variants[index], index, discriminant); for (const shape of shapes) { // Spliced in at the UNION's position, not appended to the finished // path: everything the walk composed below this union came from diff --git a/packages/spec/scripts/lib/schema-section.ts b/packages/spec/scripts/lib/schema-section.ts index 2ce01694fc..b499032339 100644 --- a/packages/spec/scripts/lib/schema-section.ts +++ b/packages/spec/scripts/lib/schema-section.ts @@ -14,9 +14,11 @@ import { escapeMdxDescription } from './escape-mdx'; import { + discriminantKeyOf, formatPropertyType, formatType, nestedShapesOf, + variantSelector, type NestedShape, type TypeContext, } from './format-type'; @@ -277,6 +279,36 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio // the COUNT out of this position for a smaller version of the same cost; // taking the whole list out of it is the same decision. expandNested = true, + // What the `### Nested Shape:` and `### Allowed Values:` headings below + // qualify themselves BY — `schemaName` for a schema rendered whole, and + // `schemaName` plus a variant selector (`ViewItem[viewKind='list']`) for a + // table rendered from inside one arm of a top-level `### Union Options`. + // + // ## Why this is a parameter now (#12590) + // + // The union branch calls this function once per variant, and both halves of + // the old `${schemaName}.${key}` qualifier are shared by every sibling + // variant of one schema. Two `ViewItem` variants each declaring a + // shape-opening `config` therefore emitted `### Nested Shape: + // \`ViewItem.config\`` twice: two identical headings, i.e. **two identical + // anchors on one page**, on the very heading whose qualifier exists to + // prevent exactly that. Measured across the tree: 12 excess occurrences, + // 10 distinct headings, 4 pages — every one of them under a schema + // rendering `### Union Options`. + // + // The comment on `path` below (#12316) reasoned that an `owner` parameter + // "would be a parameter nothing ever reads", and that was true of the level + // it was written about: a nested-shape sub-table opens nothing of its own. + // It did not hold one level UP, where the union branch is a second caller + // of this function on the same schema name. So the owner is threaded from + // there and nowhere else, and defaults to `schemaName` so that every + // non-union call site — and the recursive sub-table call, which emits no + // headings at all — is byte-identical to what it rendered before. + // + // ⛔ Not a new notation: the selector spelling comes from `variantSelector` + // in `format-type.ts`, the same function that stamps the variant segment + // into a property accessor (#12316). One grammar, one implementation. + owner: string = schemaName, ) => { // Vocabularies too wide for their own table cell. Collected while the // table is built and printed as `### Allowed Values` bullets right after @@ -320,17 +352,18 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio if (!carriesDescription(shape)) continue; const own = typeof shape.node.description === 'string' ? shape.node.description : ''; nested.push({ - // Qualified by schema AND property, for the reason the + // Qualified by OWNER and property, for the reason the // `### Allowed Values` headings below are: one page carries // many schemas, and a heading naming only the property would - // give it two identical anchors. `schemaName` and not a - // threaded `owner` parameter — a nested table opens no - // relocation and no sub-table of its own, so an owner threaded - // into one would be a parameter nothing ever reads. The variant + // give it two identical anchors. The owner is `schemaName` + // alone everywhere except inside a top-level union variant, + // where it carries that variant's selector too — because the + // schema name is shared by all of a union's siblings and so + // qualifies nothing between them (#12590). The variant // selector inside `accessor` is what extends that same // qualification down one more level, so nine sub-tables under // one property still carry nine distinct anchors. - path: `${schemaName}.${key}${shape.accessor}`, + path: `${owner}.${key}${shape.accessor}`, // The element/value node's OWN describe, when it is not simply // the property's — that one is already in the row above. On a // union this is the VARIANT's describe, which is the line that @@ -358,11 +391,16 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio t += `| **${key}** | \`${typeStr}\` | ${isReq} | ${desc} |\n`; } t += '\n'; - // Qualified by schema AND property: `api/errors.mdx` carries a wide + // Qualified by OWNER and property: `api/errors.mdx` carries a wide // `code` on both `EnhancedApiError` and `FieldError`, so a heading naming - // only the property would give one page two identical anchors. + // only the property would give one page two identical anchors. Sibling + // variants of ONE union are the same collision one level in — they share + // the schema name — so the owner carries the variant selector there + // (#12590). No page has two such headings today; covering this position + // in the same change is what stops the next wide vocabulary declared on + // two variants from reopening the defect. for (const { key, members } of relocated) { - t += `### Allowed Values: \`${schemaName}.${key}\`\n\n`; + t += `### Allowed Values: \`${owner}.${key}\`\n\n`; t += members.map(m => `* \`${m}\``).join('\n'); t += `\n\n`; } @@ -386,6 +424,13 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio new Set(shape.node.required || []), '', false, + // Passed through rather than defaulted: this call emits no + // headings today (`expandNested: false` collects neither + // relocation nor nested shape), so it is the owner's value that + // is unobservable here, never the owner's correctness. Letting it + // fall back to `schemaName` would plant a wrong value waiting for + // the day this table is allowed to open something. + owner, ); } return t; @@ -402,6 +447,23 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio } else if (mainDef.anyOf || mainDef.oneOf) { md += `### Union Options\n\nThis schema accepts one of the following structures:\n\n`; const variants = mainDef.anyOf || mainDef.oneOf; + + // Which variants are heading-emitting contexts at all — only the object + // branch below calls `renderProperties`, and only `renderProperties` + // emits `### Nested Shape:` / `### Allowed Values:` headings. An `enum`, + // `$ref` or scalar arm prints one line and can collide with nothing. + const emitsHeadings: boolean[] = variants.map( + (variant: any) => variant?.type === 'object' && !!variant.properties, + ); + const emitters = emitsHeadings.filter(Boolean).length; + // Fewer than two and there is nothing to tell apart: a lone object arm's + // headings are already unique on the page, so it keeps the exact bytes it + // rendered before. This mirrors the walk's own `total < 2` rule in + // `nestedShapesOf` — a selector is spent only where the union is what + // distinguishes the thing being named — measured on the same principle: + // every heading outside the duplicate population stays byte-identical. + const discriminant = emitters >= 2 ? discriminantKeyOf(variants, emitsHeadings) : null; + variants.forEach((variant: any, index: number) => { const variantTitle = variant.title || `Option ${index + 1}`; md += `#### ${variantTitle}\n\n`; @@ -411,7 +473,26 @@ export function renderSchemaSection(schemaName: string, schema: any, ctx: Sectio if (variant.properties.type && variant.properties.type.const) { md += `**Type:** \`${variant.properties.type.const}\`\n\n`; } - md += renderProperties(variant.properties, new Set(variant.required || [])); + // The owner this variant's headings qualify themselves by. The + // selector segment is the one `format-type.ts` stamps into a + // property accessor for the same union (#12316) — discriminant + // where every emitting variant pins a distinct literal, positional + // otherwise. Getting that fallback right IS the fix: a + // discriminant shared by two variants would re-create the + // duplicate anchors through the qualifier meant to remove them, + // which is exactly what `discriminantKeyOf`'s distinctness test + // refuses to answer. + const variantOwner = + emitters >= 2 + ? `${schemaName}${variantSelector(variant, index, discriminant)}` + : schemaName; + md += renderProperties( + variant.properties, + new Set(variant.required || []), + '### Properties', + true, + variantOwner, + ); } else if (variant.enum) { md += `Allowed Values: ${variant.enum.map((e:string) => `\`${e}\``).join(', ')}\n\n`; } else if (variant.$ref) { diff --git a/packages/spec/scripts/schema-section.test.ts b/packages/spec/scripts/schema-section.test.ts index 9b02b21b0c..1c875edfd3 100644 --- a/packages/spec/scripts/schema-section.test.ts +++ b/packages/spec/scripts/schema-section.test.ts @@ -315,3 +315,257 @@ describe('selectRootDef — precedence', () => { expect(selectRootDef('Thing', doc)).toBe(doc); }); }); + +/** + * THE DEFECT THIS PINS (#12590). `### Nested Shape:` and `### Allowed Values:` + * headings qualify themselves by `Schema.key` so that one page carrying many + * schemas cannot emit the same heading — the same anchor — twice. Inside the + * `### Union Options` branch that qualifier stops qualifying anything: the + * branch calls `renderProperties` once per variant, and BOTH halves of + * `Schema.key` are shared by every sibling variant of one schema. Two + * `ViewItem` variants each declaring a shape-opening `config` therefore emitted + * `### Nested Shape: \`ViewItem.config\`` twice. + * + * Measured on `origin/main@b489d3c7`, over the emitted tree: **12 excess + * `### Nested Shape:` occurrences, 10 distinct headings, 4 pages** — every one + * under a schema rendering `### Union Options`. `check:doc-anchors` is green + * through all of it by design: it checks that links RESOLVE, never that anchors + * are unique, so a duplicate anchor silently sends every link to the first of + * the two. + * + * WHAT THE FIX MAY NOT DO, and what these cases hold it to: + * + * - Not invent a notation. The qualifier reuses `variantSelector` from + * `format-type.ts` — the very function that stamps `[type='sidebar']` / + * `[option 2]` into a property accessor (#12316) — so a page has one + * variant grammar, not two. + * - Not trust a discriminant that does not discriminate. A key pinned to the + * SAME literal on two variants names neither of them, and qualifying with + * it would re-create the duplicate anchors through the fix itself. The + * positional fallback is therefore pinned as hard as the discriminant. + * - Not touch a heading outside a union variant. A schema rendered whole, and + * a union with a single heading-emitting arm, keep the exact headings they + * had. + * + * `### Properties` and `#### Option N` repeat within a union section and are + * deliberately NOT in scope here: they are the section grammar's own headings, + * repeated on every page in the tree, not the per-key qualifier this card is + * about. The uniqueness assertions below are scoped to the two qualified + * headings for that reason. + */ + +/** A `ViewItem`-shaped variant: a discriminant literal plus a shape-opening `config`. */ +const variantWithConfig = (viewKind: string) => ({ + type: 'object', + properties: { + viewKind: { const: viewKind }, + config: { + type: 'object', + properties: { label: { type: 'string', description: `Label shown on the ${viewKind} view.` } }, + }, + }, + required: ['viewKind', 'config'], +}); + +/** The same shape with no `const` anywhere — nothing for a discriminant to read. */ +const anonymousVariantWithConfig = (label: string) => ({ + type: 'object', + properties: { + config: { + type: 'object', + properties: { label: { type: 'string', description: label } }, + }, + }, +}); + +/** Wide enough to leave the cell (`TOP_LEVEL_ENUM_WIDTH_LIMIT` is 160). */ +const WIDE_VOCABULARY = Array.from({ length: 12 }, (_, i) => `vocabulary_member_${i + 1}`); + +const variantWithVocabulary = (viewKind: string) => ({ + type: 'object', + properties: { + viewKind: { const: viewKind }, + mode: { type: 'string', enum: WIDE_VOCABULARY }, + }, + required: ['viewKind', 'mode'], +}); + +/** The qualified headings only — the population this card measures. */ +const qualifiedHeadings = (md: string) => + md.split('\n').filter(l => l.startsWith('### Nested Shape: ') || l.startsWith('### Allowed Values: ')); + +describe('renderSchemaSection — union variants qualify their own headings (#12590)', () => { + it('gives sibling variants sharing a shape-opening key DISTINCT nested-shape headings', () => { + const md = renderSchemaSection('ViewItem', { + anyOf: [variantWithConfig('list'), variantWithConfig('record')], + }); + + expect(md).toContain("### Nested Shape: `ViewItem[viewKind='list'].config`"); + expect(md).toContain("### Nested Shape: `ViewItem[viewKind='record'].config`"); + // The shared spelling — one anchor for two different shapes — is gone. + expect(md).not.toContain('### Nested Shape: `ViewItem.config`'); + }); + + it('emits no duplicate qualified heading anywhere in the section', () => { + const md = renderSchemaSection('ViewItem', { + anyOf: [variantWithConfig('list'), variantWithConfig('record'), variantWithConfig('form')], + }); + + const headings = qualifiedHeadings(md); + expect(headings).toHaveLength(3); + expect(new Set(headings).size).toBe(headings.length); + }); + + it('spells the qualifier in the accessor voice the page already prints', () => { + const md = renderSchemaSection('ViewItem', { + anyOf: [variantWithConfig('list'), variantWithConfig('record')], + }); + + // `[key='literal']`, the #12316 discriminant spelling — the same literal + // formatting the Type cell two lines above uses, so a reader copying + // `viewKind: 'list'` off the heading copies the schema's own answer. + expect(md).toMatch(/### Nested Shape: `ViewItem\[viewKind='list'\]\.config`/); + // No second notation: no `#N`, no `(1)`, no bare `[1]`. + expect(md).not.toMatch(/### Nested Shape: `ViewItem[#(]/); + }); + + it('falls back to the positional spelling when the union has no discriminant', () => { + const md = renderSchemaSection('Thing', { + anyOf: [anonymousVariantWithConfig('first'), anonymousVariantWithConfig('second')], + }); + + expect(md).toContain('### Nested Shape: `Thing[option 1].config`'); + expect(md).toContain('### Nested Shape: `Thing[option 2].config`'); + }); + + /** + * The trap #12316 named, and the one way this fix could recreate the very + * defect it removes: `viewKind` is a `const` on both variants and pins the + * SAME literal, so it identifies neither. Answering it would emit + * `Thing[viewKind='list'].config` twice. + */ + it('refuses a discriminant two variants pin to the same literal', () => { + const md = renderSchemaSection('Thing', { + anyOf: [variantWithConfig('list'), variantWithConfig('list')], + }); + + expect(md).not.toContain("[viewKind='list']"); + expect(md).toContain('### Nested Shape: `Thing[option 1].config`'); + expect(md).toContain('### Nested Shape: `Thing[option 2].config`'); + const headings = qualifiedHeadings(md); + expect(new Set(headings).size).toBe(headings.length); + }); + + /** + * The positional number counts DECLARED position, including arms that emit no + * headings — so it can be checked against the `#### Option N` heading the + * reader already has three lines up. Renumbering to skip them would print a + * number matching nothing on the page. + */ + it('numbers positions by the union as declared, not by the emitting arms', () => { + const md = renderSchemaSection('Thing', { + anyOf: [ + { type: 'string' }, + anonymousVariantWithConfig('first'), + anonymousVariantWithConfig('second'), + ], + }); + + expect(md).toContain('#### Option 2'); + expect(md).toContain('### Nested Shape: `Thing[option 2].config`'); + expect(md).toContain('### Nested Shape: `Thing[option 3].config`'); + expect(md).not.toContain('### Nested Shape: `Thing[option 1].config`'); + }); + + it('keeps the variant qualifier at the union position when the accessor goes deeper', () => { + const md = renderSchemaSection('Thing', { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'listy' }, + items: { + type: 'array', + items: { type: 'object', properties: { uid: { type: 'string', description: 'Element id.' } } }, + }, + }, + }, + { + type: 'object', + properties: { + kind: { const: 'flat' }, + items: { + type: 'array', + items: { type: 'object', properties: { uid: { type: 'string', description: 'Element id.' } } }, + }, + }, + }, + ], + }); + + // Reads left to right: the `listy` variant, its `items`, an element of it. + expect(md).toContain("### Nested Shape: `Thing[kind='listy'].items[number]`"); + expect(md).toContain("### Nested Shape: `Thing[kind='flat'].items[number]`"); + }); +}); + +describe('renderSchemaSection — headings outside a union variant are unchanged (#12590)', () => { + it('leaves a plain object schema’s nested-shape heading exactly as it was', () => { + const md = renderSchemaSection('Widget', { + type: 'object', + properties: { + config: { + type: 'object', + properties: { label: { type: 'string', description: 'The label.' } }, + }, + }, + }); + + expect(qualifiedHeadings(md)).toEqual(['### Nested Shape: `Widget.config`']); + }); + + /** + * A union whose single object arm is the only heading-emitting context has + * nothing to tell apart, so it spends no selector — the same rule the walk + * applies to `string | { … }` in `nestedShapesOf` (`total < 2`), which is what + * keeps every heading outside the measured duplicate population byte-identical. + */ + it('spends no selector on a union with a single heading-emitting arm', () => { + const md = renderSchemaSection('Thing', { + anyOf: [{ type: 'string' }, variantWithConfig('list')], + }); + + expect(qualifiedHeadings(md)).toEqual(['### Nested Shape: `Thing.config`']); + }); +}); + +describe('renderSchemaSection — the same treatment for relocated vocabularies (#12590)', () => { + /** + * Zero corpus today: no page carries two `### Allowed Values:` headings that + * collide. It is the SAME exposure — one qualifier, shared by every sibling + * variant — so closing only the half with a measured population would leave + * the next wide vocabulary declared on two variants to reopen it. + */ + it('gives sibling variants sharing a wide vocabulary DISTINCT allowed-values headings', () => { + const md = renderSchemaSection('ViewItem', { + anyOf: [variantWithVocabulary('list'), variantWithVocabulary('record')], + }); + + expect(md).toContain("### Allowed Values: `ViewItem[viewKind='list'].mode`"); + expect(md).toContain("### Allowed Values: `ViewItem[viewKind='record'].mode`"); + expect(md).not.toContain('### Allowed Values: `ViewItem.mode`'); + + const headings = qualifiedHeadings(md); + expect(headings).toHaveLength(2); + expect(new Set(headings).size).toBe(headings.length); + }); + + it('leaves a plain object schema’s allowed-values heading exactly as it was', () => { + const md = renderSchemaSection('Widget', { + type: 'object', + properties: { mode: { type: 'string', enum: WIDE_VOCABULARY } }, + }); + + expect(qualifiedHeadings(md)).toEqual(['### Allowed Values: `Widget.mode`']); + }); +});