diff --git a/.changeset/recursive-schema-input-pins.md b/.changeset/recursive-schema-input-pins.md new file mode 100644 index 0000000000..48a8763588 --- /dev/null +++ b/.changeset/recursive-schema-input-pins.md @@ -0,0 +1,56 @@ +--- +"@objectstack/spec": patch +--- + +test(spec): pin the input half of every recursive schema, so the third omission fails instead of shipping (#3786) + +A recursive Zod schema cannot infer its own type, so it carries a hand-written +`z.ZodType<...>` annotation. `z.ZodType` takes **two** type parameters, +``, and `Input` defaults to `unknown`. Naming only the first +compiles, validates correctly at runtime, and silently un-types every authoring +path through the schema — `unknown` accepts everything. + +This package has made that mistake twice: + +1. **#4171** replaced `z.ZodType` on the nav union with + `z.ZodType`, fixing the output half. `check-exported-any.ts` + was built to hold that fix and reads output only, so it reported green over + the half that was still broken. +2. **#4221** found the consequence — `defineApp`, the documented authoring entry + point, compiled `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean — + and **#4227** then named both parameters on the six remaining recursive + schemas. + +Both fixes are correct and both are currently unpinned. #4227 considered a +`.d.ts`-level scanner and declined it for a good reason: separating a deliberate +single-parameter `z.ZodType` (the generic in `contracts/llm-adapter.ts` takes +a caller-supplied schema, where the input side is nobody's business) from an +omission needs heuristics on emitted type names, and `check-exported-any.ts`'s +own rule is zero false positives so red keeps meaning broken. Its commit +nominated #4221's assertion-file pattern instead. This is that pattern, applied +to the eight schemas #4221 did not cover: `QuerySchema`, `JoinNodeSchema`, +`FieldNodeSchema`, `FilterConditionSchema`, `NormalizedFilterSchema`, +`StateNodeSchema`, `ValidationRuleSchema`, `FormFieldSchema`. + +`src/recursive-schema-input-assertions.ts` gives each one a positive probe (the +authoring shape still compiles — guarding an input type drawn too tight) and a +negative probe reached **through `z.input`**, the way a consumer +gets there. The negative is load-bearing: it is a value `unknown` would accept +and the real type rejects, so dropping a type parameter turns the suppression +unused and `tsc --noEmit` fails on that line, by name. + +Verified by mutation in both shapes a regression can take: + +- `QuerySchema` back to one parameter → the pin fires **and** `JoinNodeSchema` + cascades a type error, because `JoinNodeInput.subquery` is `QueryInput`. +- `StateNodeSchema` back to one parameter → **only** the pin fires. Nothing else + in the package references its input, so without this file that regression is + completely silent. That is the case the file exists for. + +No runtime change, no new public export (`check:api-surface` reports no diff); +the module is referenced by no tsup entry and re-exported by no barrel. + +Also classifies `check:strictness-ledger` in `check-generated.ts`'s ledger. It +landed in #4232 without an entry, so `check:generated` was failing on `main` +itself — the same cross-PR race the `check:variant-docs` entry above it already +documents, now on its second occurrence. diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index 18aebaab09..282ff846f0 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -75,6 +75,14 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ 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`)', }, + // Same cross-PR race as `check:variant-docs` above, now for the second time: + // landed in #4232 with no entry here, so `main` again carries an unclassified + // script and this reconciliation fails on `main` itself. The ledger it audits + // is hand-written prose — there is no generator to name. + { + check: 'check:strictness-ledger', + why: 'audits the hand-written #4001 strictness ledger against the z.object sites in src — no artifact', + }, ]; /** diff --git a/packages/spec/src/recursive-schema-input-assertions.ts b/packages/spec/src/recursive-schema-input-assertions.ts new file mode 100644 index 0000000000..a7472ba87b --- /dev/null +++ b/packages/spec/src/recursive-schema-input-assertions.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Compile-level pins for the INPUT half of every recursive schema (#3786). + * + * A recursive Zod schema cannot infer its own type, so it carries a hand-written + * `z.ZodType<...>` annotation. `z.ZodType` takes TWO type parameters, + * ``, and **`Input` defaults to `unknown`**. Naming only the + * first is the path of least resistance, it compiles, it validates correctly at + * runtime — and it silently un-types every authoring path through the schema, + * because `unknown` accepts everything. + * + * That has now happened twice in this package, and the second time is the reason + * this file exists: + * + * 1. #4171 found `z.ZodType` on the nav union and replaced it with + * `z.ZodType` — fixing the OUTPUT half and leaving `Input` at + * its default. `check-exported-any.ts` was built to hold that fix, and it + * reads output only, so it reported green over the remaining half. + * 2. #4221 found the consequence: `defineApp(config: z.input)`, + * the documented authoring entry point, compiled + * `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean. #4227 then + * named both parameters on the six remaining recursive schemas. + * + * ## Why probes and not a scanner + * + * #4227 considered a `.d.ts`-level gate and declined it, correctly: separating a + * deliberate `z.ZodType` (the generic parameter in `contracts/llm-adapter.ts` + * takes a caller-supplied schema, where the input side is genuinely nobody's + * business) from an omission needs heuristics on emitted type names, and + * `check-exported-any.ts`'s own rule is zero false positives so red keeps + * meaning broken. Its commit nominated #4221's assertion pattern instead. This + * is that pattern, applied to the eight schemas #4221 did not cover. + * + * Each schema gets two probes. The **negative** one is load-bearing: it is a + * value `unknown` would accept and the real input type rejects, so the moment a + * type parameter goes missing the suppression becomes unused and `tsc` fails on + * this line. The positive one guards the opposite error — an input type so tight + * that legitimate authoring stops compiling. + * + * Why a plain src module and not a test: `packages/spec/tsconfig.json` excludes + * every test file, and the CI gate for this package is `tsc --noEmit` over the + * `src` tree (lint.yml), so probes must live in a non-test src file to be checked + * at all. Nothing imports this module and no barrel re-exports it, so it adds + * nothing to any build. + * + * `NavigationItemSchema` is deliberately absent — `ui/app.nav-type-assertions.ts` + * covers it in more detail, including its per-branch recursion knot. + */ + +import type { z } from 'zod'; + +import type { FormFieldInput, FormFieldSchema } from './ui/view.zod'; +import type { StateNodeConfig, StateNodeSchema } from './automation/state-machine.zod'; +import type { BaseValidationRuleShape, ValidationRuleSchema } from './data/validation.zod'; +import type { + FilterCondition, + FilterConditionSchema, + NormalizedFilter, + NormalizedFilterSchema, +} from './data/filter.zod'; +import type { + FieldNode, + FieldNodeSchema, + JoinNodeInput, + JoinNodeSchema, + QueryInput, + QuerySchema, +} from './data/query.zod'; + +/* ── data/query.zod.ts ─────────────────────────────────────────────────────── */ + +/** The authoring shape: only `object` is required, `expand` recurses. */ +export const queryInput: QueryInput = { + object: 'account', + fields: ['name', { field: 'owner', fields: ['email'] }], + expand: { owner: { object: 'user', fields: ['name'] } }, +}; + +// @ts-expect-error — a query is not a string (`unknown` would take it) +export const queryNotAString: QueryInput = 'not a query at all'; + +// @ts-expect-error — `object` is required; nothing else can stand in for it +export const queryNeedsObject: QueryInput = { fields: ['name'] }; + +/** A join carries its ON condition and may join a derived subquery. */ +export const joinInput: JoinNodeInput = { + type: 'inner', + object: 'contact', + on: { account_id: 'id' }, + subquery: { object: 'account' }, +}; + +// @ts-expect-error — a join node is not a number +export const joinNotANumber: JoinNodeInput = 42; + +// @ts-expect-error — `type` must be one of the four declared join kinds +export const joinBadType: JoinNodeInput = { type: 'sideways', object: 'c', on: {} }; + +/** A select entry is a bare name or a nested relation select. */ +export const fieldNodeString: FieldNode = 'name'; +export const fieldNodeNested: FieldNode = { field: 'owner', fields: ['email'], alias: 'o' }; + +// @ts-expect-error — a select entry is not a number +export const fieldNodeNotANumber: FieldNode = 42; + +/* ── data/filter.zod.ts ────────────────────────────────────────────────────── */ + +/** + * NOTE the weak pin here. `FilterCondition` carries `[key: string]: any` because + * implicit equality (`{ name: 'x' }`) genuinely accepts any value, so the type + * admits every object shape. What it still rejects — and what these probes + * therefore assert — is a non-object. That is enough to catch the regression + * this file guards (`unknown` accepts primitives too) and no more; the index + * signature is a separate, pre-existing looseness. + */ +export const filterInput: FilterCondition = { + name: 'Acme', + amount: { $gt: 100 }, + $or: [{ stage: 'won' }, { stage: 'lost' }], +}; + +// @ts-expect-error — a filter is not a number +export const filterNotANumber: FilterCondition = 42; + +/** The normalized AST is properly closed — a stronger pin than the above. */ +export const normalizedInput: NormalizedFilter = { + $and: [{ name: { $eq: 'Acme' } }, { $or: [{ stage: { $eq: 'won' } }] }], +}; + +// @ts-expect-error — a normalized filter is not a string +export const normalizedNotAString: NormalizedFilter = 'nope'; + +// @ts-expect-error — only `$and` / `$or` / `$not` are declared at this level +export const normalizedBadKey: NormalizedFilter = { $nand: [] }; + +/* ── automation/state-machine.zod.ts ───────────────────────────────────────── */ + +/** Every key is optional; `states` recurses. */ +export const stateInput: StateNodeConfig = { + type: 'compound', + initial: 'draft', + states: { + draft: { type: 'atomic', on: { SUBMIT: 'review' } }, + review: { type: 'final' }, + }, +}; + +// @ts-expect-error — a state node is not a string +export const stateNotAString: StateNodeConfig = 'draft'; + +// @ts-expect-error — `type` must be one of the five declared state kinds +export const stateBadType: StateNodeConfig = { type: 'pending' }; + +/* ── data/validation.zod.ts ────────────────────────────────────────────────── */ + +/** + * `type` / `name` / `message` are required. Like `FilterCondition`, this shape + * carries an index signature (`[key: string]: unknown`), so it types the known + * keys and admits others — the required three are what the pin can assert. + */ +export const validationInput: BaseValidationRuleShape = { + type: 'expression', + name: 'amount_positive', + message: 'Amount must be positive', + severity: 'error', +}; + +// @ts-expect-error — a validation rule is not a number +export const validationNotANumber: BaseValidationRuleShape = 42; + +// @ts-expect-error — `message` is required alongside `type` and `name` +export const validationNeedsMessage: BaseValidationRuleShape = { + type: 'expression', + name: 'amount_positive', +}; + +/* ── ui/view.zod.ts ────────────────────────────────────────────────────────── */ + +/** Only `field` is required; `fields` recurses for composite/repeater types. */ +export const formFieldInput: FormFieldInput = { + field: 'line_items', + type: 'repeater', + fields: [{ field: 'product' }, { field: 'qty' }], +}; + +// @ts-expect-error — a form field is not a string +export const formFieldNotAString: FormFieldInput = 'line_items'; + +// @ts-expect-error — `field` is required +export const formFieldNeedsField: FormFieldInput = { type: 'text' }; + +/* ── The wiring ──────────────────────────────────────────────────────────────── + * + * Everything above pins the exported input TYPES. None of it pins the thing that + * actually broke twice: whether each SCHEMA is annotated with its input type at + * all. A perfectly-written `QueryInput` that `QuerySchema` does not reference + * leaves `z.input` at `unknown` and every authoring path + * unchecked — the exact state #4221 and #4227 fixed. + * + * So these go through `z.input`, the way a consumer reaches it. + * Dropping a second type parameter turns each into an unused suppression. + * ──────────────────────────────────────────────────────────────────────────── */ + +// @ts-expect-error — QuerySchema's input is checked, not `unknown` +export const wiredQuery: z.input = 42; + +// @ts-expect-error — JoinNodeSchema's input is checked +export const wiredJoin: z.input = 42; + +// @ts-expect-error — FieldNodeSchema's input is checked +export const wiredFieldNode: z.input = 42; + +// @ts-expect-error — FilterConditionSchema's input is checked +export const wiredFilter: z.input = 42; + +// @ts-expect-error — NormalizedFilterSchema's input is checked +export const wiredNormalized: z.input = 42; + +// @ts-expect-error — StateNodeSchema's input is checked +export const wiredState: z.input = 42; + +// @ts-expect-error — ValidationRuleSchema's input is checked +export const wiredValidation: z.input = 42; + +// @ts-expect-error — FormFieldSchema's input is checked +export const wiredFormField: z.input = 42;