Skip to content

Commit 6e357ed

Browse files
authored
test(spec): pin the input half of every recursive schema, so the third omission fails instead of shipping (#3786) (#4252)
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 half still broken. #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; neither was pinned. #4227 declined a .d.ts scanner for a good reason — separating a deliberate single-parameter `z.ZodType<T>` (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, so the pin covers the WIRING and not just the type alias. 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, since 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. No runtime change and no new public export; 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.
1 parent a648e96 commit 6e357ed

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)