|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Self-consistency assertions for the generated OpenAPI document (#5168). |
| 5 | + * |
| 6 | + * ## The gap this closes |
| 7 | + * |
| 8 | + * `gen:openapi` is one of the two completely ungated generators in the repo |
| 9 | + * (`check:generated`'s own closing line names it: "Generated but ungated (2): |
| 10 | + * gen:openapi, gen:sbom"). Ungated in BOTH senses — nothing verifies the |
| 11 | + * artifact is current, and nothing verified it was even internally coherent. |
| 12 | + * |
| 13 | + * #5168 is what the second gap costs. Every one of the nine contract schemas |
| 14 | + * is wrapped in `lazySchema()`, whose Proxy target is `function lazyZod() {}`, |
| 15 | + * so `typeof schema === 'function'`. The collector's guard led with |
| 16 | + * `typeof schema === 'object'`, short-circuited on all nine, and emitted |
| 17 | + * `components.schemas: {}` — while the hand-written `$ref` literals in `paths` |
| 18 | + * were written out regardless. The published document therefore carried six |
| 19 | + * `$ref`s pointing at nothing, covering the request and response bodies of |
| 20 | + * every CRUD operation, and the failure was visible three ways at once (empty |
| 21 | + * components, dangling refs, a `Components: 0` line printed to the console) |
| 22 | + * without a single thing going red. |
| 23 | + * |
| 24 | + * A "the artifact is coherent" assertion is cheaper than a "the artifact is |
| 25 | + * current" one and catches strictly this class: it needs no baseline, no |
| 26 | + * committed snapshot (`packages/spec/json-schema/` is gitignored and rebuilt |
| 27 | + * on every `pnpm build`), and it covers `$ref`s added in the future for free. |
| 28 | + * |
| 29 | + * ## The two rules |
| 30 | + * |
| 31 | + * 1. **Every local `$ref` resolves.** Any `$ref` beginning with `#/` is a JSON |
| 32 | + * Pointer into this same document; if it does not resolve, the document is |
| 33 | + * broken for every consumer that parses it (Scalar's viewer at |
| 34 | + * `GET /api/v1/docs`, and any client generator pointed at |
| 35 | + * `GET /api/v1/openapi.json`). Resolution is by pointer rather than by a |
| 36 | + * `#/components/schemas/` prefix match so that a future `#/$defs/…` ref |
| 37 | + * is covered without touching this file. |
| 38 | + * 2. **No schema is silently degraded.** See `assertNoDegradedSchemas`. |
| 39 | + * |
| 40 | + * Both are consulted BEFORE the document is written: a self-inconsistent |
| 41 | + * artifact is never emitted at all, rather than emitted and then complained |
| 42 | + * about. The gate is wired into the generator itself (not a separate `check:` |
| 43 | + * script) because the artifact is regenerated on every build — a standalone |
| 44 | + * checker would have to run the generator first to have anything to check. |
| 45 | + */ |
| 46 | + |
| 47 | +/** One unresolvable `$ref`, with the document location that carried it. */ |
| 48 | +export interface DanglingRef { |
| 49 | + /** The `$ref` value verbatim, e.g. `#/components/schemas/ApiError`. */ |
| 50 | + ref: string; |
| 51 | + /** Where it appeared, as a readable path: `paths./api/{object}.get.…`. */ |
| 52 | + at: string; |
| 53 | +} |
| 54 | + |
| 55 | +/** |
| 56 | + * Resolve a JSON Pointer (RFC 6901) against `root`. |
| 57 | + * |
| 58 | + * Returns `undefined` when any segment is missing. `~1` decodes to `/` and |
| 59 | + * `~0` to `~`, in that order — reversing the order corrupts a literal `~1`. |
| 60 | + */ |
| 61 | +function resolvePointer(root: unknown, pointer: string): unknown { |
| 62 | + // '#' alone addresses the whole document. |
| 63 | + if (pointer === '#' || pointer === '#/') return root; |
| 64 | + |
| 65 | + const segments = pointer |
| 66 | + .slice(2) // drop the leading '#/' |
| 67 | + .split('/') |
| 68 | + .map((s) => decodeURIComponent(s).replace(/~1/g, '/').replace(/~0/g, '~')); |
| 69 | + |
| 70 | + let node: unknown = root; |
| 71 | + for (const segment of segments) { |
| 72 | + if (node === null || typeof node !== 'object') return undefined; |
| 73 | + const container = node as Record<string, unknown>; |
| 74 | + if (!Object.prototype.hasOwnProperty.call(container, segment)) return undefined; |
| 75 | + node = container[segment]; |
| 76 | + } |
| 77 | + return node; |
| 78 | +} |
| 79 | + |
| 80 | +/** |
| 81 | + * Collect every local (`#/…`) `$ref` in `doc` that does not resolve. |
| 82 | + * |
| 83 | + * External refs (`https://…`, `./other.json#/…`) are out of scope — this |
| 84 | + * document has never contained one, and resolving them would mean fetching. |
| 85 | + * They are simply not reported either way. |
| 86 | + */ |
| 87 | +export function findDanglingRefs(doc: unknown): DanglingRef[] { |
| 88 | + const dangling: DanglingRef[] = []; |
| 89 | + const seen = new Set<unknown>(); |
| 90 | + |
| 91 | + const walk = (node: unknown, at: string): void => { |
| 92 | + if (node === null || typeof node !== 'object') return; |
| 93 | + // Generated documents are trees, but guard against a cycle regardless: |
| 94 | + // an unguarded walk would hang the build instead of failing it. |
| 95 | + if (seen.has(node)) return; |
| 96 | + seen.add(node); |
| 97 | + |
| 98 | + if (Array.isArray(node)) { |
| 99 | + node.forEach((item, i) => walk(item, `${at}[${i}]`)); |
| 100 | + return; |
| 101 | + } |
| 102 | + |
| 103 | + for (const [key, value] of Object.entries(node as Record<string, unknown>)) { |
| 104 | + const here = at ? `${at}.${key}` : key; |
| 105 | + if (key === '$ref' && typeof value === 'string') { |
| 106 | + if (value.startsWith('#') && resolvePointer(doc, value) === undefined) { |
| 107 | + dangling.push({ ref: value, at }); |
| 108 | + } |
| 109 | + continue; |
| 110 | + } |
| 111 | + walk(value, here); |
| 112 | + } |
| 113 | + }; |
| 114 | + |
| 115 | + walk(doc, ''); |
| 116 | + return dangling; |
| 117 | +} |
| 118 | + |
| 119 | +/** |
| 120 | + * Throw when any `$ref` in `doc` dangles. Message names every offender and the |
| 121 | + * schemas that WERE defined, because "which of the two sides is empty" is the |
| 122 | + * first thing a reader needs (in #5168 the defined side was `[]` entirely). |
| 123 | + */ |
| 124 | +export function assertRefsResolve(doc: unknown): void { |
| 125 | + const dangling = findDanglingRefs(doc); |
| 126 | + if (dangling.length === 0) return; |
| 127 | + |
| 128 | + const defined = Object.keys( |
| 129 | + ((doc as Record<string, any>)?.components?.schemas ?? {}) as Record<string, unknown>, |
| 130 | + ); |
| 131 | + |
| 132 | + const lines = dangling.map((d) => ` - ${d.ref} (referenced at ${d.at || '<root>'})`); |
| 133 | + throw new Error( |
| 134 | + `OpenAPI document is not self-consistent: ${dangling.length} unresolvable $ref(s).\n` + |
| 135 | + `${lines.join('\n')}\n` + |
| 136 | + ` defined components.schemas: [${defined.join(', ') || '<none>'}]\n` + |
| 137 | + `\n` + |
| 138 | + ` A $ref that resolves to nothing breaks every consumer of the published\n` + |
| 139 | + ` document (the Scalar viewer at GET /api/v1/docs renders an empty schema\n` + |
| 140 | + ` panel; client generators fail at parse time). If components.schemas is\n` + |
| 141 | + ` empty, the collector in build-openapi.ts skipped its inputs — note that\n` + |
| 142 | + ` lazySchema() returns a Proxy whose typeof is 'function', not 'object'\n` + |
| 143 | + ` (#5168).`, |
| 144 | + ); |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Throw when any contract schema was dropped or degraded during collection. |
| 149 | + * |
| 150 | + * `build-openapi.ts` names its nine contract schemas in a literal table, so a |
| 151 | + * name that fails to convert is never a "this one is optional" — it is an |
| 152 | + * export that moved, was renamed, or stopped being a Zod schema. The original |
| 153 | + * loop expressed that as `if (looks-like-zod) { emit }` with no `else`, which |
| 154 | + * is precisely how nine silent skips published an empty `components.schemas`. |
| 155 | + * Declared here therefore means enforced: every declared name must produce a |
| 156 | + * real converted schema, or the build fails naming the ones that did not. |
| 157 | + */ |
| 158 | +export function assertNoDegradedSchemas( |
| 159 | + declared: readonly string[], |
| 160 | + emitted: Readonly<Record<string, unknown>>, |
| 161 | + degraded: readonly string[], |
| 162 | +): void { |
| 163 | + const missing = declared.filter((name) => !(name in emitted)); |
| 164 | + if (missing.length === 0 && degraded.length === 0) return; |
| 165 | + |
| 166 | + const parts: string[] = ['OpenAPI component schema collection is incomplete.']; |
| 167 | + if (missing.length > 0) { |
| 168 | + parts.push( |
| 169 | + ` not emitted at all (${missing.length}): ${missing.join(', ')}\n` + |
| 170 | + ` The export is missing, renamed, or is not a Zod schema. Note that a\n` + |
| 171 | + ` lazySchema() Proxy has typeof 'function' — a guard demanding\n` + |
| 172 | + ` typeof 'object' rejects every one of them (#5168).`, |
| 173 | + ); |
| 174 | + } |
| 175 | + if (degraded.length > 0) { |
| 176 | + parts.push( |
| 177 | + ` converted to a placeholder (${degraded.length}): ${degraded.join(', ')}\n` + |
| 178 | + ` z.toJSONSchema() threw for these. Publishing a bare {type:'object'}\n` + |
| 179 | + ` in their place would ship a contract that describes nothing while\n` + |
| 180 | + ` looking complete — fix the schema instead.`, |
| 181 | + ); |
| 182 | + } |
| 183 | + throw new Error(parts.join('\n')); |
| 184 | +} |
0 commit comments