diff --git a/.changeset/identical-union-variant-dedupe.md b/.changeset/identical-union-variant-dedupe.md new file mode 100644 index 0000000000..359c7216d2 --- /dev/null +++ b/.changeset/identical-union-variant-dedupe.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 参考文档的联合单元格每种拼写只印一次,重复的变体计入既有的 `… +N more` (#6569) + +`content/docs/references/**` 的类型单元格里,同一个联合的多个变体如果**渲染成 +逐字相同的字符串**,现在只印一次,被折叠掉的变体数计进 #6226 既有的 +`… +N more` 标记。全语料实测:**14 格**改变(7 个页面),这些格的类型文本合计 +**3682 → 2469 字符**;字面的 `object | object | object | object` 从 **9 格降到 +0 格**。 + +## 为什么会有这种重复 + +#6374 给内联形状摘要加了深度预算(嵌套形状印 `object`)。后果之一:一个联合的 +多个变体如果**都是对象**,它们在摘要之下就都渲染成 `object`,单元格里于是出现 +连续重复。`kernel/manifest.mdx` 的 `Manifest.navigationContributions` 最刺眼 —— +它印了四个 `object`,再数「`… +5 more`」个**和已印出的那四个一模一样**的 +`object`。 + +重复本身不是 #6374 造出来的新行为(`ui/app.mdx` 的 `App.navigation` 在 #6226 +之前就把同一个形状印了九遍),但深度预算让它变成了语料里的常见拼写。这些页面是 +AI 作者的权威输入(ADR-0033),一格读起来像渲染 bug 的代价高于它的宽度。 + +## 裁决落地的形状 + +维护者的裁决在 issue 正文的 B 与 C 之间:**折叠**(重复的拼写不携带第一份之外 +的任何信息),但**自报元数**(#6226 的规则:省略必须说出自己的大小),并且用 +表格**已有**的标记词汇,而不是新发明一种 `object ×4` 之类的记号 —— #6226 的 +裁决理由恰恰是「同一张表里出现第二种省略风格,比它要修的宽度更糟」。 + +于是一句话对读者成立:**印出的拼写数 + 计数 = 联合的元数**,无论某个变体是被 +变体数上限藏起来的,还是因为这一格已经逐字印过它而被折叠掉的。 + +## 三条有意的边界 + +- **判等在渲染出来的字符串上**,不在 schema 结构上:`object | object[]` + (`ui/page.mdx` 的 `Page.slots`)是两种拼写,永不折叠。这个渲染器判的是读者 + 看到的东西。 +- **不限于相邻**。实测语料里唯一一处两种规则会分歧的地方,正是 #6226 立案的那 + 一格:`App.navigation` 的九个变体里,八个是同一种导航项拼写、一个是 + `{ type: 'separator'; … }`,而分隔符排在**第七位**——`uniq` 式的相邻规则会 + 把第八份导航项**再印一遍**,等于把缺陷留在这一格里。 +- **共享的「标记必须挣回自己的位置」守卫原样适用**,没有豁免:`object | object` + 加上标记反而**变长 3 字符**,`object | object | object` 只省 6 字符而标记占 + 12 字符,两者都保留重复;四个重复才是第一个划算的宽度(省 15 字符)。语料里 + 14 处带重复拼写的联合渲染全部通过守卫。 + +## 一条既有 pin 因此翻转 + +#6226 曾把 `string | string | string | string | string` 逐字钉住,理由写在测试 +注释里:按变体数上限印四个再加标记会**更长**,守卫拒绝。这次改的是**候选**而 +不是守卫 —— 一种拼写加 `… +4 more` 是 18 字符对 42 字符,省 24 —— 同一条未经 +修改的守卫因此接受。旧 pin 给出的理由,正是新 pin 给出相反结论的理由。守卫的 +拒绝区间仍然有人占着,只是往下挪了一档(见上一节)。 diff --git a/content/docs/references/ai/conversation.mdx b/content/docs/references/ai/conversation.mdx index 979be312c8..442fe8e3f3 100644 --- a/content/docs/references/ai/conversation.mdx +++ b/content/docs/references/ai/conversation.mdx @@ -118,7 +118,7 @@ const result = CodeContentSchema.parse(data); | **context** | `{ sessionId: string; userId?: string; agentId?: string; object?: string; … }` | ✅ | | | **modelId** | `string` | optional | AI model ID | | **tokenBudget** | `{ maxTokens: integer; maxPromptTokens?: integer; maxCompletionTokens?: integer; reserveTokens: integer; … }` | ✅ | | -| **messages** | `{ id: string; timestamp: string; role: Enum<'system' \| 'user' \| 'assistant' \| 'function' \| 'tool'>; content: (object \| object \| object \| object)[]; … }[]` | ✅ | | +| **messages** | `{ id: string; timestamp: string; role: Enum<'system' \| 'user' \| 'assistant' \| 'function' \| 'tool'>; content: (object \| … +3 more)[]; … }[]` | ✅ | | | **tokens** | `{ promptTokens: integer; completionTokens: integer; totalTokens: integer; budgetLimit: integer; … }` | optional | | | **totalTokens** | `{ promptTokens: integer; completionTokens: integer; totalTokens: integer }` | optional | Total tokens across all messages | | **totalCost** | `number` | optional | Total cost for this session in USD | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4c6101e001..a3817db766 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -948,9 +948,9 @@ Get package response | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c49c98c332..7c7265f943 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -136,7 +136,7 @@ const result = ApiMethod.parse(data); | **titleFormat** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField. | | **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. | | **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) | | **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. | | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | diff --git a/content/docs/references/data/validation.mdx b/content/docs/references/data/validation.mdx index 059326a44a..bca2ff3029 100644 --- a/content/docs/references/data/validation.mdx +++ b/content/docs/references/data/validation.mdx @@ -115,8 +115,8 @@ const result = ConditionalValidationSchema.parse(data); | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | | **type** | `'conditional'` | ✅ | | | **when** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Predicate (CEL). e.g. P`record.type == 'enterprise'` | -| **then** | `{ name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| … +2 more` | ✅ | Validation rule to apply when condition is true | -| **otherwise** | `{ name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| { name: string; label?: string; description?: string; active?: boolean; … } \| … +2 more` | optional | Validation rule to apply when condition is false | +| **then** | `{ name: string; label?: string; description?: string; active?: boolean; … } \| [ConditionalValidation](#conditionalvalidation) \| … +4 more` | ✅ | Validation rule to apply when condition is true | +| **otherwise** | `{ name: string; label?: string; description?: string; active?: boolean; … } \| [ConditionalValidation](#conditionalvalidation) \| … +4 more` | optional | Validation rule to apply when condition is false | --- diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index f0f3ca8e3f..3b2ee358b1 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -44,7 +44,7 @@ const result = ManifestSchema.parse(data); | **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | | **capabilities** | `{ implements?: object[]; provides?: object[]; requires?: object[]; extensionPoints?: object[]; … }` | optional | Plugin capability declarations for interoperability | | **extensions** | `Record` | optional | Extension points and contributions | -| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| object \| object \| object \| … +5 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | | **loading** | `{ strategy?: Enum<'eager' \| 'lazy' \| 'parallel' \| 'deferred' \| 'on-demand'>; preload?: object; codeSplitting?: object; dynamicImport?: object; … }` | optional | Plugin loading and runtime behavior configuration | | **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | | **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index c5b94429ad..d19084e726 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -73,7 +73,7 @@ const result = ActionNavItemSchema.parse(data); | **active** | `boolean` | optional | Whether the app is enabled | | **isDefault** | `boolean` | optional | Is default app | | **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| … +5 more)[]` | optional | Full navigation tree for the app sidebar | +| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | | **homePageId** | `never` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | @@ -204,7 +204,7 @@ const result = ActionNavItemSchema.parse(data); | **label** | `string \| Record` | ✅ | Area display label | | **icon** | `string` | optional | Area icon name | | **description** | `string \| Record` | optional | Area description | -| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| … +5 more)[]` | ✅ | Navigation items within this area | +| **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items within this area | --- @@ -220,7 +220,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level | | **priority** | `integer` | optional | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| { id: string; label: string \| Record; icon?: string; order?: number; … } \| … +5 more)[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | --- diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index f09a883329..8a6feaf536 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -748,9 +748,9 @@ End-user quick-filter configuration (Airtable "User filters" parity) | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | optional | Human-readable label shown in metadata lists. | | **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. | -| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | | | **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | | -| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | +| **listViews** | `Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) | | **formViews** | `Record; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -869,7 +869,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -935,7 +935,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| object \| object \| object; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts index baa14e7264..e9309c4769 100644 --- a/packages/spec/scripts/format-type.test.ts +++ b/packages/spec/scripts/format-type.test.ts @@ -1154,13 +1154,24 @@ describe('formatPropertyType — a property that IS a vocabulary relocates it (# * four reds being precisely the marker cases, and no other block moved. */ describe('formatType — a union spells four variants and counts the rest (#6226)', () => { + /** + * REPLACED FIXTURE (#6569). The distinguishing key used to be the FIFTH one, + * i.e. behind `INLINE_KEY_LIMIT`, so every variant this helper built rendered + * to the same string. That was faithful to `App.navigation` and it is why + * `… +5 more` used to be reachable here — but once identical spellings + * collapse, such a fixture exercises the DEDUPE and not the cap, and every + * case below would be asserting the wrong rule. Moving the unique key inside + * the limit makes each variant render distinctly, so this block pins the + * ARITY CAP alone. The identical-spelling reality of `App.navigation` did not + * disappear with the fixture: it is pinned, faithfully, in the #6569 block. + */ const variant = (id: string) => ({ type: 'object', - properties: { id: { type: 'string' }, label: { type: 'string' }, icon: { type: 'string' }, order: { type: 'number' }, [id]: { type: 'string' } }, + properties: { id: { type: 'string' }, label: { type: 'string' }, [id]: { type: 'string' }, order: { type: 'number' }, icon: { type: 'string' } }, required: ['id', 'label'], }); - it('renders the `App.navigation` cell as four variants plus a count (the filed instance)', () => { + it('renders a nine-variant array cell as four variants plus a count', () => { const rendered = formatType( { type: 'array', items: { anyOf: Array.from({ length: 9 }, (_, i) => variant(`k${i}`)) } }, ctx(), @@ -1195,12 +1206,24 @@ describe('formatType — a union spells four variants and counts the rest (#6226 }); it('does NOT elide five tiny variants — the marker would cost more than it saves', () => { - // `string | string | string | string | string` is 42 characters; capping it + // `string | number | boolean | null | 'x'` is 38 characters; capping it // would print 33 + a 12-character marker footprint and come out LONGER. The // pay-for-your-marker guard is shared with the enum elisions, so a count // never replaces a spelling that was already shorter than the count. - const rendered = formatType({ anyOf: Array.from({ length: 5 }, () => ({ type: 'string' })) }, ctx()); - expect(rendered).toBe('string | string | string | string | string'); + // + // REPLACED FIXTURE (#6569). This case used to build FIVE IDENTICAL + // `string` variants, and that spelling now collapses to `string | … +4 + // more` — 24 characters saved, so the same guard that refused there accepts + // here. The flip is the contract change #6569 makes and is pinned as such + // in its own block; what this case is for is the guard's refusal on the + // CAP, so it needs five variants the cap can actually be asked about, i.e. + // five DISTINCT ones. Same arity, same guard, same verdict — the fixture + // just stopped answering a different question than its name. + const rendered = formatType( + { anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }, { type: 'null' }, { const: 'x' }] }, + ctx(), + ); + expect(rendered).toBe("string | number | boolean | null | 'x'"); expect(rendered).not.toContain('more'); }); @@ -1209,14 +1232,15 @@ describe('formatType — a union spells four variants and counts the rest (#6226 expect(rendered).toContain('… +5 more'); }); - it('caps a union nested inside a shape summary too — the `Manifest.navigationContributions` cell', () => { - // REPLACED FIXTURE (#6374). This case used to build six OBJECT variants - // under a key and assert `… +2 more`. Below a summary those six now all - // render `object`, and six six-character spellings are narrower than the - // marker that would replace two of them, so the shared pay-for-your-marker - // guard refuses — correctly, and the case would have been asserting the - // guard rather than the cap. The corpus keeps exactly one cell where a - // nested union is still wide enough for the cap to pay, and this is it. + it('counts a union nested inside a shape summary too — the `Manifest.navigationContributions` cell', () => { + // REPLACED EXPECTATION (#6569), on the fixture #6374 already replaced once. + // Below a summary all nine variants render `object`, so after #6569 it is + // the DEDUPE and not the cap that decides this cell: one spelling survives + // and the marker counts the other eight. Kept in this block, and kept + // pointing at the corpus cell it is named for, because the two rules share + // one marker and this is where a reader checks that they compose — but read + // it as the composition, not as the cap: no corpus cell is decided by the + // nested cap alone any more. const rendered = formatType( { type: 'object', @@ -1228,11 +1252,12 @@ describe('formatType — a union spells four variants and counts the rest (#6226 }, ctx(), ); - expect(rendered).toBe('{ app: string; items: (object | object | object | object | … +5 more)[] }'); - // 4 shown + 5 hidden = the 9 the schema declares, below a summary exactly as - // above one: the budget changes what a variant SPELLS, never the arity the - // cap reports. - expect(4 + 5).toBe(9); + expect(rendered).toBe('{ app: string; items: (object | … +8 more)[] }'); + // 1 spelled + 8 counted = the 9 the schema declares, below a summary exactly + // as above one: the budget changes what a variant SPELLS and the dedupe + // changes how many spellings are worth printing, but neither changes the + // arity the marker reports. + expect(1 + 8).toBe(9); }); }); @@ -1442,18 +1467,20 @@ describe('formatType — one shape level, whichever way down (#6374)', () => { ); }); - it('prints an identical variant once per variant — the budget may not drop the arity', () => { - // Six object variants below a summary all print `object`, and the cell says - // so six times. It reads oddly and it is deliberate: #6226 ruled that a - // union elision must SELF-REPORT what it hid, and the shared - // pay-for-your-marker guard refuses a marker here because six six-character - // spellings are narrower than the count that would replace two of them. - // Collapsing them to one `object` would drop the arity — the one fact the - // cell still carries — and would re-decide #6226 on a surface the - // maintainer has just ruled on. The renderer already ships exactly this - // shape for scalars (`string | string | string | string | string`, pinned - // in the #6226 block), so this is that pinned behaviour meeting a new - // spelling, not a new behaviour. Filed for the maintainer as a finding. + it('spells an identical variant once and counts the rest — the arity survives the collapse', () => { + // RULED, and the pin turned over (#6569). #6374 shipped this cell printing + // `object` six times and pinned it that way ON PURPOSE, with the question + // filed for the maintainer rather than answered by the implementer: the + // repetition read as a bug but it carried the ARITY, and dropping arity + // would have re-decided #6226 from inside a depth-budget PR. + // + // The ruling keeps both halves. The spelling collapses (a second `object` + // tells a reader nothing the first did not) and the count is restored in + // #6226's own `… +N more` marker rather than in a new multiplicity + // notation, so the cell still says "choose one of six shapes" and the table + // still has exactly one way of saying "there is more". The budget above + // decides what a variant SPELLS; this decides how many spellings are worth + // printing; the marker reports the arity either way. const rendered = formatType( { type: 'object', @@ -1468,7 +1495,227 @@ describe('formatType — one shape level, whichever way down (#6374)', () => { }, ctx(), ); - expect(rendered).toBe('{ slot?: object | object | object | object | object | object }'); - expect(rendered).not.toContain('more'); + expect(rendered).toBe('{ slot?: object | … +5 more }'); + // 1 spelled + 5 counted = the six the schema declares. + expect(1 + 5).toBe(6); + }); +}); + +/** + * A cell spells each DISTINCT variant rendering once and counts the rest — + * #6569, ruled after #6374 shipped the repetition and pinned it as the open + * question. + * + * The condition is #6374's: with `SHAPE_DEPTH_LIMIT` in force, an all-object + * union below a summary prints `object` per variant, so 11 cells on 6 shipped + * reference pages rendered `object | object | object | object` and + * `kernel/manifest.mdx` spelled four of them before counting `… +5 more` + * identical ones. The ruling threads between the options the issue laid out: + * COLLAPSE the repeated spelling (it carries nothing the first copy did not), + * but SELF-REPORT the arity (#6226: an omission must state its size), in the + * marker vocabulary the table ALREADY has (#6226 again: a second omission style + * in one table is worse than the width it would fix). + * + * REVERSE VERIFICATION — predicted before running, by deleting the dedupe and + * restoring `if (rendered.length <= VARIANT_LIMIT) return full;` with the + * `rendered.slice(0, VARIANT_LIMIT)` call under it. The direction is NOT + * uniformly red, and the two greens are the point: + * RED — the four cases whose subject IS the collapse (the `App.navigation` + * cell, the filed 4-object cell, the flipped #6226 scalar pin, the + * arity invariant), plus 'four repeats is the first width that pays' + * and the no-new-notation case. Plus, outside this block, exactly two + * more: the `Manifest.navigationContributions` cell in the #6226 block + * and the identical-variant pin in the #6374 block. + * GREEN — 'refuses the marker while the repetition is narrower than the + * count' (two and three repeats are under `VARIANT_LIMIT`, so the + * baseline prints them verbatim for its own reason and the guard + * prints them verbatim for this one — the rule's FLOOR does not move + * in either direction, which is why it is asserted separately from + * the width where it starts paying), and 'two spellings that differ' + * (the dedupe is a no-op on distinct variants, so `Page.slots` must + * be untouched by construction). + * GREEN — and the whole #6226 cap block, all seven cases: after its fixture + * was re-spelled to render DISTINCT variants, the dedupe is a no-op + * on every one of them. A red there would mean the cap block is still + * answering the dedupe's question instead of the cap's. + * Predicted split: 8 red / 2 green in this file's dedupe-sensitive cases, with + * the #6226 cap block green throughout. + * + * ACTUAL: **7 failed | 79 passed**, i.e. 7 red / 3 green — the seven reds are + * exactly the seven named above, and the #6226 cap block held green throughout + * as predicted (its one red is the composition case, which was predicted red). + * ONE MISS, recorded because it says something about the assertion rather than + * about the code: the ARITY INVARIANT case was predicted red and stayed GREEN. + * It cannot go red — `shown + hidden = arity` is satisfied by the BASELINE + * renderer too (the cap reports its own hidden count, and at arity 6 the guard + * refuses so all six are shown and nothing is hidden). It is a property both + * rules obey, so it pins that the ruling PRESERVED the invariant rather than + * discriminating that the dedupe exists. Kept deliberately, and re-classified + * here into the green set: it is the regression test for the one thing option + * B of the issue would have broken, and a case that would go red if a future + * change made the marker count spellings instead of variants. + */ +describe('formatType — identical variant spellings collapse into the count (#6569)', () => { + /** A nav item: five keys, so the fifth is elided and every item renders alike. */ + const navItem = (i: number) => ({ + type: 'object', + properties: { id: { type: 'string' }, label: { type: 'string' }, icon: { type: 'string' }, order: { type: 'number' }, [`k${i}`]: { type: 'string' } }, + required: ['id', 'label'], + }); + const SEPARATOR = { + type: 'object', + properties: { type: { const: 'separator' }, id: { type: 'string' }, order: { type: 'number' } }, + required: ['type'], + }; + /** Two keys, so it renders `object` below a summary and `{ … }` above one. */ + const objectVariant = (i: number) => ({ + type: 'object', + properties: { id: { type: 'string' }, [`k${i}`]: { type: 'string' } }, + }); + const NAV_ITEM_CELL = '{ id: string; label: string; icon?: string; order?: number; … }'; + + it('collapses a NON-adjacent repeat too — the real `App.navigation` variant order', () => { + // The corpus shape, measured: nine variants, of which eight render to one + // character-identical nav-item spelling and one is the separator — and the + // separator sits SEVENTH, between the run and the eighth copy. This is why + // equality is on the rendered string and not on adjacency: a `uniq`-style + // adjacent rule collapses the run, then meets the separator, then prints + // the nav-item shape A SECOND TIME — i.e. it leaves the repetition in the + // one cell #6226 was filed on. It is the only corpus site where the two + // rules differ (3 of 554 union renderings, this cell on its three pages). + const rendered = formatType( + { + type: 'array', + items: { anyOf: [...Array.from({ length: 7 }, (_, i) => navItem(i)), SEPARATOR, navItem(7)] }, + }, + ctx(), + ); + expect(rendered).toBe( + `(${NAV_ITEM_CELL} | { type: 'separator'; id?: string; order?: number } | … +7 more)[]`, + ); + // The load-bearing half: the spelling appears ONCE, not once per run. + expect(rendered.split(NAV_ITEM_CELL)).toHaveLength(2); + // 2 spelled + 7 counted = the nine the schema declares. 582 → 132 chars. + expect(2 + 7).toBe(9); + expect(rendered.length).toBe(132); + }); + + it('collapses the four-object cells the issue was filed on', () => { + // `ai/conversation.mdx`'s `ConversationSession.messages` — the `content` + // key is an array whose element is a union of four object variants, all + // four printing `object` because they sit below the summary's one shape + // level. Seven more cells (`ui/view.mdx`, `data/object.mdx`, + // `api/protocol.mdx`) carry the same union under `data?:`. + expect( + formatType( + { + type: 'object', + properties: { + role: { type: 'string' }, + content: { type: 'array', items: { anyOf: Array.from({ length: 4 }, (_, i) => objectVariant(i)) } }, + }, + required: ['role', 'content'], + }, + ctx(), + ), + ).toBe('{ role: string; content: (object | … +3 more)[] }'); + }); + + it('flips #6226’s five-identical-scalar pin — the count now pays for itself', () => { + // A PIN THAT CHANGES IS A CONTRACT THAT CHANGES, so it is asserted here + // rather than quietly re-spelled. #6226 pinned + // `string | string | string | string | string` verbatim, and its stated + // reason was arithmetic, not principle: capping five variants prints four + // of them plus a 12-character marker footprint and comes out LONGER, so the + // shared guard refused. The dedupe changes the candidate, not the guard — + // ONE spelling plus `… +4 more` is 18 characters against 42, a 24-character + // saving — so the same guard, unmodified, now accepts. The reason the old + // pin gave for its verdict is exactly the reason the new one gives for the + // opposite verdict; that is what makes this a re-measurement rather than a + // weakening. The guard's refusal band is still occupied, one width down — + // see the next case. + expect(formatType({ anyOf: Array.from({ length: 5 }, () => ({ type: 'string' })) }, ctx())).toBe( + 'string | … +4 more', + ); + }); + + it('refuses the marker while the repetition is narrower than the count', () => { + // NO EXEMPTION for the pay-for-your-marker guard, and no special case to + // make the output prettier: a marker still has to earn its own footprint. + // Two `object`s would GROW by 3 characters and three save 6 against a + // 12-character footprint, so both keep every repeat. Deliberately green in + // both directions — see the block header. + expect( + formatType({ type: 'object', properties: { slot: { anyOf: [objectVariant(0), objectVariant(1)] } } }, ctx()), + ).toBe('{ slot?: object | object }'); + expect( + formatType( + { type: 'object', properties: { slot: { anyOf: Array.from({ length: 3 }, (_, i) => objectVariant(i)) } } }, + ctx(), + ), + ).toBe('{ slot?: object | object | object }'); + }); + + it('starts paying at four repeats — the guard’s boundary, measured', () => { + // Four is the first repeat count where the count is cheaper than the + // spellings (33 → 18, a 15-character saving against a 12-character + // footprint), and it happens to be exactly where the corpus cells sit. The + // boundary is the guard's, not a threshold of this rule's own: nothing in + // the dedupe knows about the number four. + expect( + formatType( + { type: 'object', properties: { slot: { anyOf: Array.from({ length: 4 }, (_, i) => objectVariant(i)) } } }, + ctx(), + ), + ).toBe('{ slot?: object | … +3 more }'); + }); + + it('never collapses two spellings that DIFFER — `Page.slots` is untouched', () => { + // `PageComponent | PageComponent[]` renders `object | object[]`: two + // spellings, however alike the schemas behind them. The rule judges what a + // reader sees. Deliberately green in both directions — the dedupe must be a + // no-op here or it is deleting information rather than repetition. + expect( + formatType( + { + type: 'object', + properties: { header: { anyOf: [objectVariant(0), { type: 'array', items: objectVariant(0) }] } }, + }, + ctx(), + ), + ).toBe('{ header?: object | object[] }'); + }); + + it('keeps “spellings shown + the count = the arity” true however a variant was withheld', () => { + // The one sentence a reader needs. The cap withholds variants because there + // are too many worth reading; the dedupe withholds them because the cell + // already prints their spelling; both report into the SAME marker, so the + // sum has to come out at the schema's arity in both cases and in the case + // where they compose. + for (const arity of [4, 5, 6, 9, 12]) { + const rendered = formatType( + { + type: 'object', + properties: { slot: { anyOf: Array.from({ length: arity }, (_, i) => objectVariant(i)) } }, + }, + ctx(), + ); + const hidden = Number(/… \+(\d+) more/.exec(rendered)?.[1] ?? 0); + const shown = rendered.slice('{ slot?: '.length, -' }'.length).split(' | ').filter(v => !v.startsWith('…')).length; + expect(shown + hidden).toBe(arity); + } + }); + + it('reports the count in the EXISTING marker — no multiplicity notation is invented', () => { + // #6226's ruling turned on there being one way to say "there is more" in a + // table. `object ×4` (or `object (4)`, or a superscript) would be the + // fourth omission style on a page that already carries three uses of one. + const rendered = formatType( + { type: 'object', properties: { slot: { anyOf: Array.from({ length: 6 }, (_, i) => objectVariant(i)) } } }, + ctx(), + ); + expect(rendered).toMatch(/… \+\d+ more/); + expect(rendered).not.toMatch(/[×*]\s*\d/); + expect(rendered).not.toMatch(/\(\s*\d+\s*\)/); }); }); diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts index 2819662949..201666f5ad 100644 --- a/packages/spec/scripts/lib/format-type.ts +++ b/packages/spec/scripts/lib/format-type.ts @@ -218,8 +218,12 @@ const TOP_LEVEL_ENUM_WIDTH_LIMIT = 160; * A union's width is variant COUNT times variant WIDTH, so neither enum budget * above can reach it: `ui/app.mdx`'s `App.navigation` prints * `{ id: string; label: string; icon?: string; order?: number; … }` nine times, - * seven of them character-identical, for 582 characters of correct-but-repeated - * type. The maintainer's ruling on #6226 chose a cap on the count that prints + * eight of the nine being one character-identical spelling (the ninth is + * `{ type: 'separator'; … }`), for 582 characters of correct-but-repeated + * type. (The repetition itself is #6569's subject, ruled after this cap landed + * and handled by the dedupe at the call site; this constant governs only how + * many DISTINCT spellings are worth reading.) The ruling on #6226 chose a cap + * on the count that prints * how many variants it hid, over a whole-cell character budget degrading to * `object` and over restoring `$ref` links: those lose more information, and a * SECOND elision style in the same table is worse than the width it would fix. @@ -420,8 +424,9 @@ function elideEnum(values: unknown[], budget: number | null): { body: string; hi * * Shared by all three elisions (#6225/#6226 reuse what #5340 measured): the * enum-body budget, the top-level vocabulary relocation, and the `anyOf` - * variant cap. They differ in what they count, never in whether a marker is - * worth printing. + * variant cap — which since #6569 also counts variants withheld for spelling a + * string the cell already prints. They differ in what they count, never in + * whether a marker is worth printing. * * Re-measured on the corpus with both new limits in place, by regenerating with * this guard forced to return `elided` unconditionally: it refuses **54** of the @@ -544,14 +549,86 @@ function renderType(prop: any, ctx: TypeContext | undefined, depth: number): str if (prop.anyOf || prop.oneOf) { const variants = prop.anyOf || prop.oneOf; - const rendered = variants.map((v: any) => renderType(v, ctx, depth)); + const rendered: string[] = variants.map((v: any) => renderType(v, ctx, depth)); const full = rendered.join(' | '); - if (rendered.length <= VARIANT_LIMIT) return full; + + // A cell spells each DISTINCT variant rendering once, and counts every + // variant it did not spell — repeats included (#6569). + // + // Two independent facts decide what a union cell shows, and this is one + // expression of both. `VARIANT_LIMIT` caps how many spellings are worth + // reading; the dedupe drops spellings that carry nothing because the cell + // already prints them character for character. They compose into a single + // marker rather than stacking two elisions, so the invariant a reader can + // rely on stays one sentence: SPELLINGS SHOWN + THE COUNT = THE UNION'S + // ARITY, whichever rule withheld a variant. + // + // Identical spellings are not new here, but #6374 made them common: with + // `SHAPE_DEPTH_LIMIT` in force every all-object union below a summary + // prints `object` per variant, so `object | object | object | object` + // appeared on 9 cells across 5 shipped reference pages, and + // `kernel/manifest.mdx`'s `Manifest.navigationContributions` spelled four + // of them and then counted `… +5 more` identical ones behind them. Those + // pages are the authoritative input for AI authors (ADR-0033), where a + // cell that reads as a rendering bug costs more than a wide one. + // + // 9 and not the 11 #6569 was filed with, because a SUBSTRING count of + // `object | object` also matches the two cells that spell + // `object | object[]` (`ui/page.mdx`'s `slots`, + // `automation/state-machine.mdx`'s `states`) — two spellings, which the + // rule below deliberately never collapses. Worth knowing before grepping: + // re-deriving 11 that way and concluding the dedupe missed two cells is + // the wrong conclusion from a correct observation. Counting all duplicate + // spellings rather than only `object` ones, the rule moves 14 cells on 7 + // pages; the extra 5 repeat a SHAPE spelling (`ui/app.mdx`, + // `data/validation.mdx`). + // + // WHY THE COUNT STAYS. Collapsing to a bare `object` would drop the union's + // ARITY, which after the depth budget is the only fact that cell still + // carries — "choose one of N shapes" degraded to "some object". #6226 ruled + // that a union elision must self-report what it hid; a dedupe that reports + // nothing re-decides that ruling by the back door. + // + // WHY NO NEW NOTATION. The count is `… +N more`, the marker the table + // already uses for elided enum members (#5340), relocated vocabularies + // (#6225) and capped variants (#6226) — not a multiplicity sigil like + // `object ×4`. #6226's ruling turned on exactly this: a SECOND omission + // style in one table is worse than the width it would fix. `N` counts + // variants-not-spelled in every one of the four positions, so the marker + // keeps meaning one thing. + // + // WHY EQUALITY IS ON THE RENDERED STRING, and why it is not restricted to + // ADJACENT runs. This renderer judges what a reader sees, so two variants + // are interchangeable here exactly when their cells are byte-identical — + // `object | object[]` (`ui/page.mdx`'s `Page.slots`) is two spellings and + // never collapses, however alike the schemas are. Adjacency was measured + // and rejected: `App.navigation` — #6226's own filed instance — renders + // seven identical nav-item shapes, then `{ type: 'separator'; … }`, then an + // EIGHTH copy of the nav-item shape. A `uniq`-style adjacent rule leaves + // that eighth copy spelled a second time, i.e. it leaves the defect in the + // one cell the family was filed on. It is the only corpus site where the + // two rules differ (3 of 554 union renderings, all three that cell on its + // three pages). + // + // THE PAY-FOR-YOUR-MARKER GUARD APPLIES UNCHANGED — not exempted, and its + // refusals are correct. Under it a run of identical spellings only + // collapses once the count is cheaper than the repetition: `object | + // object` would GROW by 3 characters and `object | object | object` saves + // 6 against a 12-character marker footprint, so both keep their repeats; + // four is the first width at which `object` repeats pay (15 saved). + // Measured on the corpus: 554 union renderings, 14 carry a duplicate + // spelling, and the guard accepts all 14 — no corpus cell sits in the + // refusal band today, which is why the refusal is pinned by unit test + // rather than by a page. + const spelled = [...new Set(rendered)]; + const shown = spelled.slice(0, VARIANT_LIMIT); + const hidden = rendered.length - shown.length; + if (hidden === 0) return full; // The variants a reader does not see are counted, never silently dropped — // the principle #5340 established for enum members, applied to the other // axis a cell grows along (#6226). `elideWithMarker` keeps the count from // costing more than the spellings it replaces. - const elided = elideWithMarker(rendered.slice(0, VARIANT_LIMIT), rendered.length - VARIANT_LIMIT, full.length); + const elided = elideWithMarker(shown, hidden, full.length); return elided ?? full; }