diff --git a/.changeset/exported-types-no-longer-resolve-to-any.md b/.changeset/exported-types-no-longer-resolve-to-any.md new file mode 100644 index 0000000000..fbd9adb61d --- /dev/null +++ b/.changeset/exported-types-no-longer-resolve-to-any.md @@ -0,0 +1,84 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": patch +"@objectstack/metadata-protocol": patch +"@objectstack/driver-mongodb": patch +--- + +fix(spec): five exported symbols resolved to `any` — type the recursive schemas and gate it in CI (#4171) + +A recursive Zod schema needs an explicit annotation to break its circular +inference, and five of them took the cheapest one available: + +```ts +export const NavigationItemSchema: z.ZodType = z.lazy(() => …); +export type NavigationItem = z.infer; // → any +``` + +It compiles, it validates correctly at runtime, and it silently throws the type +away. `NavigationItem`, `FormField`, `JoinNode` and `NormalizedFilter` were all +`any` on the published surface, plus `FieldNodeSchema` — which had no exported +type alias yet, so `z.infer` was `any` and +`QueryAST['fields']` with it. + +That is worse than a missing export. #4115 tells every consumer that a local +declaration under a spec export's name must be replaced by a binding to the +spec — and for these, obeying it **replaced a precise type with `any`**. +objectui's `NavigationItem` is a 118-line documented interface (`recordId` +template variables, `requiresObject` / `requiresService` capability gates, +`filters` precedence); every key of it exists in the spec's version, so by every +available signal it read as a redundant fork safe to delete. Deleting it swapped +a fully-typed interface for `any`, with no compile error anywhere to say so. + +It is hard to catch by inspection because `any` is mutually assignable with +everything, so the natural "are these the same type?" check answers *yes* in both +directions and recommends precisely the wrong action. Same failure family as +#4075's `[key: string]: any` on `ActionDef`: a type that agrees with everything +reads as agreement. + +**Now annotated with the real type**, using the pattern `QueryAST` already +follows in `data/query.zod.ts` — infer the non-recursive part, tie the recursive +knot in the type, so the keys stay derived from the schema instead of being +hand-maintained beside it: + +```ts +const BaseXSchema = z.object({ …every non-recursive key }); +export type X = z.infer & { children?: X[] }; +export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + children: z.array(XSchema).optional(), +})); +``` + +`z.infer` now resolves to the type it should always have been: `NavigationItem` +is the nine-branch discriminated union, `FormField` the 30-key form-field +contract (with `visibleOn` absent by construction — ADR-0089 D2 folds it into +`visibleWhen` at the boundary), `JoinNode` and the newly exported `FieldNode` +the query AST nodes, `NormalizedFilter` the normalized filter AST. Runtime +validation is unchanged: every schema parses exactly what it parsed before. + +**What the types immediately caught**, none of it visible while they were `any`: + +- `account.app.ts` set `defaultOpen` on three nav groups — a key the spec has + never declared. It worked only because objectui's `NavigationRenderer` still + falls back to that legacy alias. Fixed at the producer per Prime Directive + #12: the canonical key is `expanded`. +- The MongoDB driver built its projection with `projection[field] = 1` over + `query.fields`, so a relationship `FieldNode` would have keyed the projection + on `"[object Object]"`. It now reads the node's field name. +- `setup.app.ts`, `studio.app.ts` and `setup-nav.contributions.ts` are annotated + with the PARSED `App` / `NavigationContribution` types but omitted + `.default()`ed keys (`expanded`, `target`), as did the form fields + `metadata-protocol` synthesizes for `getUiView` (`span`). Each now states the + default it was relying on, matching what the surrounding literals already do + for `active` / `isDefault` / `collapsible` / `collapsed` / `columns`. + +**Gated, not just fixed** (`check:exported-any`, wired into the required +`TypeScript Type Check` job). `api-surface.json` records that an export *exists* +and never what it *resolves to*, which is how these survived a whole major with +every gate green. The new scan reads the built `.d.ts` a consumer's import +actually resolves to and fails on any exported type that resolves to `any` — or +any exported schema whose output is `any`, the root cause, and the only reason +`FieldNodeSchema` was visible at all. Its `KNOWN_ANY` ledger is shrink-only and +currently empty. It self-tests against the real zod first, so if the internals it +reads are ever renamed the gate fails loudly instead of quietly passing +everything forever. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a2e8888b6e..f584da7514 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -284,6 +284,19 @@ jobs: - name: Check @objectstack/spec public API surface run: pnpm --filter @objectstack/spec run check:api-surface + # Same surface, the other axis: api-surface.json records that an export + # EXISTS, never what it resolves to — so four exported types sat at `any` + # across a whole major with every gate green (#4171). #4115 tells consumers + # to replace a local declaration with the spec import, which for those four + # traded a precise type for one that constrains nothing, silently: `any` is + # mutually assignable with everything, so the check that would catch the + # swap reports "identical, safe to re-export". Reads the built dist a + # consumer's import actually resolves to, so it runs after the build step + # with the other consumer gates. Self-tests first — a scan whose green + # result is "nothing found" has to prove it can still find something. + - name: Check no exported spec type resolves to `any` + run: pnpm --filter @objectstack/spec run check:exported-any + # Anti-drift for the skill EXAMPLES, not just the skill reference indexes # (#3094). The TypeScript in skills/ is the first thing an AI copies when # authoring metadata, yet nothing type-checked it — so it rotted silently diff --git a/AGENTS.md b/AGENTS.md index 8574771346..1e22db8e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,10 +274,20 @@ believe it, and before you file a bug about `main` being red. (Two phantom "brea removals" this way while writing this section; `check:generated` now prints this caveat inline when that gate is the one failing.) -`check:liveness`, `check:empty-state`, `check:skill-examples` and -`check:react-conformance` are pure checks with no generator — a failure there is a real -finding to fix, not an artifact to regenerate. `check:generated` names them as -deliberately not run, so its "all up to date" never reads as "everything passed". +`check:liveness`, `check:empty-state`, `check:skill-examples`, +`check:react-conformance` and `check:exported-any` are pure checks with no generator — a +failure there is a real finding to fix, not an artifact to regenerate. `check:generated` +names them as deliberately not run, so its "all up to date" never reads as "everything +passed". + +`check:exported-any` is the one of those that also reads the built `dist/*.d.ts`, so the +stale-`dist` caveat above applies to it too. It asks the other half of the +`api-surface.json` question: that snapshot records an export *exists*, never what it +*resolves to*, which is how five exported symbols sat at `any` for a whole major with +every gate green (#4171). A recursive Zod schema needs an annotation to break its +circular inference, and `z.ZodType` compiles, validates correctly, and silently +throws the type away — annotate with the type instead (`QueryAST` in +`src/data/query.zod.ts` is the pattern). Two generators have **no** gate at all — `gen:openapi` and `gen:sbom`. Nothing verifies their output is current; the script reports that each run rather than staying silent diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index e273a5e1ed..fb3ad05547 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -158,12 +158,12 @@ Column footer summary configuration | **span** | `Enum<'auto' \| 'full'>` | optional | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | -| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | | **keyField** | `{ field?: string; label?: string; placeholder?: string; helpText?: string; … }` | optional | Key column config for record-typed fields | | **dependsOn** | `string` | optional | Parent field name for cascading | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — field shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). e.g. P`record.priority == 'urgent'` | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse. | | **disclosure** | `Enum<'inline' \| 'popover'>` | optional | Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure). | +| **fields** | `[FormField](#formfield)[]` | optional | Sub-fields for composite/repeater/record types | --- diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 60d5aad8d0..17ace13ad0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2841,7 +2841,14 @@ export class ObjectStackProtocolImplementation implements readonly: fields[f]?.readonly, type: fields[f]?.type, // Default to 2 columns for most, 1 for textareas - colSpan: (fields[f]?.type === 'textarea' || fields[f]?.type === 'html') ? 2 : 1 + colSpan: (fields[f]?.type === 'textarea' || fields[f]?.type === 'html') ? 2 : 1, + // `FormField.span` defaults to 'auto'; this view is hand-built + // rather than run through `FormFieldSchema.parse()`, so the + // default is spelled out to make the returned object a real + // parsed `View` (the same reason the section below states its + // own `collapsible` / `collapsed` / `columns` defaults). Was + // unnoticed while `FormField` resolved to `any` (#4171). + span: 'auto' as const })); return { diff --git a/packages/platform-objects/src/apps/account.app.ts b/packages/platform-objects/src/apps/account.app.ts index b4f48adc79..408976f539 100644 --- a/packages/platform-objects/src/apps/account.app.ts +++ b/packages/platform-objects/src/apps/account.app.ts @@ -54,6 +54,13 @@ export const ACCOUNT_APP: App = { // No `requiredPermissions`: any authenticated user must be able to // manage their own linked accounts / personal OAuth apps. RLS on each // object scopes rows to the caller. + // + // Group open-state is `expanded` — the spec key. These entries said + // `defaultOpen`, objectui's legacy alias, which the spec has never declared + // and `.strict()` would reject; it worked only because objectui's + // NavigationRenderer still falls back to it, and because `NavigationItem` + // resolved to `any` so nothing checked the key here (#4171). Per Prime + // Directive #12 the producer is the place to fix that, not the renderer. navigation: [ // Profile is the canonical landing — a hand-written React settings card // (Vercel/Linear style) registered in the Console SPA as diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 3fff63011c..7d7b3b50d8 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -26,6 +26,11 @@ import type { NavigationContribution } from '@objectstack/spec/ui'; const BASE_PRIORITY = 100; +// `target: '_self'` on every `url` entry is the schema's own default, spelled +// out: this array is annotated with the PARSED type, where a `.default()`ed key +// is required. Omitting it was only possible while `NavigationItem` resolved to +// `any` and nothing checked these literals at all (#4171). + // Marketplace entries (browse / installed) moved to // @objectstack/cloud-connection's marketplace plugins (cloud ADR-0009: // the nav lives and dies with the capability — no plugin, no entry). @@ -97,7 +102,7 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ group: 'group_configuration', priority: BASE_PRIORITY, items: [ - { id: 'nav_settings_hub', type: 'url', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_hub', type: 'url', target: '_self', label: 'All Settings', url: '/apps/setup/system/settings', icon: 'settings-2', requiredPermissions: ['manage_platform_settings'] }, // Workspace identity first — Localization (order 2) and Company (order 3) // are the lowest-`order` settings manifests and the first thing a new // company admin configures. They ship as `service-settings` manifests @@ -106,15 +111,15 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ // admin consoles (Salesforce "Company Information", ServiceNow) surface // both directly. No `requiredPermissions` — matches Branding (read perm is // the app's base `setup.access`). - { id: 'nav_settings_localization', type: 'url', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, - { id: 'nav_settings_company', type: 'url', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, - { id: 'nav_settings_branding', type: 'url', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, - { id: 'nav_settings_auth', type: 'url', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_mail', type: 'url', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_storage', type: 'url', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_ai', type: 'url', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_knowledge', type: 'url', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, - { id: 'nav_settings_feature_flags', type: 'url', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, + { id: 'nav_settings_localization', type: 'url', target: '_self', label: 'Localization', url: '/apps/setup/system/settings/localization', icon: 'globe' }, + { id: 'nav_settings_company', type: 'url', target: '_self', label: 'Company', url: '/apps/setup/system/settings/company', icon: 'building-2' }, + { id: 'nav_settings_branding', type: 'url', target: '_self', label: 'Branding', url: '/apps/setup/system/settings/branding', icon: 'palette' }, + { id: 'nav_settings_auth', type: 'url', target: '_self', label: 'Authentication', url: '/apps/setup/system/settings/auth', icon: 'lock-keyhole', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_mail', type: 'url', target: '_self', label: 'Email', url: '/apps/setup/system/settings/mail', icon: 'mail', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_storage', type: 'url', target: '_self', label: 'File Storage', url: '/apps/setup/system/settings/storage', icon: 'hard-drive', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_ai', type: 'url', target: '_self', label: 'AI & Embedder', url: '/apps/setup/system/settings/ai', icon: 'sparkles', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_knowledge', type: 'url', target: '_self', label: 'Knowledge', url: '/apps/setup/system/settings/knowledge', icon: 'book-open', requiredPermissions: ['manage_platform_settings'] }, + { id: 'nav_settings_feature_flags', type: 'url', target: '_self', label: 'Feature Flags', url: '/apps/setup/system/settings/feature_flags', icon: 'flag' }, ], }, { diff --git a/packages/platform-objects/src/apps/setup.app.ts b/packages/platform-objects/src/apps/setup.app.ts index 4d0bba95f4..6de9acdeb0 100644 --- a/packages/platform-objects/src/apps/setup.app.ts +++ b/packages/platform-objects/src/apps/setup.app.ts @@ -47,12 +47,18 @@ export const SETUP_APP: App = { requiredPermissions: ['setup.access'], // Shell only — the stable group anchors. Children are supplied by // `navigationContributions` from the packages that own the objects. + // + // `expanded: false` is the schema's own default, spelled out: this literal is + // annotated with the PARSED `App` type, where a `.default()`ed key is + // required. Omitting it was only possible while `NavigationItem` resolved to + // `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', + expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -61,6 +67,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Apps', icon: 'package', + expanded: false, children: [], }, { @@ -68,6 +75,7 @@ export const SETUP_APP: App = { type: 'group', label: 'People & Organization', icon: 'users', + expanded: false, children: [], }, { @@ -75,6 +83,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Access Control', icon: 'shield', + expanded: false, children: [], }, { @@ -82,6 +91,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Approvals', icon: 'check-circle', + expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -90,6 +100,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Configuration', icon: 'sliders-horizontal', + expanded: false, children: [], }, { @@ -97,6 +108,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Diagnostics', icon: 'stethoscope', + expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, @@ -105,6 +117,7 @@ export const SETUP_APP: App = { type: 'group', label: 'Integrations', icon: 'plug', + expanded: false, requiredPermissions: ['manage_platform_settings'], children: [], }, diff --git a/packages/platform-objects/src/apps/studio.app.ts b/packages/platform-objects/src/apps/studio.app.ts index c818a22c8e..1743fddfc1 100644 --- a/packages/platform-objects/src/apps/studio.app.ts +++ b/packages/platform-objects/src/apps/studio.app.ts @@ -77,12 +77,17 @@ export const STUDIO_APP: App = { placement: 'sidebar_header', }, ], + // `expanded: false` on each group is the schema's own default, spelled out: + // this literal is annotated with the PARSED `App` type, where a `.default()`ed + // key is required. Omitting it was only possible while `NavigationItem` + // resolved to `any` and nothing checked these entries at all (#4171). navigation: [ { id: 'group_overview', type: 'group', label: 'Overview', icon: 'layout-dashboard', + expanded: false, children: [ { // The application builder's front door (ADR-0080/0084): pick or @@ -121,6 +126,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'Data Model', icon: 'database', + expanded: false, children: [ { id: 'nav_objects', @@ -146,6 +152,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'User Experience', icon: 'layout', + expanded: false, children: [ { id: 'nav_apps', @@ -204,6 +211,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'Logic', icon: 'function-square', + expanded: false, children: [ { id: 'nav_actions', @@ -229,6 +237,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'Automation', icon: 'workflow', + expanded: false, children: [ { id: 'nav_flows', @@ -253,6 +262,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'AI', icon: 'sparkles', + expanded: false, children: [ { id: 'nav_agents', @@ -289,6 +299,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'Developer', icon: 'terminal', + expanded: false, children: [ { id: 'nav_api_console', @@ -322,6 +333,7 @@ export const STUDIO_APP: App = { type: 'group', label: 'Integration', icon: 'plug', + expanded: false, children: [ { id: 'nav_email_templates', diff --git a/packages/plugins/driver-mongodb/src/mongodb-driver.ts b/packages/plugins/driver-mongodb/src/mongodb-driver.ts index f95b5bdd42..2c5b74047b 100644 --- a/packages/plugins/driver-mongodb/src/mongodb-driver.ts +++ b/packages/plugins/driver-mongodb/src/mongodb-driver.ts @@ -223,7 +223,14 @@ export class MongoDBDriver implements IDataDriver { if (query.fields && query.fields.length > 0) { const projection: Document = {}; for (const field of query.fields) { - projection[field] = 1; + // A `FieldNode` is either a plain field name or a relationship field + // carrying a nested select. The nested select is resolved by the engine + // (expand → batch `$in` queries), never by the driver, so all the + // projection needs from the object form is the relationship field's own + // name. Indexing with the object itself keyed the projection on + // `"[object Object]"` — invisible until #4171 gave `QueryAST['fields']` + // a real type. + projection[typeof field === 'string' ? field : field.field] = 1; } // Always include `id`, never include `_id` projection.id = 1; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 46a786dc84..2b3698d9dd 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -350,6 +350,7 @@ "FieldInput (type)", "FieldMapping (type)", "FieldMappingSchema (const)", + "FieldNode (type)", "FieldNodeSchema (const)", "FieldOperators (type)", "FieldOperatorsSchema (const)", diff --git a/packages/spec/package.json b/packages/spec/package.json index 36401dd68c..035222023b 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -198,6 +198,7 @@ "gen:sbom": "tsx scripts/generate-sbom.ts", "gen:api-surface": "tsx scripts/build-api-surface.ts", "check:api-surface": "tsx scripts/build-api-surface.ts --check", + "check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:spec-changes": "tsx scripts/build-spec-changes.ts", "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", diff --git a/packages/spec/scripts/check-exported-any.ts b/packages/spec/scripts/check-exported-any.ts new file mode 100644 index 0000000000..5b2d1e920b --- /dev/null +++ b/packages/spec/scripts/check-exported-any.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-exported-any.ts — no exported type of @objectstack/spec may resolve to `any`. + * + * The spec package IS the third-party API, and #4115 made a rule out of that: a + * symbol whose name matches a spec export must be an IMPORT, not a local + * re-declaration. For four exports that rule actively *degraded* the consumer + * that obeyed it (#4171) — the type it bound to was `any`: + * + * declare const NavigationItemSchema: z.ZodType; // recursion annotation + * type NavigationItem = z.infer; // → any + * + * A recursive Zod schema needs an explicit annotation to break the circular + * inference, and `z.ZodType` is the path of least resistance. It compiles, + * it validates correctly at runtime, and it silently throws the type away. The + * consumer that deletes its own 118-line `NavigationItem` in favour of the spec + * import — exactly what #4115 asks for — ends up with *less* type safety than + * before, and nothing anywhere fails to say so. + * + * The reason it survives review is that `any` answers every question + * affirmatively: `[Local] extends [Spec]` and `[Spec] extends [Local]` are BOTH + * true when `Spec` is `any`, so the natural "are these identical?" check reports + * "identical, safe to re-export" and recommends precisely the wrong action. Same + * failure family as #4075's `[key: string]: any` on `ActionDef`. A type that + * agrees with everything cannot be caught by asking it whether it agrees. + * + * So it is asked structurally instead, from the built `.d.ts` a consumer's + * import actually resolves to — the same vantage point as `build-api-surface.ts`, + * because a source-level check would not see what the declaration bundler emits. + * + * TWO surfaces, because the defect has two visible faces: + * 1. exported TYPES that resolve to `any` — what the consumer binds to. + * 2. exported SCHEMAS whose output is `any` — the root cause, and `any` for + * any consumer writing `z.infer` even when nobody has + * exported a named type for it yet. `FieldNodeSchema` was exactly that: + * one alias away from being a fifth entry in #4171's table, and invisible + * to a types-only scan. + * + * SCOPE — the type itself must BE `any`. A type with `any` somewhere inside it + * (`Record`, `any[]`, an `$eq?: any` operator field) is NOT flagged: + * that is a different and far broader question, and drawing the line here keeps + * the gate at zero false positives so red keeps meaning broken. The authorable + * key surface is ratcheted separately (`authorable-surface.json`, #3855). + * + * Fix, don't declare: annotate the recursion with the type instead of `any` — + * infer the non-recursive part and tie the recursive knot in the type (the + * `QueryAST` pattern this repo already follows in `data/query.zod.ts`): + * + * const BaseXSchema = z.object({ ...every non-recursive key }); + * export type X = z.infer & { children?: X[] }; + * export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({ + * children: z.array(XSchema).optional(), + * })); + * + * `KNOWN_ANY` is the escape hatch for a case that genuinely cannot be typed, and + * it is shrink-only: an entry that no longer resolves to `any` fails the gate + * until it is deleted, so a fix cannot leave a stale exemption behind to cover + * the next regression under the last one's reason. It is currently EMPTY, and + * worth keeping that way — #4171 fixed all four rather than declaring them. + * + * ## Usage + * + * pnpm --filter @objectstack/spec check:exported-any # audit the built dist + * pnpm --filter @objectstack/spec exec tsx scripts/check-exported-any.ts --self-test + * + * The self-test compiles a fixture against the REAL zod, so the day zod renames + * the internals this reads (`_output`), the gate fails loudly instead of quietly + * passing everything forever — the failure mode that matters most for a check + * whose green result is "nothing found". + * + * Reads the built dist — run after `pnpm --filter @objectstack/spec build`. + */ +import ts from 'typescript'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const SELF_TEST = process.argv.includes('--self-test'); + +/** + * Deliberate, reasoned exemptions — `':': 'why'`. + * + * Shrink-only: an entry listed here that no longer resolves to `any` is a + * failure, not a pass. Every entry is debt with a name on it. + */ +const KNOWN_ANY: Record = {}; + +/** Public entry points → their built CJS `.d.ts`, read from the exports map. */ +function collectEntries(): Record { + const pkg = JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')); + const entries: Record = {}; + for (const [sub, val] of Object.entries(pkg.exports ?? {})) { + if (!sub.startsWith('.')) continue; + const dts = val?.require?.types ?? val?.import?.types; + if (typeof dts === 'string' && dts.endsWith('.d.ts')) entries[sub] = resolve(PKG_DIR, dts); + } + return entries; +} + +type Violation = { key: string; kind: 'type' | 'schema'; detail: string }; +type ScanResult = { violations: Violation[]; declared: Set; types: number; schemas: number }; + +/** + * Scan a program's module exports for symbols that resolve to `any`. + * + * `entries` maps a public subpath to the `.d.ts` that serves it, so a violation + * is reported as `./ui:NavigationItem` — the import path a consumer would write. + */ +function scan(program: ts.Program, entries: Record, exempt: Record): ScanResult { + const checker = program.getTypeChecker(); + const result: ScanResult = { violations: [], declared: new Set(), types: 0, schemas: 0 }; + + const unalias = (s: ts.Symbol): ts.Symbol => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + const isAny = (t: ts.Type | undefined): boolean => Boolean(t && t.flags & ts.TypeFlags.Any); + + /** + * The output type of a Zod schema value, or undefined when the symbol is not a + * schema. `_output` is `Internals['output']` on zod's `ZodType`, i.e. exactly + * what `z.infer` resolves to — so this asks the consumer's question + * without reimplementing `z.infer`. + */ + const schemaOutput = (sym: ts.Symbol, decl: ts.Declaration): ts.Type | undefined => { + const output = checker.getPropertyOfType(checker.getTypeOfSymbolAtLocation(sym, decl), '_output'); + return output ? checker.getTypeOfSymbolAtLocation(output, decl) : undefined; + }; + + for (const [sub, file] of Object.entries(entries)) { + const sf = program.getSourceFile(file); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + if (!moduleSym) throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`); + + for (const exported of checker.getExportsOfModule(moduleSym)) { + const name = exported.getName(); + const sym = unalias(exported); + const flags = sym.getFlags(); + const key = `${sub}:${name}`; + const record = (kind: Violation['kind'], detail: string) => { + if (exempt[key]) result.declared.add(key); + else result.violations.push({ key, kind, detail }); + }; + + // 1. Exported types — what a consumer following #4115 binds its symbol to. + if (flags & ts.SymbolFlags.TypeAlias) { + result.types++; + if (isAny(checker.getDeclaredTypeOfSymbol(sym))) { + record('type', `exported type \`${name}\` resolves to \`any\``); + } + continue; + } + + // 2. Exported schemas — the root cause, and `any` for `z.infer` + // even when no named type has been exported for it yet. + if (flags & ts.SymbolFlags.Variable) { + const decl = sym.valueDeclaration ?? sym.declarations?.[0]; + if (!decl) continue; + const output = schemaOutput(sym, decl); + if (!output) continue; + result.schemas++; + if (isAny(output)) { + record( + 'schema', + `exported schema \`${name}\` has output \`any\`, so \`z.infer\` is \`any\``, + ); + } + } + } + } + + return result; +} + +function makeProgram(files: string[], extra: ts.CompilerOptions = {}): ts.Program { + return ts.createProgram(files, { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + noEmit: true, + ...extra, + }); +} + +// ── Self-test ──────────────────────────────────────────────────────────────── + +/** + * Verify the checker still detects what it exists to detect, against the real + * zod. Both halves matter: a false negative makes the gate dormant (green + * forever, which is indistinguishable from "clean"), a false positive makes it + * noise that someone will route around. + */ +function selfTest(): never { + const fail = (msg: string): never => { + console.error(`✗ self-test: ${msg}`); + process.exit(1); + }; + + // Resolve the real zod so the fixture exercises the actual `ZodType` internals + // this checker reads — a stub would keep passing after zod renamed them. + const require = createRequire(import.meta.url); + const zodDir = dirname(require.resolve('zod/package.json', { paths: [PKG_DIR] })); + + const dir = mkdtempSync(join(tmpdir(), 'spec-exported-any-')); + const fixture = join(dir, 'fixture.ts'); + writeFileSync( + fixture, + `import { z } from 'zod';\n` + + // Should be flagged: the type IS `any`, however it got there. + `export type BareAny = any;\n` + + `export const AnySchema: z.ZodType = z.lazy(() => z.object({ a: z.string() }));\n` + + `export type InferredFromAnySchema = z.infer;\n` + + // Should NOT be flagged: precisely typed, or merely `any`-CONTAINING. + `export type Precise = { a: string };\n` + + `export const PreciseSchema: z.ZodType = z.lazy(() => z.object({ a: z.string() }));\n` + + `export const PlainSchema = z.object({ a: z.string() });\n` + + `export type InferredFromPlain = z.infer;\n` + + `export type AnyInside = Record;\n` + + `export type AnyArray = any[];\n` + + `export const LooseSchema = z.record(z.string(), z.any());\n` + + `export const NotASchema = { a: 1 };\n` + + `export function notASchemaEither(): void {}\n`, + 'utf8', + ); + + try { + const program = makeProgram([fixture], { + // Node10 resolution + an explicit `paths` mapping so the fixture's bare + // `zod` import resolves from a temp dir outside any node_modules tree. + moduleResolution: ts.ModuleResolutionKind.Node10, + baseUrl: dir, + paths: { zod: [zodDir], 'zod/*': [`${zodDir}/*`] }, + }); + + const syntactic = program.getSyntacticDiagnostics(); + if (syntactic.length > 0) fail(`fixture does not parse: ${ts.flattenDiagnosticMessageText(syntactic[0].messageText, ' ')}`); + + const { violations, types, schemas } = scan(program, { './fixture': fixture }, {}); + const flagged = new Set(violations.map((v) => v.key.split(':')[1])); + + // The fixture exports 6 type aliases and 4 schemas. A lower count means the + // scan is not seeing them at all — which would make every assertion below + // pass vacuously, the exact way a gate goes dormant. + if (types !== 6) fail(`saw ${types} exported types, expected 6 — the fixture's types are not resolving (zod unresolved?)`); + if (schemas !== 4) fail(`saw ${schemas} exported schemas, expected 4 — \`_output\` no longer resolves, so the schema half of this gate is DORMANT`); + + for (const name of ['BareAny', 'InferredFromAnySchema']) { + if (!flagged.has(name)) fail(`missed exported type \`${name}\` — the type half of this gate is DORMANT`); + } + if (!flagged.has('AnySchema')) fail('missed `AnySchema` — the schema half of this gate is DORMANT'); + + for (const name of ['Precise', 'PreciseSchema', 'PlainSchema', 'InferredFromPlain', 'AnyInside', 'AnyArray', 'LooseSchema', 'NotASchema']) { + if (flagged.has(name)) fail(`false positive on \`${name}\` — only a type that IS \`any\` may be flagged`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + console.log('✅ self-test: detects `any` types and `any`-output schemas, and nothing else.'); + process.exit(0); +} + +if (SELF_TEST) selfTest(); + +// ── Audit ──────────────────────────────────────────────────────────────────── + +const entries = collectEntries(); +const { violations, declared, types, schemas } = scan(makeProgram(Object.values(entries)), entries, KNOWN_ANY); + +// Ratchet: an exemption that no longer applies must be deleted, or it stays +// available to cover the next regression under the last one's reason. +const stale = Object.keys(KNOWN_ANY).filter((key) => !declared.has(key)); + +if (violations.length === 0 && stale.length === 0) { + const exemptions = Object.keys(KNOWN_ANY).length; + console.log( + `✅ no exported type resolves to \`any\`: ${types} types + ${schemas} schemas across ` + + `${Object.keys(entries).length} entry points` + + `${exemptions > 0 ? `, ${exemptions} declared exemption(s)` : ''}.`, + ); + process.exit(0); +} + +if (violations.length > 0) { + console.error(`❌ ${violations.length} exported symbol(s) of @objectstack/spec resolve to \`any\`:\n`); + for (const v of violations) console.error(` • ${v.key} — ${v.detail}`); + console.error( + '\nAn exported `any` is worse than a missing export: #4115 tells every consumer to replace its own\n' + + 'declaration with the spec import, and `any` is mutually assignable with everything, so the check\n' + + 'that would catch the swap reports "identical, safe to re-export" (#4171). The damage is silent —\n' + + 'no compile error, just a type that stopped constraining anything.\n\n' + + 'Almost always a recursive schema annotated `z.ZodType` to break the circular inference.\n' + + 'Annotate it with the type instead — infer the non-recursive part, tie the knot in the type:\n\n' + + ' const BaseXSchema = z.object({ ...every non-recursive key });\n' + + ' export type X = z.infer & { children?: X[] };\n' + + ' export const XSchema: z.ZodType = z.lazy(() => BaseXSchema.extend({\n' + + ' children: z.array(XSchema).optional(),\n' + + ' }));\n\n' + + '(`QueryAST` in src/data/query.zod.ts is the in-repo precedent.) If a case genuinely cannot be\n' + + 'typed, add it to KNOWN_ANY in scripts/check-exported-any.ts with a reason — it is shrink-only.', + ); +} + +if (stale.length > 0) { + console.error(`\n❌ ${stale.length} stale KNOWN_ANY exemption(s) — the gap is closed, delete the entry:\n`); + for (const key of stale) console.error(` • ${key} — no longer \`any\` (reason on file: ${KNOWN_ANY[key]})`); + console.error( + '\nThe ledger is shrink-only. A stale entry stays available to cover the NEXT regression under the\n' + + "last one's reason, which is how a ratchet quietly stops ratcheting.", + ); +} + +process.exit(1); diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index ff944e0d22..18aebaab09 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -62,6 +62,19 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ { check: 'check:empty-state', why: 'audits empty-state coverage — no artifact' }, { check: 'check:react-conformance', why: 'audits react blocks against their contract — no artifact' }, { check: 'check:skill-examples', why: 'validates skill examples parse — no artifact' }, + // Landed in #4177 while this ledger landed in #4183 — neither PR could see the + // other, so `main` carried an unclassified script and this reconciliation was + // failing on `main` itself. The doc it checks against is hand-written, so there + // is no generator to name. + { check: 'check:variant-docs', why: 'audits that each schema variant appears in its hand-written doc — no artifact' }, + // The odd one out: it audits the source's TYPES, but reads them from the BUILT + // `dist/*.d.ts` — the surface a consumer's import actually resolves to, which + // is the only place the defect is visible (#4171). So the `readsDist` caveat + // above applies to it even though there is nothing to regenerate. + { + check: 'check:exported-any', + why: 'audits the built .d.ts for exported types/schemas that resolve to `any` — no artifact (needs a fresh `pnpm build`)', + }, ]; /** diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 4e1b8a8457..2e021e7f59 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -314,7 +314,25 @@ export type QueryFilter = z.infer; * * This simplifies adapter implementation by providing a consistent structure. */ -export const NormalizedFilterSchema: z.ZodType = z.lazy(() => +export type NormalizedFilter = { + /** All conditions must hold. Each entry is a field condition or a nested group. */ + $and?: Array | NormalizedFilter>; + /** At least one condition must hold. */ + $or?: Array | NormalizedFilter>; + /** Negated condition. */ + $not?: Record | NormalizedFilter; +}; + +/** + * Zod schema for the normalized filter AST. + * + * Every key is recursive, so there is no non-recursive half to infer from and + * {@link NormalizedFilter} is written out above instead — it is this schema's + * annotation. Annotating with `z.ZodType` (as this did before #4171) made + * the exported `NormalizedFilter` resolve to `any`, so the adapters that walk + * this AST were writing against a type that constrained nothing. + */ +export const NormalizedFilterSchema: z.ZodType = z.lazy(() => z.object({ $and: z.array( z.union([ @@ -339,8 +357,6 @@ export const NormalizedFilterSchema: z.ZodType = z.lazy(() => }) ); -export type NormalizedFilter = z.infer; - // ============================================================================ // AST Array Format Detection & Validation // ============================================================================ diff --git a/packages/spec/src/data/query.zod.ts b/packages/spec/src/data/query.zod.ts index fc7d4c3f0e..75257e8309 100644 --- a/packages/spec/src/data/query.zod.ts +++ b/packages/spec/src/data/query.zod.ts @@ -181,6 +181,28 @@ export const JoinType = z.enum(['inner', 'left', 'right', 'full']); */ export const JoinStrategy = z.enum(['auto', 'database', 'hash', 'loop']); +/** Non-recursive half of {@link JoinNodeSchema} — every key except `subquery`. */ +const JoinNodeBaseSchema = lazySchema(() => z.object({ + type: JoinType.describe('Join type'), + strategy: JoinStrategy.optional().describe('Execution strategy hint'), + object: z.string().describe('Object/table to join'), + alias: z.string().optional().describe('Table alias'), + on: FilterConditionSchema.describe('Join condition'), +})); + +/** + * A single join — the TYPE half of {@link JoinNodeSchema}. + * + * `subquery` is what makes the schema recursive (through {@link QuerySchema}), + * so it is declared here rather than inferred: `z.lazy()` needs an annotation, + * and the `z.ZodType` this carried before #4171 made the exported + * `JoinNode` — and `QueryAST['joins']` with it — resolve to `any`. + */ +export type JoinNode = z.infer & { + /** Join against a derived dataset instead of a plain object/table. */ + subquery?: QueryAST; +}; + /** * Join Node * Represents table joins for combining data from multiple objects. @@ -259,13 +281,8 @@ export const JoinStrategy = z.enum(['auto', 'database', 'hash', 'loop']); * ] * } */ -export const JoinNodeSchema: z.ZodType = z.lazy(() => - z.object({ - type: JoinType.describe('Join type'), - strategy: JoinStrategy.optional().describe('Execution strategy hint'), - object: z.string().describe('Object/table to join'), - alias: z.string().optional().describe('Table alias'), - on: FilterConditionSchema.describe('Join condition'), +export const JoinNodeSchema: z.ZodType = z.lazy(() => + JoinNodeBaseSchema.extend({ subquery: z.lazy(() => QuerySchema).optional().describe('Subquery instead of object'), }) ); @@ -418,11 +435,31 @@ export const WindowFunctionNodeSchema = lazySchema(() => z.object({ over: WindowSpecSchema.describe('Window specification (OVER clause)'), })); +/** + * One entry of a select list: a plain field name, or a relationship field + * carrying a nested select. + * + * The TYPE half of {@link FieldNodeSchema} — it is that schema's recursion + * annotation, which used to be `z.ZodType`, making `QueryAST['fields']` + * `any[]` and leaving this one alias away from being a fifth entry in #4171's + * table. + */ +export type FieldNode = + | string + | { + /** Relationship field name (e.g. `owner`). */ + field: string; + /** Nested select on the related object. */ + fields?: FieldNode[]; + /** Result alias. */ + alias?: string; + }; + /** * Field Selection Node * Represents "Select" attributes, including joins. */ -export const FieldNodeSchema: z.ZodType = z.lazy(() => +export const FieldNodeSchema: z.ZodType = z.lazy(() => z.union([ z.string(), // Primitive field: "name" z.object({ @@ -598,6 +635,7 @@ export type SortNode = z.infer; export type AggregationNode = z.infer; export type GroupByNode = z.infer; export type DateGranularityValue = z.infer; -export type JoinNode = z.infer; +// `JoinNode` / `FieldNode` are declared next to their schemas — they ARE those +// schemas' annotations, so they cannot be inferred back out of them (#4171). export type WindowFunctionNode = z.infer; export type WindowSpec = z.infer; diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 8df865963b..3a56de4941 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -412,11 +412,55 @@ const SeparatorNavItemSchema = lazySchema(() => z.object({ order: z.number().optional().describe('Sort order within the same level (lower = first)'), }, { error: navItemUnknownKeyError('separator') }).strict()); +/** Separator branch — internal, mirrors {@link SeparatorNavItemSchema}. */ +type SeparatorNavItem = z.infer; + +/** + * Recursive union of every navigation item type — the TYPE half of + * {@link NavigationItemSchema}, and the annotation that breaks its circular + * inference. + * + * Spelled out here rather than derived with `z.infer` + * because that inference is exactly what the recursion cannot compute: the + * schema needs an annotation, and the `z.ZodType` this used to carry made + * `NavigationItem` resolve to `any` for every consumer (#4171). `any` is + * mutually assignable with everything, so a consumer deleting its own + * `NavigationItem` in favour of this import — what #4115 asks for — silently + * traded a precise type for one that constrains nothing. + * + * Only the recursive `children` knot is tied by hand; each branch still derives + * from its own schema, so a key added to `ObjectNavItemSchema` lands here too. + */ +export type NavigationItem = + | (ObjectNavItem & { children?: NavigationItem[] }) + | DashboardNavItem + | PageNavItem + | UrlNavItem + | ReportNavItem + | ActionNavItem + | ComponentNavItem + | SeparatorNavItem + | GroupNavItem; + /** * Recursive Union of all navigation item types. * Allows constructing an unlimited-depth navigation tree. + * + * The trailing cast to `z.ZodType` is forced by the member-array + * widening below: `discriminatedUnion` reports the output of a + * `ZodObject` member as `Record`, so the union's + * inferred output carries no branch shapes at all. That fits the `z.ZodType` + * this used to be annotated with — and nothing sharper, which is how the exported + * `NavigationItem` came to be `any` for every consumer (#4171). + * + * What the cast does NOT weaken: every branch of {@link NavigationItem} is + * `z.infer`, so a key added to any variant's schema still + * flows into the exported type with no edit here. What it leaves unchecked is + * only the MEMBERSHIP of the list — a branch added to the schema and not to the + * type, or the reverse — which app.test.ts covers at runtime by parsing all nine + * ("accepts every variant with its full declared payload"). */ -export const NavigationItemSchema: z.ZodType = z.lazy(() => +export const NavigationItemSchema: z.ZodType = z.lazy(() => // DISCRIMINATED on `type` (#4001 PR B). With `.strict()` members a plain // union would answer one unknown key with an `invalid_union` aggregate // listing all nine branches' failures; discriminating on `type` first means @@ -440,7 +484,7 @@ export const NavigationItemSchema: z.ZodType = z.lazy(() => // The members are lazySchema Proxies and a superRefine-wrapped variant, so // the array is widened for the discriminator-typed overload; runtime // discrimination works on all of them (asserted in app.test.ts). - ] as unknown as readonly [z.ZodObject, ...z.ZodObject[]]) + ] as unknown as readonly [z.ZodObject, ...z.ZodObject[]]) as unknown as z.ZodType ); /** @@ -1027,7 +1071,8 @@ export function defineApp(config: z.input): App { export type App = z.infer; export type AppInput = z.input; export type AppBranding = z.infer; -export type NavigationItem = z.infer; +// `NavigationItem` is declared next to NavigationItemSchema — it IS that +// schema's annotation, so it cannot be inferred back out of it (#4171). export type NavigationArea = z.infer; // Discriminated Item Types (Helper exports) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 15e498285b..251086064b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -797,23 +797,22 @@ export const ListViewSchema = lazySchema(() => z.object({ })); /** - * Form Field Configuration Schema - * Detailed configuration for individual form fields. - * - * Reuses Data.FieldType and related constraints from the Data protocol to avoid duplication. - * The `type` field auto-infers widget rendering; explicit `widget` overrides are only needed - * for custom components. - * - * @example Auto-inferred select widget - * { field: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] } - * - * @example Lookup field with reference - * { field: 'account_id', type: 'lookup', reference: 'account', label: 'Account' } - * - * @example Custom widget override - * { field: 'filter', widget: 'filter-builder' } + * Non-recursive half of {@link FormFieldSchema} — every key except the + * recursive `fields`. + * + * Split out so the recursion can be TYPED. `z.lazy()` needs an explicit + * annotation to break the circular inference, and the `z.ZodType` this + * schema used to carry made `FormField` resolve to `any` for every consumer + * (#4171): a precise 30-key contract silently reduced to a type that constrains + * nothing, with no compile error anywhere to say so. Inferring the base and + * closing the recursive loop in the type instead keeps the keys derived from the + * schema — the `QueryAST` pattern in `data/query.zod.ts`. + * + * Still `lazySchema`-wrapped: splitting the literal out of the exported schema's + * factory would otherwise have moved its construction back to module load, which + * is the allocation `lazySchema` exists to defer. */ -export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ +const FormFieldBaseSchema = lazySchema(() => z.object({ /** Field name (snake_case) */ field: z.string().describe('Field name (snake_case)'), @@ -860,18 +859,6 @@ export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ /** For `code` fields: source language (e.g. 'javascript', 'sql', 'json', 'typescript', 'expression', 'cel'). Drives syntax highlighting. */ language: z.string().optional().describe('Code editor language (for type=code)'), - /** - * Sub-fields for `composite` / `repeater` / `record` types — declares - * the inner shape of an embedded sub-object (composite), each row of - * an embedded sub-object array (repeater), or each entry of a name-keyed - * map (record). Recursive: any of the three can nest. - * - * Use `lookup` / `master_detail` instead when the children are independent - * records with their own IDs in a separate object/table. - */ - fields: z.array(z.lazy(() => FormFieldSchema)).optional() - .describe('Sub-fields for composite/repeater/record types'), - /** * For `record`-typed fields only. Declares how the map key is sourced, * displayed, and validated when an admin creates a new entry. @@ -904,7 +891,50 @@ export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ /** @deprecated ADR-0089 — use `visibleWhen`. Accepted and normalized to `visibleWhen` at parse. */ visibleOn: ExpressionInputSchema.optional().describe('[DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Normalized to `visibleWhen` at parse.'), disclosure: z.enum(['inline', 'popover']).optional().describe('Composite rendering: inline bordered box (default) or a summary line + gear popover (progressive disclosure).'), -}, { error: strictVisibilityError }).strict().transform(normalizeVisibleWhen)); +}, { error: strictVisibilityError })); + +/** + * A parsed form field — the TYPE half of {@link FormFieldSchema}. + * + * `visibleOn` is absent by construction: ADR-0089 D2 folds it into + * `visibleWhen` at the schema boundary, so it is accepted on input and never + * present on output. + */ +export type FormField = Omit, 'visibleOn'> & { + /** + * Sub-fields for `composite` / `repeater` / `record` types — declares + * the inner shape of an embedded sub-object (composite), each row of + * an embedded sub-object array (repeater), or each entry of a name-keyed + * map (record). Recursive: any of the three can nest. + * + * Use `lookup` / `master_detail` instead when the children are independent + * records with their own IDs in a separate object/table. + */ + fields?: FormField[]; +}; + +/** + * Form Field Configuration Schema + * Detailed configuration for individual form fields. + * + * Reuses Data.FieldType and related constraints from the Data protocol to avoid duplication. + * The `type` field auto-infers widget rendering; explicit `widget` overrides are only needed + * for custom components. + * + * @example Auto-inferred select widget + * { field: 'status', type: 'select', options: [{ label: 'Open', value: 'open' }] } + * + * @example Lookup field with reference + * { field: 'account_id', type: 'lookup', reference: 'account', label: 'Account' } + * + * @example Custom widget override + * { field: 'filter', widget: 'filter-builder' } + */ +export const FormFieldSchema: z.ZodType = lazySchema(() => + FormFieldBaseSchema.extend({ + fields: z.array(z.lazy(() => FormFieldSchema)).optional() + .describe('Sub-fields for composite/repeater/record types'), + }).strict().transform(normalizeVisibleWhen)); /** * Form Layout Section @@ -1800,7 +1830,8 @@ export type ListView = z.infer; export type FormView = z.infer; export type FormSection = z.infer; export type ListColumn = z.infer; -export type FormField = z.infer; +// `FormField` is declared next to FormFieldSchema — it IS that schema's +// annotation, so it cannot be inferred back out of it (#4171). export type SelectionConfig = z.infer; export type NavigationConfig = z.infer; export type PaginationConfig = z.infer;