diff --git a/.changeset/docs-gen-numeric-literal-quoting.md b/.changeset/docs-gen-numeric-literal-quoting.md new file mode 100644 index 0000000000..8de04df4aa --- /dev/null +++ b/.changeset/docs-gen-numeric-literal-quoting.md @@ -0,0 +1,43 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): the reference generator quotes a literal by its `typeof`, so numeric literals stop being written down as strings (#5729) + +`formatType()`'s two literal branches wrapped every value in `'…'` without +looking at its type — `prop.enum.map(e => `'${e}'`)` and +`return `'${prop.const}'`` — so a **numeric** literal union was printed as a +**string** one. `FormSection.columns` is the specimen the issue was filed from: +it is declared + +```ts +z.union([z.enum(['1', '2', '3', '4']), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) +``` + +and `content/docs/references/ui/view.mdx` rendered it as +`Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'`, where the second half is +those four `z.literal()` variants. The two halves came out +indistinguishable, so the page claimed the key takes strings only — while the +schema accepts both `2` and `'2'`. + +For these pages that is a contract error, not a typo. They are the authoritative +input for an AI author (ADR-0033), and a literal union is a copy-the-spelling +surface: whatever quoting the page shows is what gets written. #5611 paid for it +directly — its `RecordDetailsProps.sections[].columns` was meant to be a numeric +literal union, the generated reference said strings, and the PR abandoned the +shape for `z.number().int().min(1).max(4)` to escape the contradiction. The +generator was defining the contract backwards. + +Quoting is now decided per **value**, not per node, because JSON Schema states +member types per value and an `enum` may mix them (`z.nativeEnum({ A: 1 })` +emits a numeric `enum`): strings stay quoted, `number`/`boolean` render bare, +and `null` renders as the keyword. + +Eight reference pages change, 11 lines, and in one direction only — quotes are +removed from 18 non-string literals and no string literal loses its: +`api/dispatcher`, `api/errors` (`success: false`), `data/data-engine` +(`sort: Record< string, 1 | -1 >`), `data/driver-nosql` (`projection: +Record< string, 0 | 1 >`), `data/object` (`systemFields` / `stageField: false`), +`security/explain` (`version: 1`), `ui/dashboard` (`filterBindings`) and +`ui/view` (`columns: … | 1 | 2 | 3 | 4`). Every `Enum<'a' | 'b'>` on those +pages is untouched. diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx index f07ecabaf2..25d63521eb 100644 --- a/content/docs/references/api/dispatcher.mdx +++ b/content/docs/references/api/dispatcher.mdx @@ -78,7 +78,7 @@ Route-resolution failure mode emitted in `error.code` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **success** | `'false'` | ✅ | | +| **success** | `false` | ✅ | | | **error** | `{ code: string; message: string; httpStatus?: integer; route?: string; … }` | ✅ | | diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 3923e27ec9..99534a1030 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -87,7 +87,7 @@ const result = EnhancedApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **success** | `'false'` | ✅ | Always false for error responses | +| **success** | `false` | ✅ | Always false for error responses | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| 'INVALID_REFERENCE' \| 'DUPLICATE_VALUE' \| 'INVALID_QUERY' \| 'INVALID_FILTER' \| 'INVALID_SORT' \| 'MAX_RECORDS_EXCEEDED' \| 'UNAUTHENTICATED' \| 'INVALID_CREDENTIALS' \| 'EXPIRED_TOKEN' \| 'INVALID_TOKEN' \| 'SESSION_EXPIRED' \| 'MFA_REQUIRED' \| 'EMAIL_NOT_VERIFIED' \| 'PERMISSION_DENIED' \| 'INSUFFICIENT_PRIVILEGES' \| 'FIELD_NOT_ACCESSIBLE' \| 'RECORD_NOT_ACCESSIBLE' \| 'LICENSE_REQUIRED' \| 'IP_RESTRICTED' \| 'TIME_RESTRICTED' \| 'RESOURCE_NOT_FOUND' \| 'OBJECT_NOT_FOUND' \| 'RECORD_NOT_FOUND' \| 'FIELD_NOT_FOUND' \| 'ENDPOINT_NOT_FOUND' \| 'RESOURCE_CONFLICT' \| 'CONCURRENT_MODIFICATION' \| 'DELETE_RESTRICTED' \| 'DUPLICATE_RECORD' \| 'LOCK_CONFLICT' \| 'METHOD_NOT_ALLOWED' \| 'PRECONDITION_REQUIRED' \| 'RATE_LIMIT_EXCEEDED' \| 'QUOTA_EXCEEDED' \| 'CONCURRENT_LIMIT_EXCEEDED' \| 'INTERNAL_ERROR' \| 'DATABASE_ERROR' \| 'TIMEOUT' \| 'SERVICE_UNAVAILABLE' \| 'NOT_IMPLEMENTED' \| 'EXTERNAL_SERVICE_ERROR' \| 'INTEGRATION_ERROR' \| 'WEBHOOK_DELIVERY_FAILED' \| 'BATCH_PARTIAL_FAILURE' \| 'BATCH_COMPLETE_FAILURE' \| 'TRANSACTION_FAILED'>; message: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>; httpStatus?: number; … }` | ✅ | Error details | | **meta** | `{ timestamp?: string; requestId?: string; traceId?: string }` | optional | Response metadata | diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index aac0fde689..8b1706a18e 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -230,7 +230,7 @@ Query options for IDataEngine.find() operations | **context** | `{ userId?: string; actor?: string; attributedUserId?: string; email?: string; … }` | optional | | | **filter** | `Record \| any` | optional | Data Engine query filter conditions | | **select** | `string[]` | optional | | -| **sort** | `Record> \| Record \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | +| **sort** | `Record> \| Record \| { field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sort order definition | | **limit** | `integer` | optional | | | **skip** | `integer` | optional | | | **top** | `integer` | optional | | @@ -382,7 +382,7 @@ Type: `Record>` #### Option 2 -Type: `Record` +Type: `Record` --- diff --git a/content/docs/references/data/driver-nosql.mdx b/content/docs/references/data/driver-nosql.mdx index b1d788c6d1..5760f0a603 100644 --- a/content/docs/references/data/driver-nosql.mdx +++ b/content/docs/references/data/driver-nosql.mdx @@ -33,7 +33,7 @@ const result = AggregationPipelineSchema.parse(data); | :--- | :--- | :--- | :--- | | **collection** | `string` | ✅ | Collection/table name | | **stages** | `{ operator: string; options: Record }[]` | ✅ | Aggregation pipeline stages | -| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeout?: integer; … }` | optional | Query options | +| **options** | `{ consistency?: Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>; readFromSecondary?: boolean; projection?: Record; timeout?: integer; … }` | optional | Query options | --- @@ -203,7 +203,7 @@ const result = AggregationPipelineSchema.parse(data); | :--- | :--- | :--- | :--- | | **consistency** | `Enum<'all' \| 'quorum' \| 'one' \| 'local_quorum' \| 'each_quorum' \| 'eventual'>` | optional | Consistency level override | | **readFromSecondary** | `boolean` | optional | Allow reading from secondary replicas | -| **projection** | `Record` | optional | Field projection | +| **projection** | `Record` | optional | Field projection | | **timeout** | `integer` | optional | Query timeout (ms) | | **useCursor** | `boolean` | optional | Use cursor instead of loading all results | | **batchSize** | `integer` | optional | Cursor batch size | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index aa8ab116b9..beac787b5d 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -118,7 +118,7 @@ const result = ApiMethod.parse(data); | **managedBy** | `Enum<'platform' \| 'config' \| 'system-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. | | **ownership** | `Enum<'user' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id) \| org \| none (no per-record owner, skips owner_id). Distinct from the package own/extend contribution kind. | | **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; delete?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; … }` | optional | Per-object override of the resolved CRUD affordance matrix. | -| **systemFields** | `'false' \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. | +| **systemFields** | `false \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. | | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. | @@ -135,7 +135,7 @@ const result = ApiMethod.parse(data); | **displayNameField** | `string` | optional | [DEPRECATED → nameField] Field to use as the record display name (e.g., "name", "title"). Accepted as an alias for nameField. | | **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. | +| **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; data?: { provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }; … }>` | 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 | diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 7eb5ea749e..0911e3503d 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -71,7 +71,7 @@ const result = AccessMatrixSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **version** | `'1'` | ✅ | | +| **version** | `1` | ✅ | | | **entries** | `{ permissionSet: string; object: string; create: boolean; read: boolean; … }[]` | ✅ | | diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 6801369d34..9b78891009 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -108,7 +108,7 @@ Dashboard header action | **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** | `{ 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 | +| **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** | `never` | 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. | | **aria** | `never` | optional | [REMOVED] `dashboard.widgets[].aria` was removed in @objectstack/spec 17.0.0 (#5010, ADR-0049 D2) — no renderer ever applied it, so ARIA attributes declared on a widget silently did not reach the DOM: the key promised accessibility compliance it did not deliver. This is the same removal the dashboard-level `aria` got in 17.0.0 (#3896). Delete the key. The dashboard renderer emits its own `aria-*` attributes for the widget grid; author a `title` (and `description`) on the widget instead — those ARE what the renderer labels the card with. The shared `AriaProps` shape is NOT gone: it stays live on `app.aria` and `page.components[].aria`. 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 ef53125cad..95cef0019a 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -181,7 +181,7 @@ Column footer summary configuration | **collapsed** | `boolean` | optional | | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | -| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | | +| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | | **fields** | `(string \| { field: 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'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … })[]` | ✅ | | diff --git a/packages/spec/scripts/format-type.test.ts b/packages/spec/scripts/format-type.test.ts index 3cfe3eff5b..e92d334283 100644 --- a/packages/spec/scripts/format-type.test.ts +++ b/packages/spec/scripts/format-type.test.ts @@ -515,3 +515,137 @@ describe('formatType — tombstones leave the inline summary to the live keys (# expect(formatType({ ...allDead, additionalProperties: {} }, ctx())).toBe('Record'); }); }); + +/** + * The real `FormSection.columns` node, as `gen:schema` emits it — probed + * against the converter, not guessed: + * `z.toJSONSchema(z.object({ columns: z.union([z.enum(['1','2','3','4']), + * z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1) }), + * { target: 'draft-2020-12' })` yields this in BOTH the `output` and the + * `io: 'input'` direction `build-schemas.ts` falls back to. + */ +const FORM_SECTION_COLUMNS = { + default: 1, + anyOf: [ + { type: 'string', enum: ['1', '2', '3', '4'] }, + { type: 'number', const: 1 }, + { type: 'number', const: 2 }, + { type: 'number', const: 3 }, + { type: 'number', const: 4 }, + ], +}; + +/** + * Pin for NON-STRING literals — #5729. + * + * The `enum` and `const` branches quoted every value unconditionally + * (`.map(e => `'${e}'`)` / `return `'${prop.const}'``), so a numeric literal + * union was written down as a string one. `FormSection.columns` is the specimen + * the issue was filed from: `content/docs/references/ui/view.mdx:184` printed + * `Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'`, where the second half + * is the four `z.literal()` variants — rendered identically to the four + * string ones, so the cell claimed the key takes strings only while the schema + * takes both `2` and `'2'`. + * + * Why it is worth a pin rather than a one-line sweep: these pages are the + * authoritative input for AI authors (ADR-0033) and a literal union is a + * copy-the-spelling surface — the quotes get copied. #5611 was the live cost: + * its `RecordDetailsProps.sections[].columns` was meant to be a numeric literal + * union, the generated reference said strings, and the PR gave up the shape for + * `z.number().int().min(1).max(4)`. The generator was defining the contract + * backwards, which is the class this block exists to keep closed. + * + * MEASURED (reverse verification): restoring the unconditional quoting in BOTH + * branches — `.map((e: any) => `'${e}'`)` and `return `'${prop.const}'`` — gives + * **5 failed | 30 passed**. The five reds are exactly the five cases below that + * assert a non-string literal, reporting `expected ''2'' to be '2'`, + * `expected ''true'' to be 'true'`, `expected 'Enum<'1' | '2'>' to be + * 'Enum<1 | 2>'`, `expected ''1' | '2'' to be '1 | 2'`, and the `columns` cell + * back to its issue-report spelling. The direction is the ordinary one (restore + * the defect → the new pins go red) because these assert a POSITIVE rendering + * the fix produces, not the absence of a finding. + * + * The three `STRING literals are UNTOUCHED` cases stay green under both + * versions, and are split into their own `it`s for that reason: an assertion + * that only ever runs in the shadow of a red sibling proves nothing. They are + * the over-reach guard — a fix that simply *deleted* the quotes, rather than + * choosing per `typeof`, turns those three red instead and would be caught + * here. Their staying green is also why the bug survived this long: the + * renderer was correct on the only literal kind anyone thought to look at. + * + * (An earlier draft of this comment predicted "4 red, 2 green" and was wrong on + * both counts — the string assertions were then sharing an `it` with the + * numeric ones, so they could not stay green independently. The numbers above + * are the re-measured ones after that split.) + */ +describe('formatType — literal quoting follows `typeof`, not habit (#5729)', () => { + it('renders a numeric literal bare', () => { + // `z.literal(2)` — the issue's headline case. + expect(formatType({ type: 'number', const: 2 }, ctx())).toBe('2'); + expect(formatType({ type: 'integer', const: 0 }, ctx())).toBe('0'); + expect(formatType({ type: 'number', const: -1.5 }, ctx())).toBe('-1.5'); + }); + + it('renders boolean and null literals as the keywords they are', () => { + expect(formatType({ type: 'boolean', const: true }, ctx())).toBe('true'); + expect(formatType({ type: 'boolean', const: false }, ctx())).toBe('false'); + // `const: null` reaches the branch (`null !== undefined`) and used to print + // `'null'` — a four-character string type, not the null literal. + expect(formatType({ type: 'null', const: null }, ctx())).toBe('null'); + }); + + it('quotes per VALUE inside an `enum`, so a numeric enum stays numeric', () => { + // `z.nativeEnum({ A: 1, B: 2 })` emits a NUMERIC enum — same branch. + expect(formatType({ type: 'number', enum: [1, 2] }, ctx())).toBe('Enum<1 | 2>'); + // JSON Schema states member types per value, so a mixed `enum` must render + // mixed. A node-level `type` test would get this one wrong. + expect(formatType({ enum: ['a', 1, true, null] }, ctx())).toBe("Enum<'a' | 1 | true | null>"); + }); + + it('renders a union of numeric literals as bare numbers (the #5611 shape)', () => { + expect( + formatType({ anyOf: [{ type: 'number', const: 1 }, { type: 'number', const: 2 }] }, ctx()), + ).toBe('1 | 2'); + // …and the array form still parenthesizes: `1 | 2[]` would claim "the + // number 1, or an array of 2s". The depth scan is unchanged by this fix, + // but it now scans a cell with no quotes in it. + expect( + formatType( + { type: 'array', items: { anyOf: [{ type: 'number', const: 1 }, { type: 'number', const: 2 }] } }, + ctx(), + ), + ).toBe('(1 | 2)[]'); + }); + + it('renders the `FormSection.columns` cell end-to-end as view.mdx must show it', () => { + // Was: `Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'` — the two + // halves indistinguishable. Now the string half keeps its quotes and the + // numeric half loses them, which is the ONLY difference the page needed. + expect(formatType(FORM_SECTION_COLUMNS, ctx())).toBe("Enum<'1' | '2' | '3' | '4'> | 1 | 2 | 3 | 4"); + }); + + // ---- STRING literals are UNTOUCHED. Green before and after; see the block + // comment above for why they are separate `it`s rather than extra lines in + // the cases above. + + it('keeps a string `const` quoted — the distinction the fix exists to make', () => { + // Same *spelling* as `z.literal(2)` in the schema source, different type. + // The quotes are the only thing telling the two apart on the page. + expect(formatType({ type: 'string', const: '2' }, ctx())).toBe("'2'"); + expect(formatType({ type: 'string', const: 'global' }, ctx())).toBe("'global'"); + }); + + it('keeps a string `enum` quoted', () => { + expect(formatType({ type: 'string', enum: ['a', 'b'] }, ctx())).toBe("Enum<'a' | 'b'>"); + }); + + it('leaves the string `const`s already on the pages exactly as they render today', () => { + // `ObjectSchema.indexes[].unique` — `z.union([z.boolean(), + // z.literal('global'), z.literal('organization')])`. Its two string + // literals are the regression risk of this change, so they are asserted + // through the same real node the tombstone block uses. + expect(formatType(INDEX_SCHEMA, ctx())).toBe( + "{ name?: string; fields: string[]; unique?: boolean | 'global' | 'organization' }[]", + ); + }); +}); diff --git a/packages/spec/scripts/lib/format-type.ts b/packages/spec/scripts/lib/format-type.ts index f27ca5658a..91a313bfd0 100644 --- a/packages/spec/scripts/lib/format-type.ts +++ b/packages/spec/scripts/lib/format-type.ts @@ -103,6 +103,53 @@ function isNeverNode(prop: any): boolean { ); } +/** + * One JSON Schema literal value — a `const`, or a single member of an `enum` — + * rendered as the TypeScript literal an author would have to type. + * + * Quoting is decided by `typeof`, not applied unconditionally (#5729). Both + * literal branches below used to wrap every value in `'…'`, which turns a + * NUMERIC literal union into a STRING one on the page: `FormSection.columns` + * (`z.union([z.enum(['1','2','3','4']), z.literal(1) … z.literal(4)])`) printed + * as `Enum<'1' | '2' | '3' | '4'> | '1' | '2' | '3' | '4'`, so the four + * `z.literal()` variants were indistinguishable from the four string + * ones and the cell read as "this key only takes strings" — while the schema + * takes both `2` and `'2'`. + * + * That is not cosmetic for the audience these pages are written for. The + * reference pages are the authoritative input for AI authors (ADR-0033), and a + * literal union is a copy-the-spelling surface: quotes copied off the page onto + * a number-only union are a hard parse error. #5611 hit exactly that — its + * `RecordDetailsProps.sections[].columns` was meant to be a numeric literal + * union, the generated reference said strings, and the PR retreated to + * `z.number().int().min(1).max(4)` to sidestep the contradiction. The + * generator was defining the contract backwards. + * + * The mapping is the JSON→TypeScript one, and it is per VALUE rather than per + * node because JSON Schema states the two independently: `enum` may mix types + * in one array (`z.nativeEnum({A: 1})` emits `{ type: 'number', enum: [1] }`, + * a numeric `enum`), so a node-level `type` test would mis-render the mixed + * case that a per-value test gets right for free. + */ +function formatLiteral(value: unknown): string { + // The only kind that IS quoted — and it keeps its quotes exactly as before. + if (typeof value === 'string') return `'${value}'`; + // `z.literal(null)` → `{ type: 'null', const: null }`. `null` is a keyword, + // and `String(null)` already spells it; the explicit branch is here so the + // `typeof value === 'object'` tail below cannot claim it first. + if (value === null) return 'null'; + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') { + return String(value); + } + // A composite `const` (object/array literal). No schema emits one today, so + // this is a guard rather than a rendering decision: `String({})` would print + // `[object Object]` and the old code printed `'[object Object]'`, either of + // which states a type no author can write. JSON is at least the literal's + // real spelling. `undefined` cannot reach the `const` branch (guarded by + // `!== undefined`) but can sit inside a hand-written `enum` array. + return JSON.stringify(value) ?? 'any'; +} + export function formatType(prop: any, ctx?: TypeContext): string { if (!prop) return 'any'; @@ -145,11 +192,11 @@ export function formatType(prop: any, ctx?: TypeContext): string { } if (prop.enum) { - return `Enum<${prop.enum.map((e: any) => `'${e}'`).join(' | ')}>`; + return `Enum<${prop.enum.map((e: unknown) => formatLiteral(e)).join(' | ')}>`; } if (prop.const !== undefined) { - return `'${prop.const}'`; + return formatLiteral(prop.const); } if (prop.anyOf || prop.oneOf) {