From 7b7b48d8d57ba019802fff2935ed0670bb75246c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 00:47:44 +0000 Subject: [PATCH] test(spec): pin the input half of every recursive schema (#3786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recursive Zod schema carries a hand-written `z.ZodType<...>` annotation. `z.ZodType` takes and Input DEFAULTS TO `unknown`, so naming only the first compiles, validates correctly at runtime, and silently un-types every authoring path through the schema. This package has made that mistake twice. #4171 fixed the output half of the nav annotation and check-exported-any.ts, built to hold that fix, reads output only — so it reported green over the broken half. #4221 found the consequence (`defineApp` compiled `navigation: [{ totally: 'made up' }, 42, 'nonsense']`) and #4227 named both parameters on the six remaining recursive schemas. Both fixes are correct and both are unpinned. #4227 declined a .d.ts scanner for a good reason — separating a deliberate single-parameter `z.ZodType` (the generic in contracts/llm-adapter.ts takes a caller-supplied schema) from an omission needs heuristics on emitted type names, against check-exported-any.ts's zero-false-positive rule — and nominated #4221's assertion pattern instead. This applies it to the eight schemas #4221 did not cover: Query, JoinNode, FieldNode, FilterCondition, NormalizedFilter, StateNode, ValidationRule, FormField. Each gets 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: a value `unknown` would accept and the real type rejects, so dropping a type parameter turns the suppression unused and tsc fails by name. Mutation-tested in both shapes a regression can take: - QuerySchema back to one parameter → the pin fires AND JoinNodeSchema cascades, because JoinNodeInput.subquery is QueryInput. - StateNodeSchema back to one parameter → ONLY the pin fires. Nothing else references its input, so without this file that regression is silent. That is the case the file exists for. No runtime change and 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 with no entry, so check:generated was failing on main itself, the same cross-PR race the check:variant-docs entry above it documents. Verified: tsc --noEmit clean, spec suite 275 files / 7142 tests green, check:exported-any / liveness / strictness-ledger / docs / variant-docs / api-surface / generated all PASS against a fresh DTS build. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb --- .changeset/recursive-schema-input-pins.md | 56 +++++ packages/spec/scripts/check-generated.ts | 8 + .../src/recursive-schema-input-assertions.ts | 227 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 .changeset/recursive-schema-input-pins.md create mode 100644 packages/spec/src/recursive-schema-input-assertions.ts 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;