Skip to content

Commit 7b7b48d

Browse files
Claudeclaude
andcommitted
test(spec): pin the input half of every recursive schema (#3786)
A recursive Zod schema carries a hand-written `z.ZodType<...>` annotation. `z.ZodType` takes <Output, Input> 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<T>` (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<typeof Schema>` — 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UXGj3Z5TmwSV6RK2oGc3cb
1 parent 19365b7 commit 7b7b48d

3 files changed

Lines changed: 291 additions & 0 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
test(spec): pin the input half of every recursive schema, so the third omission fails instead of shipping (#3786)
6+
7+
A recursive Zod schema cannot infer its own type, so it carries a hand-written
8+
`z.ZodType<...>` annotation. `z.ZodType` takes **two** type parameters,
9+
`<Output, Input>`, and `Input` defaults to `unknown`. Naming only the first
10+
compiles, validates correctly at runtime, and silently un-types every authoring
11+
path through the schema — `unknown` accepts everything.
12+
13+
This package has made that mistake twice:
14+
15+
1. **#4171** replaced `z.ZodType<any>` on the nav union with
16+
`z.ZodType<NavigationItem>`, fixing the output half. `check-exported-any.ts`
17+
was built to hold that fix and reads output only, so it reported green over
18+
the half that was still broken.
19+
2. **#4221** found the consequence — `defineApp`, the documented authoring entry
20+
point, compiled `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean —
21+
and **#4227** then named both parameters on the six remaining recursive
22+
schemas.
23+
24+
Both fixes are correct and both are currently unpinned. #4227 considered a
25+
`.d.ts`-level scanner and declined it for a good reason: separating a deliberate
26+
single-parameter `z.ZodType<T>` (the generic in `contracts/llm-adapter.ts` takes
27+
a caller-supplied schema, where the input side is nobody's business) from an
28+
omission needs heuristics on emitted type names, and `check-exported-any.ts`'s
29+
own rule is zero false positives so red keeps meaning broken. Its commit
30+
nominated #4221's assertion-file pattern instead. This is that pattern, applied
31+
to the eight schemas #4221 did not cover: `QuerySchema`, `JoinNodeSchema`,
32+
`FieldNodeSchema`, `FilterConditionSchema`, `NormalizedFilterSchema`,
33+
`StateNodeSchema`, `ValidationRuleSchema`, `FormFieldSchema`.
34+
35+
`src/recursive-schema-input-assertions.ts` gives each one a positive probe (the
36+
authoring shape still compiles — guarding an input type drawn too tight) and a
37+
negative probe reached **through `z.input<typeof Schema>`**, the way a consumer
38+
gets there. The negative is load-bearing: it is a value `unknown` would accept
39+
and the real type rejects, so dropping a type parameter turns the suppression
40+
unused and `tsc --noEmit` fails on that line, by name.
41+
42+
Verified by mutation in both shapes a regression can take:
43+
44+
- `QuerySchema` back to one parameter → the pin fires **and** `JoinNodeSchema`
45+
cascades a type error, because `JoinNodeInput.subquery` is `QueryInput`.
46+
- `StateNodeSchema` back to one parameter → **only** the pin fires. Nothing else
47+
in the package references its input, so without this file that regression is
48+
completely silent. That is the case the file exists for.
49+
50+
No runtime change, no new public export (`check:api-surface` reports no diff);
51+
the module is referenced by no tsup entry and re-exported by no barrel.
52+
53+
Also classifies `check:strictness-ledger` in `check-generated.ts`'s ledger. It
54+
landed in #4232 without an entry, so `check:generated` was failing on `main`
55+
itself — the same cross-PR race the `check:variant-docs` entry above it already
56+
documents, now on its second occurrence.

packages/spec/scripts/check-generated.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [
7575
check: 'check:exported-any',
7676
why: 'audits the built .d.ts for exported types/schemas that resolve to `any` — no artifact (needs a fresh `pnpm build`)',
7777
},
78+
// Same cross-PR race as `check:variant-docs` above, now for the second time:
79+
// landed in #4232 with no entry here, so `main` again carries an unclassified
80+
// script and this reconciliation fails on `main` itself. The ledger it audits
81+
// is hand-written prose — there is no generator to name.
82+
{
83+
check: 'check:strictness-ledger',
84+
why: 'audits the hand-written #4001 strictness ledger against the z.object sites in src — no artifact',
85+
},
7886
];
7987

8088
/**
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Compile-level pins for the INPUT half of every recursive schema (#3786).
5+
*
6+
* A recursive Zod schema cannot infer its own type, so it carries a hand-written
7+
* `z.ZodType<...>` annotation. `z.ZodType` takes TWO type parameters,
8+
* `<Output, Input>`, and **`Input` defaults to `unknown`**. Naming only the
9+
* first is the path of least resistance, it compiles, it validates correctly at
10+
* runtime — and it silently un-types every authoring path through the schema,
11+
* because `unknown` accepts everything.
12+
*
13+
* That has now happened twice in this package, and the second time is the reason
14+
* this file exists:
15+
*
16+
* 1. #4171 found `z.ZodType<any>` on the nav union and replaced it with
17+
* `z.ZodType<NavigationItem>` — fixing the OUTPUT half and leaving `Input` at
18+
* its default. `check-exported-any.ts` was built to hold that fix, and it
19+
* reads output only, so it reported green over the remaining half.
20+
* 2. #4221 found the consequence: `defineApp(config: z.input<typeof AppSchema>)`,
21+
* the documented authoring entry point, compiled
22+
* `navigation: [{ totally: 'made up' }, 42, 'nonsense']` clean. #4227 then
23+
* named both parameters on the six remaining recursive schemas.
24+
*
25+
* ## Why probes and not a scanner
26+
*
27+
* #4227 considered a `.d.ts`-level gate and declined it, correctly: separating a
28+
* deliberate `z.ZodType<T>` (the generic parameter in `contracts/llm-adapter.ts`
29+
* takes a caller-supplied schema, where the input side is genuinely nobody's
30+
* business) from an omission needs heuristics on emitted type names, and
31+
* `check-exported-any.ts`'s own rule is zero false positives so red keeps
32+
* meaning broken. Its commit nominated #4221's assertion pattern instead. This
33+
* is that pattern, applied to the eight schemas #4221 did not cover.
34+
*
35+
* Each schema gets two probes. The **negative** one is load-bearing: it is a
36+
* value `unknown` would accept and the real input type rejects, so the moment a
37+
* type parameter goes missing the suppression becomes unused and `tsc` fails on
38+
* this line. The positive one guards the opposite error — an input type so tight
39+
* that legitimate authoring stops compiling.
40+
*
41+
* Why a plain src module and not a test: `packages/spec/tsconfig.json` excludes
42+
* every test file, and the CI gate for this package is `tsc --noEmit` over the
43+
* `src` tree (lint.yml), so probes must live in a non-test src file to be checked
44+
* at all. Nothing imports this module and no barrel re-exports it, so it adds
45+
* nothing to any build.
46+
*
47+
* `NavigationItemSchema` is deliberately absent — `ui/app.nav-type-assertions.ts`
48+
* covers it in more detail, including its per-branch recursion knot.
49+
*/
50+
51+
import type { z } from 'zod';
52+
53+
import type { FormFieldInput, FormFieldSchema } from './ui/view.zod';
54+
import type { StateNodeConfig, StateNodeSchema } from './automation/state-machine.zod';
55+
import type { BaseValidationRuleShape, ValidationRuleSchema } from './data/validation.zod';
56+
import type {
57+
FilterCondition,
58+
FilterConditionSchema,
59+
NormalizedFilter,
60+
NormalizedFilterSchema,
61+
} from './data/filter.zod';
62+
import type {
63+
FieldNode,
64+
FieldNodeSchema,
65+
JoinNodeInput,
66+
JoinNodeSchema,
67+
QueryInput,
68+
QuerySchema,
69+
} from './data/query.zod';
70+
71+
/* ── data/query.zod.ts ─────────────────────────────────────────────────────── */
72+
73+
/** The authoring shape: only `object` is required, `expand` recurses. */
74+
export const queryInput: QueryInput = {
75+
object: 'account',
76+
fields: ['name', { field: 'owner', fields: ['email'] }],
77+
expand: { owner: { object: 'user', fields: ['name'] } },
78+
};
79+
80+
// @ts-expect-error — a query is not a string (`unknown` would take it)
81+
export const queryNotAString: QueryInput = 'not a query at all';
82+
83+
// @ts-expect-error — `object` is required; nothing else can stand in for it
84+
export const queryNeedsObject: QueryInput = { fields: ['name'] };
85+
86+
/** A join carries its ON condition and may join a derived subquery. */
87+
export const joinInput: JoinNodeInput = {
88+
type: 'inner',
89+
object: 'contact',
90+
on: { account_id: 'id' },
91+
subquery: { object: 'account' },
92+
};
93+
94+
// @ts-expect-error — a join node is not a number
95+
export const joinNotANumber: JoinNodeInput = 42;
96+
97+
// @ts-expect-error — `type` must be one of the four declared join kinds
98+
export const joinBadType: JoinNodeInput = { type: 'sideways', object: 'c', on: {} };
99+
100+
/** A select entry is a bare name or a nested relation select. */
101+
export const fieldNodeString: FieldNode = 'name';
102+
export const fieldNodeNested: FieldNode = { field: 'owner', fields: ['email'], alias: 'o' };
103+
104+
// @ts-expect-error — a select entry is not a number
105+
export const fieldNodeNotANumber: FieldNode = 42;
106+
107+
/* ── data/filter.zod.ts ────────────────────────────────────────────────────── */
108+
109+
/**
110+
* NOTE the weak pin here. `FilterCondition` carries `[key: string]: any` because
111+
* implicit equality (`{ name: 'x' }`) genuinely accepts any value, so the type
112+
* admits every object shape. What it still rejects — and what these probes
113+
* therefore assert — is a non-object. That is enough to catch the regression
114+
* this file guards (`unknown` accepts primitives too) and no more; the index
115+
* signature is a separate, pre-existing looseness.
116+
*/
117+
export const filterInput: FilterCondition = {
118+
name: 'Acme',
119+
amount: { $gt: 100 },
120+
$or: [{ stage: 'won' }, { stage: 'lost' }],
121+
};
122+
123+
// @ts-expect-error — a filter is not a number
124+
export const filterNotANumber: FilterCondition = 42;
125+
126+
/** The normalized AST is properly closed — a stronger pin than the above. */
127+
export const normalizedInput: NormalizedFilter = {
128+
$and: [{ name: { $eq: 'Acme' } }, { $or: [{ stage: { $eq: 'won' } }] }],
129+
};
130+
131+
// @ts-expect-error — a normalized filter is not a string
132+
export const normalizedNotAString: NormalizedFilter = 'nope';
133+
134+
// @ts-expect-error — only `$and` / `$or` / `$not` are declared at this level
135+
export const normalizedBadKey: NormalizedFilter = { $nand: [] };
136+
137+
/* ── automation/state-machine.zod.ts ───────────────────────────────────────── */
138+
139+
/** Every key is optional; `states` recurses. */
140+
export const stateInput: StateNodeConfig = {
141+
type: 'compound',
142+
initial: 'draft',
143+
states: {
144+
draft: { type: 'atomic', on: { SUBMIT: 'review' } },
145+
review: { type: 'final' },
146+
},
147+
};
148+
149+
// @ts-expect-error — a state node is not a string
150+
export const stateNotAString: StateNodeConfig = 'draft';
151+
152+
// @ts-expect-error — `type` must be one of the five declared state kinds
153+
export const stateBadType: StateNodeConfig = { type: 'pending' };
154+
155+
/* ── data/validation.zod.ts ────────────────────────────────────────────────── */
156+
157+
/**
158+
* `type` / `name` / `message` are required. Like `FilterCondition`, this shape
159+
* carries an index signature (`[key: string]: unknown`), so it types the known
160+
* keys and admits others — the required three are what the pin can assert.
161+
*/
162+
export const validationInput: BaseValidationRuleShape = {
163+
type: 'expression',
164+
name: 'amount_positive',
165+
message: 'Amount must be positive',
166+
severity: 'error',
167+
};
168+
169+
// @ts-expect-error — a validation rule is not a number
170+
export const validationNotANumber: BaseValidationRuleShape = 42;
171+
172+
// @ts-expect-error — `message` is required alongside `type` and `name`
173+
export const validationNeedsMessage: BaseValidationRuleShape = {
174+
type: 'expression',
175+
name: 'amount_positive',
176+
};
177+
178+
/* ── ui/view.zod.ts ────────────────────────────────────────────────────────── */
179+
180+
/** Only `field` is required; `fields` recurses for composite/repeater types. */
181+
export const formFieldInput: FormFieldInput = {
182+
field: 'line_items',
183+
type: 'repeater',
184+
fields: [{ field: 'product' }, { field: 'qty' }],
185+
};
186+
187+
// @ts-expect-error — a form field is not a string
188+
export const formFieldNotAString: FormFieldInput = 'line_items';
189+
190+
// @ts-expect-error — `field` is required
191+
export const formFieldNeedsField: FormFieldInput = { type: 'text' };
192+
193+
/* ── The wiring ────────────────────────────────────────────────────────────────
194+
*
195+
* Everything above pins the exported input TYPES. None of it pins the thing that
196+
* actually broke twice: whether each SCHEMA is annotated with its input type at
197+
* all. A perfectly-written `QueryInput` that `QuerySchema` does not reference
198+
* leaves `z.input<typeof QuerySchema>` at `unknown` and every authoring path
199+
* unchecked — the exact state #4221 and #4227 fixed.
200+
*
201+
* So these go through `z.input<typeof Schema>`, the way a consumer reaches it.
202+
* Dropping a second type parameter turns each into an unused suppression.
203+
* ──────────────────────────────────────────────────────────────────────────── */
204+
205+
// @ts-expect-error — QuerySchema's input is checked, not `unknown`
206+
export const wiredQuery: z.input<typeof QuerySchema> = 42;
207+
208+
// @ts-expect-error — JoinNodeSchema's input is checked
209+
export const wiredJoin: z.input<typeof JoinNodeSchema> = 42;
210+
211+
// @ts-expect-error — FieldNodeSchema's input is checked
212+
export const wiredFieldNode: z.input<typeof FieldNodeSchema> = 42;
213+
214+
// @ts-expect-error — FilterConditionSchema's input is checked
215+
export const wiredFilter: z.input<typeof FilterConditionSchema> = 42;
216+
217+
// @ts-expect-error — NormalizedFilterSchema's input is checked
218+
export const wiredNormalized: z.input<typeof NormalizedFilterSchema> = 42;
219+
220+
// @ts-expect-error — StateNodeSchema's input is checked
221+
export const wiredState: z.input<typeof StateNodeSchema> = 42;
222+
223+
// @ts-expect-error — ValidationRuleSchema's input is checked
224+
export const wiredValidation: z.input<typeof ValidationRuleSchema> = 42;
225+
226+
// @ts-expect-error — FormFieldSchema's input is checked
227+
export const wiredFormField: z.input<typeof FormFieldSchema> = 42;

0 commit comments

Comments
 (0)