diff --git a/.changeset/gen-docs-passthrough-declared-shape.md b/.changeset/gen-docs-passthrough-declared-shape.md new file mode 100644 index 0000000000..e311df3b2b --- /dev/null +++ b/.changeset/gen-docs-passthrough-declared-shape.md @@ -0,0 +1,29 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): 参考页不再把「已声明键 + passthrough」的对象塌缩成 `Record` (#4912) + +`gen:docs` 的类型渲染器先判 `additionalProperties`、后判 `properties`,而 JSON Schema 把 +`.passthrough()` / `.catchall()` 对象**同时**表达为这两者 —— 于是每个「有形状、又开放」的 +对象在参考页上都被渲染成一个光秃秃的 `Record< string, any >`,**已声明的键被整个抹掉**。 +`BulkActionParam.options` 是立案时的样本:它的 `label` / `value` 是**必填**的,页面却显示 +「无形状」。PR #4909 当时是在该键的 `.describe()` 散文里手工补偿的,那是逐点补偿,不是修复。 + +声明键与开放性是**两个独立的事实**,现在分别呈现: + +- 之前:`Record< string, any >[]` +- 之后:`({ label: string; value: string | number | boolean } & Record< string, any >)[]` + +数组元素上的括号是必需的 —— `A & B[]` 在 TypeScript 里是 `A & (B[])`,不加括号等于声明了 +另一种类型。已声明键超过四个时仍然省略,`…`(还有更多**已声明**键)与 +`& Record< string, any >`(还接受**未声明**键)是两件不同的事,单元格两者都印。 + +本次重生成影响 6 张参考页共 12 个单元格,全部是恢复被抹掉的声明键,没有任何一页丢失形状: +`ui/bulk-action`(`params`、`options`)、`ui/view`(`gantt`、`tree`,ListView 与 +ObjectListView 各一份)、`ui/dashboard`(widget `options`)、`api/protocol`(三处 AI +`messages`)、`system/auth-config`(`socialProviders`)、`kernel/startup-orchestrator` +(`plugin`)。 + +渲染逻辑从 `build-docs.ts` 抽到 `scripts/lib/format-type.ts` 并配了单测:此前要断言它的输出 +只能跑完整个生成器再 grep `.mdx`,这正是该塌缩能在整个 #4001 战役期间无人察觉的原因。 diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4272829503..8bb091afc7 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -49,7 +49,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **messages** | `Record[]` | ✅ | Conversation messages (at least one) | +| **messages** | `({ role: Enum<'system' \| 'user' \| 'assistant' \| 'tool'>; content?: any; parts?: any[] } & Record)[]` | ✅ | Conversation messages (at least one) | | **context** | `Record` | optional | Agent context (app, object, record, …) | | **options** | `Record` | optional | Request options (model, temperature, …) | @@ -87,7 +87,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **messages** | `Record[]` | ✅ | Conversation messages (at least one) | +| **messages** | `({ role: Enum<'system' \| 'user' \| 'assistant' \| 'tool'>; content?: any; parts?: any[] } & Record)[]` | ✅ | Conversation messages (at least one) | | **system** | `string` | optional | System prompt, prepended as a system message | | **model** | `string` | optional | Model id override | | **temperature** | `number` | optional | Sampling temperature | @@ -137,7 +137,7 @@ const result = AiAgentCapabilitiesSchema.parse(data); | **title** | `string` | optional | Title / summary | | **agentId** | `string` | optional | Agent this conversation is bound to | | **userId** | `string` | optional | Owning user | -| **messages** | `Record[]` | ✅ | Message history | +| **messages** | `({ role: Enum<'system' \| 'user' \| 'assistant' \| 'tool'>; content?: any; parts?: any[] } & Record)[]` | ✅ | Message history | | **createdAt** | `string` | ✅ | Creation timestamp (ISO 8601) | | **updatedAt** | `string` | ✅ | Last update timestamp (ISO 8601) | | **metadata** | `Record` | optional | Conversation metadata | diff --git a/content/docs/references/kernel/startup-orchestrator.mdx b/content/docs/references/kernel/startup-orchestrator.mdx index adecf825bb..7e9c832aa5 100644 --- a/content/docs/references/kernel/startup-orchestrator.mdx +++ b/content/docs/references/kernel/startup-orchestrator.mdx @@ -51,7 +51,7 @@ const result = HealthStatusSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **plugin** | `Record` | ✅ | Plugin metadata | +| **plugin** | `{ name: string; version?: string } & Record` | ✅ | Plugin metadata | | **success** | `boolean` | ✅ | Whether the plugin started successfully | | **duration** | `number` | ✅ | Time taken to start the plugin in milliseconds | | **error** | `{ name: string; message: string; stack?: string; code?: string }` | optional | Serializable error representation if startup failed | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index efb1370632..02ad984df3 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -57,7 +57,7 @@ Advanced / low-level Better-Auth options | **plugins** | `{ organization: boolean; twoFactor: boolean; passkeys: boolean; passwordRejectBreached: boolean; … }` | optional | | | **session** | `{ expiresIn: number; updateAge: number }` | optional | | | **trustedOrigins** | `string[]` | optional | Trusted origins for CSRF protection. Supports wildcards (e.g. "https://*.example.com"). The baseUrl origin is always trusted implicitly. | -| **socialProviders** | `Record>` | optional | Social/OAuth provider map forwarded to better-auth socialProviders. Keys are provider ids (google, github, apple, …). | +| **socialProviders** | `Record>` | optional | Social/OAuth provider map forwarded to better-auth socialProviders. Keys are provider ids (google, github, apple, …). | | **oidcProviders** | `{ providerId: string; name?: string; discoveryUrl?: string; issuer?: string; … }[]` | optional | List of OIDC/OAuth2 providers for enterprise SSO. Product or enterprise packages can pass this directly or contribute it through auth:configure. | | **emailAndPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean; minPasswordLength?: number; … }` | optional | Email and password authentication options forwarded to better-auth | | **emailVerification** | `{ sendOnSignUp?: boolean; sendOnSignIn?: boolean; autoSignInAfterVerification?: boolean; expiresIn?: number }` | optional | Email verification options forwarded to better-auth | diff --git a/content/docs/references/ui/bulk-action.mdx b/content/docs/references/ui/bulk-action.mdx index 928ccf770e..4950b64778 100644 --- a/content/docs/references/ui/bulk-action.mdx +++ b/content/docs/references/ui/bulk-action.mdx @@ -52,7 +52,7 @@ const result = BulkActionDefSchema.parse(data); | **operation** | `Enum<'update' \| 'delete' \| 'custom'>` | ✅ | What the executor does: 'update'/'delete' are data-plane mass mutations; 'custom' dispatches an object action (see `execution`). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | For `operation: 'custom'` — 'aggregate' dispatches the named action ONCE for the whole selection, carrying every id in `params._selectedIds` (objectui#3139). Required on a custom def: the per-record form is declared as `bulkActions: ['']` instead. | | **patch** | `Record` | optional | For `operation: 'update'` — static field values applied to every selected record, merged UNDER the user-supplied params so a fixed value can be declared without exposing it in the dialog. | -| **params** | `Record[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | +| **params** | `({ name: string; label?: string; help?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; … } & Record)[]` | optional | Inputs collected once before the run. Omit to skip the params step and go straight to confirm. | | **confirmText** | `string` | optional | Confirmation text shown above the affected-record summary. | | **confirmLabel** | `string` | optional | Custom Confirm button label (default: "Run"). | | **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Eligibility predicate (CEL), same shape as `action.visible`. Evaluated once PER SELECTED RECORD with that record bound: the button is offered when at least one passes, the run covers only those, and the rest are reported as skipped. A record-free predicate (`features.x`, `current_user.y`) therefore behaves as a plain button-level gate. Fail-closed — a predicate that faults excludes the record. | @@ -95,7 +95,7 @@ const result = BulkActionDefSchema.parse(data); | **type** | `Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>` | ✅ | Field widget to render, from the standard field-type vocabulary (text/number/select/lookup/date/…). | | **required** | `boolean` | optional | Blocks the Confirm button until a value is present. | | **default** | `any` | optional | Value applied when the dialog opens. (An ActionParam spells this `defaultValue`.) | -| **options** | `Record[]` | optional | Static options for select-style widgets. Each entry is `{ label, value }` plus any extra widget config — the entry is open (`.passthrough()`) because the renderer forwards unknown option keys to the field widget, which reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared pair. | +| **options** | `({ label: string; value: string \| number \| boolean } & Record)[]` | optional | Static options for select-style widgets. Each entry is `{ label, value }` plus any extra widget config — the entry is open (`.passthrough()`) because the renderer forwards unknown option keys to the field widget, which reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared pair. | | **object** | `string` | optional | Target object for a `lookup` widget. (An ActionParam spells this `reference`.) | | **labelField** | `string` | optional | Related-object field used as the option label for a `lookup` widget (defaults to name/full_name/email/id). | | **multiple** | `boolean` | optional | Allow picking multiple values — the param value becomes an array and is written to the patch as-is. | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 0a6a6df686..9a84bba575 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -107,7 +107,7 @@ Dashboard header action | **dimensions** | `string[]` | optional | Dimension names — X/group/split | | **values** | `string[]` | ✅ | Measure names — Y (at least one) | | **layout** | `{ x: number; y: number; w: number; h: number }` | optional | Grid layout position (auto-flowed when omitted) | -| **options** | `Record` | optional | Widget specific configuration | +| **options** | `{ dateGranularity?: Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>; sortBy?: string; sortOrder?: Enum<'asc' \| 'desc'>; limit?: integer; … } & Record` | optional | Widget specific configuration | | **filterBindings** | `Record` | optional | Per-widget dashboard-filter bindings: filter name → this widget's field, or false to opt out | | **suppressWarnings** | `string[]` | optional | Build diagnostic rule ids suppressed on this widget | | **responsive** | `any` | optional | [REMOVED] `dashboard.widgets[].responsive` was removed in @objectstack/spec 17.0.0 (#4876, ADR-0049 D2) — no renderer ever read it, so per-widget breakpoint overrides were never applied: the value parsed, validated, and then did nothing. The dashboard grid reflows by its own layout rules (`columns` + `gap` on the dashboard, the `layout` box on each widget). Delete the key. The shared `ResponsiveConfig` shape is NOT gone — it stays live on `page.components[].responsive`, which objectui `useResponsiveConfig` really does read; move the layout there if you need breakpoint behaviour today. Run `os migrate meta --from 16` to rewrite it automatically. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e25e72c547..5a3467fc41 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -385,11 +385,11 @@ List chart view configuration | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | -| **tree** | `Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | @@ -473,11 +473,11 @@ List chart view configuration | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField: string; colorField?: string }` | optional | Calendar configuration — applies when the view renders as a calendar layout | -| **gantt** | `Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | +| **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … } & Record` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | | **timeline** | `{ startDateField: string; endDateField?: string; titleField: string; groupByField?: string; … }` | optional | Timeline view configuration | | **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration | -| **tree** | `Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | +| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer } & Record` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **description** | `string` | optional | View description for documentation/tooltips | | **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 6d4f7cc46b..8ee4b31897 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -26,6 +26,7 @@ import { resolveImports, type CategorySurface, } from './lib/docs-import-surface'; +import { anchorFor, formatType, type TypeContext } from './lib/format-type'; import { createSink } from './lib/generated-output'; import { schemaNameFromExportKey } from './lib/schema-name'; @@ -180,33 +181,6 @@ const IMPORT_BASELINE_COMMENT = 'shows up as this file in the diff. Regenerate with: ' + 'tsx scripts/build-docs.ts --update-import-baseline (after gen:schema).'; -/** - * Context a page needs to turn a `$ref` into a link that actually resolves. - * - * Pages are named after the *zod file* (`data/object.mdx`) while refs name a - * *schema* (`Field`), so a ref can only be linked by looking the schema name up - * in the maps built by scanCategories(). Anonymous refs (`__schemaN`, emitted - * when Zod hoists a reused inline schema into `$defs`) have no page at all and - * are rendered structurally instead. - */ -interface TypeContext { - /** `$defs` of the document being rendered — for resolving local refs. */ - defs: Record; - /** The schema whose section is being rendered — target of a self `$ref` (`"#"`). */ - currentSchema: string; - /** - * Anonymous refs already being expanded on this branch. Schemas are cyclic - * (a node contains nodes), so inlining without this recurses forever. - */ - expanding?: Set; -} - -const refName = (ref: string): string => ref.split('/').pop() || ref; -const isAnonymousRef = (name: string) => /^__schema\d+$/.test(name); - -/** A page-local anchor, matching how fumadocs slugs the `## SchemaName` heading. */ -const anchorFor = (schemaName: string) => `#${schemaName.toLowerCase()}`; - /** * Resolve a schema name to its page. Returns null when the schema isn't one we * generate a page for — callers then render the type without a link rather than @@ -219,80 +193,6 @@ function schemaHref(name: string): string | null { return `/docs/references/${category}/${zodFile}${anchorFor(name)}`; } -// Helpers to format types -function formatType(prop: any, ctx?: TypeContext): string { - if (!prop) return 'any'; - - if (prop.$ref) { - // Self-reference: link to the current section rather than a bare `#`. - if (prop.$ref === '#') { - return ctx ? `[${ctx.currentSchema}](${anchorFor(ctx.currentSchema)})` : 'object'; - } - - const name = refName(prop.$ref); - - // Zod-hoisted inline schema: no page exists. Render its shape instead. - if (isAnonymousRef(name)) { - const target = ctx?.defs?.[name]; - if (!target) return 'object'; - // Cycle guard: these schemas are recursive (a node contains nodes). - if (ctx!.expanding?.has(name)) return 'object'; - const expanding = new Set(ctx!.expanding ?? []); - expanding.add(name); - return formatType({ ...target, $ref: undefined }, { ...ctx!, expanding }); - } - - const href = schemaHref(name); - return href ? `[${name}](${href})` : name; - } - - if (prop.type === 'array') { - return `${formatType(prop.items, ctx)}[]`; - } - - if (prop.enum) { - return `Enum<${prop.enum.map((e: any) => `'${e}'`).join(' | ')}>`; - } - - if (prop.const !== undefined) { - return `'${prop.const}'`; - } - - if (prop.anyOf || prop.oneOf) { - const variants = prop.anyOf || prop.oneOf; - return variants.map((v: any) => formatType(v, ctx)).join(' | '); - } - - if (prop.type === 'object' && prop.additionalProperties) { - return `Record`; - } - - if (prop.type === 'object' && !prop.properties && !prop.additionalProperties) { - return 'object'; - } - - // Inline object: show its shape one level deep instead of an opaque `Object`. - if (prop.type === 'object' && prop.properties) { - const keys = Object.keys(prop.properties); - const shown = keys.slice(0, 4).map(k => { - const child = prop.properties[k]; - const optional = (prop.required || []).includes(k) ? '' : '?'; - // Depth-limited: nested objects stay opaque so a table cell can't explode. - const childType = child?.type === 'object' && child.properties - ? 'object' - : formatType(child, ctx); - return `${k}${optional}: ${childType}`; - }); - if (keys.length > shown.length) shown.push('…'); - return `{ ${shown.join('; ')} }`; - } - - if (Array.isArray(prop.type)) { - return prop.type.join(' | '); - } - - return prop.type || 'any'; -} /** * Rewrite a source path referenced from JSDoc (`../automation/sync.zod.ts`) to @@ -404,7 +304,7 @@ function generateMarkdown(schemaName: string, schema: any, category: string, zod md += `${escapeMdxDescription(mainDef.description)}\n\n`; } - const typeCtx: TypeContext = { defs, currentSchema: schemaName }; + const typeCtx: TypeContext = { defs, currentSchema: schemaName, schemaHref }; const renderProperties = (props: any, required: Set = new Set()) => { let t = `### Properties\n\n`; diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts new file mode 100644 index 0000000000..05f10b9da4 --- /dev/null +++ b/packages/spec/scripts/format-type.test.ts @@ -0,0 +1,191 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Pin for how the reference-docs renderer prints an OPEN object — one that + * declares keys AND accepts more (`.passthrough()` / `.catchall()`) — #4912. + * + * The renderer used to test `additionalProperties` BEFORE `properties`, and the + * two are not alternatives: JSON Schema spells a passthrough object as both at + * once. Every such node therefore collapsed to `Record`, erasing + * the declared keys from the author-facing page. `BulkActionParam.options` is + * the specimen the issue was filed from — its `label`/`value` are *required* + * (zero tolerance, pinned by the schema's own tests), yet the page showed + * `Record[]`, i.e. "no shape at all". + * + * This is a live-and-growing class, not a one-off: the #4001 strictness + * campaign keeps producing "declared keys + deliberate passthrough" sites + * (`DashboardWidget.config`, `BulkActionParam` itself, the `data/` mixed + * verdicts still to land), and each one erased its own keys on the day it + * landed. PR #4909 compensated by hand, in that key's `.describe()` prose — + * per-site compensation, not a fix. + * + * MEASURED (reverse verification): restoring the old branch order — testing + * `additionalProperties` before `properties` — turns the four `open object` + * cases below red with `Record` in place of every declared shape. + * The direction is the ordinary one (restore the defect → new pins go red) + * because these assert a POSITIVE shape the fix produces, not the absence of a + * finding. The `closed`/`pure record` cases stay green either way, which is + * exactly why the bug survived: the renderer was correct on both of the shapes + * anyone thought to look at. + */ + +import { describe, expect, it } from 'vitest'; + +import { formatType, type TypeContext } from './lib/format-type'; + +const ctx = (defs: Record = {}): TypeContext => ({ + defs, + currentSchema: 'Probe', + schemaHref: () => null, +}); + +/** The real `BulkActionParam.options` node, as `gen:schema` emits it. */ +const BULK_ACTION_OPTIONS = { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + value: { anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] }, + }, + required: ['label', 'value'], + additionalProperties: {}, + }, +}; + +describe('formatType — open objects keep their declared shape (#4912)', () => { + it('renders an array of passthrough objects with BOTH the declared keys and the openness marker', () => { + const rendered = formatType(BULK_ACTION_OPTIONS, ctx()); + + // The regression itself: the declared pair must not be erased. + expect(rendered).toContain('label: string'); + expect(rendered).toContain('value: string | number | boolean'); + // ...and the openness must still be stated, not silently dropped. + expect(rendered).toContain('Record'); + // Parenthesized: `A & B[]` is `A & (B[])` in TypeScript, so the unbracketed + // spelling would claim `options` is an object intersected with an array. + expect(rendered).toBe( + '({ label: string; value: string | number | boolean } & Record)[]', + ); + }); + + it('parenthesizes an intersection element before suffixing `[]`, but not a plain one', () => { + const open = { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: {} }; + expect(formatType({ type: 'array', items: open }, ctx())) + .toBe('({ a?: string } & Record)[]'); + + // A closed element needs no parens — the existing spelling is preserved. + expect(formatType({ type: 'array', items: { type: 'object', properties: { a: { type: 'string' } } } }, ctx())) + .toBe('{ a?: string }[]'); + expect(formatType({ type: 'array', items: { type: 'string' } }, ctx())).toBe('string[]'); + expect(formatType({ type: 'array', items: { type: 'object', additionalProperties: {} } }, ctx())) + .toBe('Record[]'); + }); + + it('ignores `&`-free nesting when deciding to parenthesize (no stray brackets)', () => { + // `Enum<'a' | 'b'>` and markdown links carry `<>`/`[]`/`()` that must not + // confuse the depth scan into either adding or skipping parens. + expect(formatType({ type: 'array', items: { enum: ['a', 'b'] } }, ctx())) + .toBe("Enum<'a' | 'b'>[]"); + expect(formatType({ type: 'array', items: { $ref: '#/$defs/Field' } }, { + defs: {}, + currentSchema: 'Probe', + schemaHref: () => '/docs/references/data/field#field', + })).toBe('[Field](/docs/references/data/field#field)[]'); + }); + + it('marks required vs optional keys on an open object the same way a closed one does', () => { + const rendered = formatType( + { + type: 'object', + properties: { id: { type: 'string' }, color: { type: 'string' } }, + required: ['id'], + additionalProperties: {}, + }, + ctx(), + ); + expect(rendered).toBe('{ id: string; color?: string } & Record'); + }); + + it('keeps the typed catchall in the marker instead of widening it to `any`', () => { + const rendered = formatType( + { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + additionalProperties: { type: 'string' }, + }, + ctx(), + ); + expect(rendered).toBe('{ name: string } & Record'); + }); + + it('still elides beyond the fourth declared key, and the marker survives the elision', () => { + const props: Record = {}; + for (const k of ['a', 'b', 'c', 'd', 'e']) props[k] = { type: 'string' }; + const rendered = formatType( + { type: 'object', properties: props, required: ['a', 'b', 'c', 'd', 'e'], additionalProperties: {} }, + ctx(), + ); + // `…` means "more DECLARED keys"; `& Record` means "more UNDECLARED keys". + // They are different facts and the cell must carry both. + expect(rendered).toBe('{ a: string; b: string; c: string; d: string; … } & Record'); + }); +}); + +describe('formatType — the shapes that were already right stay right', () => { + it('renders a pure record (no declared keys) as a bare Record', () => { + expect(formatType({ type: 'object', additionalProperties: { type: 'number' } }, ctx())) + .toBe('Record'); + expect(formatType({ type: 'object', additionalProperties: {} }, ctx())) + .toBe('Record'); + }); + + it('renders a closed object (`additionalProperties: false`) with no openness marker', () => { + const rendered = formatType( + { + type: 'object', + properties: { id: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }, + ctx(), + ); + expect(rendered).toBe('{ id: string }'); + expect(rendered).not.toContain('Record'); + }); + + it('renders a shapeless object as `object`', () => { + expect(formatType({ type: 'object' }, ctx())).toBe('object'); + }); + + it('treats an EMPTY declared-key set as no shape at all, not as `{ }`', () => { + // `z.object({}).passthrough()` declares nothing — intersecting an empty + // shape onto the record would print `{ } & Record<...>`, which is noise. + expect(formatType({ type: 'object', properties: {}, additionalProperties: {} }, ctx())) + .toBe('Record'); + // Without a catchall the pre-existing rendering is kept verbatim. + expect(formatType({ type: 'object', properties: {} }, ctx())).toBe('{ }'); + }); + + it('keeps nested objects opaque so a table cell cannot explode', () => { + const rendered = formatType( + { + type: 'object', + properties: { inner: { type: 'object', properties: { deep: { type: 'string' } } } }, + additionalProperties: {}, + }, + ctx(), + ); + expect(rendered).toBe('{ inner?: object } & Record'); + }); + + it('still links a $ref through the injected resolver', () => { + const rendered = formatType({ $ref: '#/$defs/Field' }, { + defs: {}, + currentSchema: 'Probe', + schemaHref: (n) => `/docs/references/data/field#${n.toLowerCase()}`, + }); + expect(rendered).toBe('[Field](/docs/references/data/field#field)'); + }); +}); diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts new file mode 100644 index 0000000000..e2b9a14726 --- /dev/null +++ b/packages/spec/scripts/lib/format-type.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * JSON Schema node → the type string a reference page prints in its table cell. + * + * Extracted from `build-docs.ts` so the rendering can be pinned directly + * (#4912). The generator is a top-level script with side effects, so the only + * way to assert on its type strings used to be to run the whole thing and grep + * the emitted `.mdx` — which is why the passthrough collapse below survived + * unnoticed through the whole #4001 campaign. + */ + +/** + * Context a page needs to turn a `$ref` into a link that actually resolves. + * + * Pages are named after the *zod file* (`data/object.mdx`) while refs name a + * *schema* (`Field`), so a ref can only be linked by looking the schema name up + * in the generator's category maps — injected here as `schemaHref` rather than + * imported, so this module stays free of the generator's module-level state. + * Anonymous refs (`__schemaN`, emitted when Zod hoists a reused inline schema + * into `$defs`) have no page at all and are rendered structurally instead. + */ +export interface TypeContext { + /** `$defs` of the document being rendered — for resolving local refs. */ + defs: Record; + /** The schema whose section is being rendered — target of a self `$ref` (`"#"`). */ + currentSchema: string; + /** + * Anonymous refs already being expanded on this branch. Schemas are cyclic + * (a node contains nodes), so inlining without this recurses forever. + */ + expanding?: Set; + /** + * Resolve a schema name to its page href, or `null` when the schema isn't one + * the generator produces a page for — the type is then rendered without a + * link rather than emitting a 404. + */ + schemaHref?: (name: string) => string | null; +} + +export const refName = (ref: string): string => ref.split('/').pop() || ref; +export const isAnonymousRef = (name: string) => /^__schema\d+$/.test(name); + +/** A page-local anchor, matching how fumadocs slugs the `## SchemaName` heading. */ +export const anchorFor = (schemaName: string) => `#${schemaName.toLowerCase()}`; + +/** How many declared keys an inline object shows before eliding the rest. */ +const INLINE_KEY_LIMIT = 4; + +/** + * Does this rendered type carry a top-level `&`, i.e. would suffixing `[]` + * re-associate it? + * + * `A & B[]` is `A & (B[])` in TypeScript, not `(A & B)[]` — so an array whose + * element is an intersection MUST be parenthesized or the cell states a + * different type than the schema. Depth is tracked across `{}`, `<>`, `[]` and + * `()` so operators nested inside a shape, a `Record<…>` type argument, an + * `Enum<'a' | 'b'>` or a markdown link target are correctly ignored. + * + * Scoped to `&` deliberately. Arrays whose element is a top-level UNION have + * the identical defect (`string | number[]` for an array of `string | number`) + * on 164 sites, but that one PREDATES this renderer change and is filed as + * #5338 — bundling its ~170-line regeneration in here would bury the #4912 fix + * this function exists for. Widening to `|` is the whole of that fix; the depth + * scan below already ignores nested operators correctly. + */ +function hasTopLevelIntersection(rendered: string): boolean { + let depth = 0; + for (const ch of rendered) { + if (ch === '{' || ch === '<' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === '>' || ch === ']' || ch === ')') depth--; + else if (depth === 0 && ch === '&') return true; + } + return false; +} + +export function formatType(prop: any, ctx?: TypeContext): string { + if (!prop) return 'any'; + + if (prop.$ref) { + // Self-reference: link to the current section rather than a bare `#`. + if (prop.$ref === '#') { + return ctx ? `[${ctx.currentSchema}](${anchorFor(ctx.currentSchema)})` : 'object'; + } + + const name = refName(prop.$ref); + + // Zod-hoisted inline schema: no page exists. Render its shape instead. + if (isAnonymousRef(name)) { + const target = ctx?.defs?.[name]; + if (!target) return 'object'; + // Cycle guard: these schemas are recursive (a node contains nodes). + if (ctx!.expanding?.has(name)) return 'object'; + const expanding = new Set(ctx!.expanding ?? []); + expanding.add(name); + return formatType({ ...target, $ref: undefined }, { ...ctx!, expanding }); + } + + const href = ctx?.schemaHref?.(name) ?? null; + return href ? `[${name}](${href})` : name; + } + + if (prop.type === 'array') { + const element = formatType(prop.items, ctx); + // An open object element renders as an intersection, which `[]` would + // re-associate — parenthesize so the cell keeps meaning "array of that". + return hasTopLevelIntersection(element) ? `(${element})[]` : `${element}[]`; + } + + if (prop.enum) { + return `Enum<${prop.enum.map((e: any) => `'${e}'`).join(' | ')}>`; + } + + if (prop.const !== undefined) { + return `'${prop.const}'`; + } + + if (prop.anyOf || prop.oneOf) { + const variants = prop.anyOf || prop.oneOf; + return variants.map((v: any) => formatType(v, ctx)).join(' | '); + } + + if (prop.type === 'object') { + // Declared keys and openness are INDEPENDENT facts, and JSON Schema states + // them independently: a `.passthrough()` / `.catchall()` object carries + // `properties` AND `additionalProperties` at once. Testing the latter first + // — as this renderer did until #4912 — made them alternatives, so every + // open object with a declared shape printed as a bare `Record` + // and the author-facing page lost keys the schema *requires*. + const open = prop.additionalProperties + ? `Record` + : null; + + // Inline object: show its shape one level deep instead of an opaque `Object`. + const keys = prop.properties ? Object.keys(prop.properties) : []; + + if (keys.length > 0) { + const shown = keys.slice(0, INLINE_KEY_LIMIT).map(k => { + const child = prop.properties[k]; + const optional = (prop.required || []).includes(k) ? '' : '?'; + // Depth-limited: nested objects stay opaque so a table cell can't explode. + const childType = child?.type === 'object' && child.properties + ? 'object' + : formatType(child, ctx); + return `${k}${optional}: ${childType}`; + }); + // `…` elides further DECLARED keys; `& Record<…>` states that UNDECLARED + // ones are accepted. Different facts — a cell may need both. + if (keys.length > shown.length) shown.push('…'); + const shape = `{ ${shown.join('; ')} }`; + // Declared shape first: the reader needs the keys they MUST write before + // the note that extra ones are tolerated. + return open ? `${shape} & ${open}` : shape; + } + + // Nothing declared. An empty `properties: {}` is not a shape — intersecting + // it would print `{ } & Record<…>`, so fall through to the record/opaque + // renderings exactly as before. + if (open) return open; + if (!prop.properties) return 'object'; + return '{ }'; + } + + if (Array.isArray(prop.type)) { + return prop.type.join(' | '); + } + + return prop.type || 'any'; +}