|
| 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