|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Compile-level pins for the hand-tied navigation types (#3786). |
| 5 | + * |
| 6 | + * `NavigationItem` / `NavigationItemInput` are the one part of the navigation |
| 7 | + * contract that does NOT derive from a schema — they are the annotation |
| 8 | + * `NavigationItemSchema`'s recursion needs, so they cannot be inferred back out |
| 9 | + * of it. Anything hand-written next to a schema is a second description of the |
| 10 | + * same key set, and a second description drifts. |
| 11 | + * |
| 12 | + * It already has, twice, both silently: |
| 13 | + * |
| 14 | + * 1. #4171 — the annotation was `z.ZodType<any>`, so the exported |
| 15 | + * `NavigationItem` was `any`. A consumer importing it got a type that |
| 16 | + * constrains nothing, which reads exactly like a type that works. |
| 17 | + * 2. The `Input` half of that same annotation stayed at its `unknown` default |
| 18 | + * even after #4171 typed the `Output` half — so `z.input<typeof AppSchema>` |
| 19 | + * left `navigation` unchecked, and `defineApp` (the documented authoring |
| 20 | + * entry point, `config: z.input<typeof AppSchema>`) accepted |
| 21 | + * `navigation: [{ totally: 'made up' }, 42, 'nonsense']` with a clean |
| 22 | + * compile. The #4171 fix was verified through `z.infer`; nobody re-measured |
| 23 | + * `z.input`, and half a fix looks identical to a whole one. |
| 24 | + * |
| 25 | + * Both failures share the shape this file exists to break: a type that accepts |
| 26 | + * too much reports nothing. A runtime test cannot catch either — the schema |
| 27 | + * parses correctly in both cases; it is the compile-time contract that lies. |
| 28 | + * |
| 29 | + * Why a plain src module and not a test: `packages/spec/tsconfig.json` EXCLUDES |
| 30 | + * every test file, and the CI gate for this package is `tsc --noEmit` over the |
| 31 | + * `src` tree (lint.yml), so probes must live in a non-test src file to be |
| 32 | + * checked at all. The file is referenced by no tsup entry and re-exported by no |
| 33 | + * barrel, so it adds nothing to any build; it exists purely to make |
| 34 | + * `tsc --noEmit` fail when the types and the schema drift apart again. |
| 35 | + * |
| 36 | + * The `@ts-expect-error` probes assert the NEGATIVE direction — if the type |
| 37 | + * ever starts accepting those shapes, the now-unused suppression becomes the |
| 38 | + * compile error. They are the half that catches "accepts too much", which is |
| 39 | + * the half both real failures landed in. |
| 40 | + */ |
| 41 | + |
| 42 | +import type { AppInput, NavigationItem, NavigationItemInput } from './app.zod'; |
| 43 | + |
| 44 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 45 | + * Output side (`z.infer`) — what a parse RETURNS. |
| 46 | + * Defaulted keys are present, so they are required here. |
| 47 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 48 | + |
| 49 | +const asItem = (x: NavigationItem): NavigationItem => x; |
| 50 | + |
| 51 | +/** A group holding children, including a group nested under a group. */ |
| 52 | +export const groupWithChildren: NavigationItem = { |
| 53 | + id: 'group_probe', |
| 54 | + type: 'group', |
| 55 | + label: 'Probe', |
| 56 | + expanded: false, |
| 57 | + children: [ |
| 58 | + { id: 'child_page', type: 'page', label: 'Child', pageName: 'probe_page' }, |
| 59 | + { id: 'nested_group', type: 'group', label: 'Nested', expanded: true, children: [] }, |
| 60 | + ], |
| 61 | +}; |
| 62 | + |
| 63 | +/** An object item nesting its views — `children` stays OPTIONAL on this branch. */ |
| 64 | +export const objectWithChildren: NavigationItem = { |
| 65 | + id: 'obj_probe', |
| 66 | + type: 'object', |
| 67 | + label: 'Probe', |
| 68 | + objectName: 'probe_object', |
| 69 | + children: [{ id: 'obj_view', type: 'object', label: 'View', objectName: 'probe_object' }], |
| 70 | +}; |
| 71 | + |
| 72 | +/** The same branch without children — legal, unlike `group`. */ |
| 73 | +export const objectWithoutChildren: NavigationItem = { |
| 74 | + id: 'obj_bare', |
| 75 | + type: 'object', |
| 76 | + label: 'Bare', |
| 77 | + objectName: 'probe_object', |
| 78 | +}; |
| 79 | + |
| 80 | +/** |
| 81 | + * `expanded` is REQUIRED on the output side: `GroupNavItemSchema` declares it |
| 82 | + * `.default(false)`, so a parsed group always carries it. If this suppression |
| 83 | + * goes unused the default was dropped from the schema — and every consumer |
| 84 | + * reading `item.expanded` off a parsed app silently started getting |
| 85 | + * `undefined`. |
| 86 | + */ |
| 87 | +// @ts-expect-error — parsed groups always carry `expanded` |
| 88 | +asItem({ id: 'group_bare', type: 'group', label: 'Bare', children: [] }); |
| 89 | + |
| 90 | +/** Only the `object` and `group` branches nest. */ |
| 91 | +// @ts-expect-error — `children` exists on no other branch |
| 92 | +asItem({ id: 'page_probe', type: 'page', label: 'Probe', pageName: 'probe_page', children: [] }); |
| 93 | + |
| 94 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 95 | + * Input side (`z.input`) — what an AUTHOR writes. |
| 96 | + * Defaulted keys are omissible; everything else is still checked. |
| 97 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 98 | + |
| 99 | +const asInput = (x: NavigationItemInput): NavigationItemInput => x; |
| 100 | + |
| 101 | +/** |
| 102 | + * The authoring shape: defaulted keys omitted. This is what cloud's admin app |
| 103 | + * and every downstream `defineApp` caller writes. Under the `unknown` input |
| 104 | + * this compiled for the uninteresting reason — everything did. |
| 105 | + */ |
| 106 | +export const inputOmitsDefaults: NavigationItemInput = { |
| 107 | + id: 'group_probe', |
| 108 | + type: 'group', |
| 109 | + label: 'Probe', |
| 110 | + children: [ |
| 111 | + // `target` is `.default('_self')`, so it is omissible here and required above. |
| 112 | + { id: 'link', type: 'url', label: 'Link', url: 'https://example.com' }, |
| 113 | + ], |
| 114 | +}; |
| 115 | + |
| 116 | +/** Supplying a defaulted key explicitly stays legal. */ |
| 117 | +export const inputStatesDefaults: NavigationItemInput = { |
| 118 | + id: 'group_probe2', |
| 119 | + type: 'group', |
| 120 | + label: 'Probe', |
| 121 | + expanded: true, |
| 122 | + children: [], |
| 123 | +}; |
| 124 | + |
| 125 | +/** |
| 126 | + * The load-bearing negatives. Each of these compiled clean while `Input` sat at |
| 127 | + * `unknown` — that is the whole bug, so these are the assertions that would |
| 128 | + * have caught it. |
| 129 | + */ |
| 130 | +// @ts-expect-error — a nav item is not an arbitrary record |
| 131 | +asInput({ totally: 'made up', not: 'a nav item' }); |
| 132 | + |
| 133 | +// @ts-expect-error — a nav item is not a number |
| 134 | +asInput(42); |
| 135 | + |
| 136 | +// @ts-expect-error — a nav item is not a string |
| 137 | +asInput('nonsense'); |
| 138 | + |
| 139 | +// @ts-expect-error — `type` must be one of the nine declared discriminants |
| 140 | +asInput({ id: 'bad_type', type: 'not_a_variant', label: 'X' }); |
| 141 | + |
| 142 | +// @ts-expect-error — `group` requires `children` on both sides |
| 143 | +asInput({ id: 'group_bare', type: 'group', label: 'Bare' }); |
| 144 | + |
| 145 | +// @ts-expect-error — the members are `.strict()`; an undeclared key is not authoring |
| 146 | +asInput({ id: 'grp', type: 'group', label: 'G', children: [], defaultOpen: true }); |
| 147 | + |
| 148 | +// @ts-expect-error — only `object` and `group` nest, on this side too |
| 149 | +asInput({ id: 'page_probe', type: 'page', label: 'P', pageName: 'probe_page', children: [] }); |
| 150 | + |
| 151 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 152 | + * The halves must stay DIFFERENT. |
| 153 | + * |
| 154 | + * If someone "simplifies" by pointing both parameters at one union, the |
| 155 | + * defaulted keys go wrong in one direction or the other: authors are forced to |
| 156 | + * write `expanded`, or consumers stop being able to read it. These two pin the |
| 157 | + * asymmetry itself. |
| 158 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 159 | + |
| 160 | +/** Output is assignable to input — a parsed tree can be re-authored. */ |
| 161 | +export const outputFeedsInput: NavigationItemInput = groupWithChildren; |
| 162 | + |
| 163 | +/** Input is NOT assignable to output: the defaults have not been applied yet. */ |
| 164 | +// @ts-expect-error — `NavigationItemInput` is the looser half, by design |
| 165 | +asItem(inputOmitsDefaults); |
| 166 | + |
| 167 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 168 | + * The WIRING — the assertions that actually catch the #4171 half-fix. |
| 169 | + * |
| 170 | + * Everything above pins the two unions. None of it pins the thing that broke: |
| 171 | + * whether `NavigationItemSchema` is annotated with `NavigationItemInput` at |
| 172 | + * all. A perfectly-written `NavigationItemInput` that no schema references |
| 173 | + * leaves `z.input<typeof AppSchema>` at `unknown` and every authoring path |
| 174 | + * unchecked — which is precisely the state this file was written to end. |
| 175 | + * |
| 176 | + * These go through `AppInput`, the type `defineApp` takes, so they fail the |
| 177 | + * moment the annotation loses its second parameter (verified by mutation: |
| 178 | + * dropping it back to `z.ZodType<NavigationItem>` turns all three into unused |
| 179 | + * suppressions). |
| 180 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 181 | + |
| 182 | +const asAppInput = (x: AppInput): AppInput => x; |
| 183 | + |
| 184 | +/** The shape every downstream author writes — defaulted keys omitted. */ |
| 185 | +export const appInputOmitsDefaults: AppInput = { |
| 186 | + name: 'probe_app', |
| 187 | + label: 'Probe', |
| 188 | + navigation: [{ id: 'grp', type: 'group', label: 'G', children: [] }], |
| 189 | +}; |
| 190 | + |
| 191 | +// @ts-expect-error — nav entries are checked; `unknown` would swallow this |
| 192 | +asAppInput({ name: 'probe_app', label: 'Probe', navigation: [42] }); |
| 193 | + |
| 194 | +// @ts-expect-error — …and this |
| 195 | +asAppInput({ name: 'probe_app', label: 'Probe', navigation: [{ totally: 'made up' }] }); |
| 196 | + |
| 197 | +// NOTE: kept on one line — `@ts-expect-error` suppresses the NEXT LINE only, and |
| 198 | +// the excess-property error lands on the nav entry, not on the opening `asAppInput(`. |
| 199 | +// @ts-expect-error — …and this, the strictness the nav members declare |
| 200 | +asAppInput({ name: 'p', label: 'P', navigation: [{ id: 'grp', type: 'group', label: 'G', children: [], defaultOpen: true }] }); |
0 commit comments