Skip to content

Commit 99cf2e6

Browse files
committed
fix(spec): lazySchema Proxy 撞上 typeof === 'object',OpenAPI components.schemas 为空
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D
1 parent ed0d2aa commit 99cf2e6

2 files changed

Lines changed: 216 additions & 7 deletions

File tree

packages/spec/scripts/build-openapi.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod';
77
// Dynamic imports from spec source
88
import * as API from '../src/api';
99
import * as Data from '../src/data';
10+
import { assertRefsResolve, assertNoDegradedSchemas } from './lib/openapi-self-consistency';
1011

1112
const OUT_DIR = path.resolve(__dirname, '../json-schema');
1213
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
@@ -237,7 +238,8 @@ function generateDiscoveryPaths(basePath: string): Record<string, OpenApiPath> {
237238

238239
function generateComponentSchemas(): Record<string, Record<string, unknown>> {
239240
const schemas: Record<string, Record<string, unknown>> = {};
240-
241+
const degraded: string[] = [];
242+
241243
// Map of contract schema names to their Zod schemas
242244
const contractSchemas: Record<string, z.ZodType> = {
243245
CreateRequest: (API as any).CreateRequestSchema,
@@ -252,15 +254,29 @@ function generateComponentSchemas(): Record<string, Record<string, unknown>> {
252254
};
253255

254256
for (const [name, schema] of Object.entries(contractSchemas)) {
255-
if (schema && typeof schema === 'object' && '_zod' in schema) {
256-
try {
257-
schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' });
258-
} catch {
259-
schemas[name] = { type: 'object', description: `${name} (schema too complex for auto-generation)` };
260-
}
257+
// `typeof` must admit BOTH 'object' and 'function': every contract schema
258+
// here is wrapped in `lazySchema()`, whose Proxy target is
259+
// `function lazyZod() {}`, so `typeof schema === 'function'`. Demanding
260+
// 'object' short-circuited all nine and published an empty
261+
// `components.schemas` behind six dangling `$ref`s (#5168). The `_zod`
262+
// half of the guard is Proxy-safe as written — `lazySchema` maintains a
263+
// `_zod` facade precisely so `toJSONSchema` can traverse it.
264+
const isZodLike =
265+
!!schema && (typeof schema === 'object' || typeof schema === 'function') && '_zod' in schema;
266+
if (!isZodLike) continue; // reported by assertNoDegradedSchemas below
267+
268+
try {
269+
schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' });
270+
} catch {
271+
degraded.push(name);
261272
}
262273
}
263274

275+
// Declared = enforced: the table above is a literal list of the contract's
276+
// nine schemas, so a name that produced nothing is a defect, never an
277+
// optional input. Failing here is what makes the #5168 shape unrepeatable.
278+
assertNoDegradedSchemas(Object.keys(contractSchemas), schemas, degraded);
279+
264280
return schemas;
265281
}
266282

@@ -316,6 +332,15 @@ const openapi: Record<string, unknown> = {
316332
],
317333
};
318334

335+
// ─── Self-consistency gate (#5168) ───────────────────────────────────
336+
//
337+
// Runs BEFORE the write, so a document whose `$ref`s do not resolve is never
338+
// emitted at all. `gen:openapi` has no staleness gate (`check:generated`
339+
// reports it as one of the two ungated generators), so this is the only thing
340+
// standing between a silently-broken collector and the published
341+
// `GET /api/v1/openapi.json`. Throwing exits non-zero and fails the build.
342+
assertRefsResolve(openapi);
343+
319344
// Write output
320345
if (!fs.existsSync(OUT_DIR)) {
321346
fs.mkdirSync(OUT_DIR, { recursive: true });
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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

Comments
 (0)