diff --git a/.changeset/openapi-components-lazyschema-proxy.md b/.changeset/openapi-components-lazyschema-proxy.md new file mode 100644 index 0000000000..ecfeb01f21 --- /dev/null +++ b/.changeset/openapi-components-lazyschema-proxy.md @@ -0,0 +1,21 @@ +--- +"@objectstack/spec": patch +"@objectstack/rest": patch +--- + +**发布出去的 OpenAPI 文档 `components.schemas` 不再是空的,6 个 `$ref` 不再悬空(#5168)** + +`GET /api/v1/openapi.json` 的 base spec 由 `packages/spec/scripts/build-openapi.ts` 生成,它把九个契约 schema(`CreateRequest` / `ApiError` / `ListRecordResponse` / …)转成 JSON Schema 填进 `components.schemas`。收集判据写的是 `typeof schema === 'object' && '_zod' in schema`,而这九个 schema 全部经 `lazySchema()` 包装 —— 其 Proxy target 是 `function lazyZod() {}`,于是 `typeof` 是 `'function'` 而不是 `'object'`,判据第一段就短路,九个一个都没进去。`paths` 里那 6 个 `$ref` 是手写字面量,不受影响照常写出,结果是**一份 `components.schemas` 为 `{}`、6 个 `$ref` 全部悬空的文档被发布出去**,覆盖 `/api/{object}` 与 `/api/{object}/{id}` 上全部 CRUD 操作的请求体与响应体。 + +判据放宽为同时接受 `'object'` 与 `'function'`。`'_zod' in schema` 那一段对 Proxy 本来就是有效的 —— `lazySchema` 专门维护了 `_zod` facade 供 `toJSONSchema` 遍历 —— 所以 `lazySchema` 本身不需要改动。对照实验坐实了唯一变量就是 Proxy:同一份源码下 `npx tsx scripts/build-openapi.ts` 得到 `Components: 0`,而 `OS_EAGER_SCHEMAS=1`(`lazySchema` 自带的绕过 Proxy 应急开关)得到 `Components: 9`。修复后不带任何环境变量即为 `Components: 9`。 + +两类消费者直接受益:`GET /api/v1/docs` 的 Scalar viewer 现在有 schema 可渲染;从该文档做客户端代码生成的集成方(openapi-generator / orval / …)不再在解析期撞上 unresolvable reference。 + +**同时补上防复发的门禁。** 这个缺陷三个层次同时可见(空 components、悬空 ref、控制台明晃晃的 `Components: 0`)却没有任何一处红 —— `gen:openapi` 是全仓两个完全无门禁的生成器之一。生成器现在在**写盘之前**自检两条,任一不满足即以非零码退出,自恰不了的文档根本不会被写出来: + +1. **每个本地 `$ref` 都必须解析得到。** 按 JSON Pointer 解析而不是按 `#/components/schemas/` 前缀匹配,将来新增的 `#/$defs/…` 引用自动被覆盖;报错逐条点名悬空的 `$ref` 及其在文档中的位置,并把「已定义的 schema 列表」一并打出来 —— 哪一侧是空的是读者最先需要的信息。 +2. **没有 schema 被静默降级。** 九个契约 schema 是一张字面清单,某个名字没产出东西永远是缺陷而不是「这个可选」。原先的循环写成 `if (像 zod) { 收 }` 且没有 `else`,正是这个「静默跳过」的形状让九次跳过发布成了空文档;现在**声明即强制**,漏掉的名字会被点名。`z.toJSONSchema()` 抛错时原先会塞一个 `{type:'object'}` 占位描述冒充契约,这条同样改为响亮失败 —— 当前九个全部干净转换,零占位。 + +门禁接在生成器内部而不是单独的 `check:` 脚本,因为 `packages/spec/json-schema/` 是 gitignore 的、每次 `pnpm build` 重新生成,独立检查脚本无论如何都要先跑一次生成器才有东西可查。「产物自恰」这类断言比「产物最新」更便宜,且不需要任何基线快照。 + +`packages/rest` 侧无行为改动:声明式端点的 enrichment 仍然只写 `type: object` 而不编造 `$ref` —— 九个契约 schema 是通用 CRUD 信封,不是某个具体对象的 body 形状 —— 但三处以现在时陈述「`components.schemas` 是空的」的注释已按事实更新。 diff --git a/packages/rest/src/openapi-endpoints.test.ts b/packages/rest/src/openapi-endpoints.test.ts index 4daada8265..d3dd51638a 100644 --- a/packages/rest/src/openapi-endpoints.test.ts +++ b/packages/rest/src/openapi-endpoints.test.ts @@ -29,7 +29,14 @@ import { // Helpers // --------------------------------------------------------------------------- -/** A document shaped like the one `@objectstack/spec/openapi.json` ships. */ +/** + * A document shaped like the one `@objectstack/spec/openapi.json` ships. + * + * `components.schemas` is left empty here on purpose: this module's enrichment + * never reads it (only `securitySchemes`, at `resolveSecurityRequirement`), so + * an empty map keeps the fixture minimal. The real artifact carries nine + * schemas since #5168. + */ function baseDoc() { return { openapi: '3.1.0', @@ -162,8 +169,10 @@ describe('path entries', () => { }); it('never invents a response schema — only descriptions', () => { - // The shipped document has ZERO component schemas (#5168), so any `$ref` - // this module emitted would dangle. Descriptions are the honest maximum. + // The shipped document's component schemas are the generic CRUD envelopes, + // never a per-object response shape (before #5168 there were none at all), + // so any `$ref` this module emitted would name something that does not + // describe THIS endpoint. Descriptions are the honest maximum. const op = buildEndpointOperation( endpoint({ ...OBJECT_FIND, method: 'POST', objectParams: { object: 'showcase_task', operation: 'create' } }), undefined, diff --git a/packages/rest/src/openapi-endpoints.ts b/packages/rest/src/openapi-endpoints.ts index 1f1eb930e9..67b3ff5228 100644 --- a/packages/rest/src/openapi-endpoints.ts +++ b/packages/rest/src/openapi-endpoints.ts @@ -238,11 +238,13 @@ export function buildEndpointOperation( if (facts.readsBody && BODY_METHODS.has(endpoint.method)) { // Free-form object, deliberately: the executor forwards the body (through // `inputMapping`, when declared) to the same pipeline the built-in route - // uses, and this document has no per-object schemas to point at — its - // `components.schemas` is in fact EMPTY today (#5168), so a `$ref` emitted - // here would dangle exactly as the six built-in ones already do. An empty - // `type: object` says "a JSON object, shape not described here", which is - // true; naming fields we have not derived would not be. + // uses, and this document has no PER-OBJECT schemas to point at. Since + // #5168 `components.schemas` is no longer empty — it carries the nine + // contract schemas, and the six built-in `$ref`s resolve — but those are + // the generic CRUD envelopes (`CreateRequest`, `ApiError`, …), not the + // shape of `showcase_task`'s body. An empty `type: object` says "a JSON + // object, shape not described here", which is true; naming fields we have + // not derived would not be. operation.requestBody = { required: true, content: { 'application/json': { schema: { type: 'object' } } }, diff --git a/packages/spec/scripts/build-openapi.ts b/packages/spec/scripts/build-openapi.ts index 4cdf27dbc3..72d08308b8 100644 --- a/packages/spec/scripts/build-openapi.ts +++ b/packages/spec/scripts/build-openapi.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; // Dynamic imports from spec source import * as API from '../src/api'; import * as Data from '../src/data'; +import { assertRefsResolve, assertNoDegradedSchemas } from './lib/openapi-self-consistency'; const OUT_DIR = path.resolve(__dirname, '../json-schema'); const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8')); @@ -237,7 +238,8 @@ function generateDiscoveryPaths(basePath: string): Record { function generateComponentSchemas(): Record> { const schemas: Record> = {}; - + const degraded: string[] = []; + // Map of contract schema names to their Zod schemas const contractSchemas: Record = { CreateRequest: (API as any).CreateRequestSchema, @@ -252,15 +254,29 @@ function generateComponentSchemas(): Record> { }; for (const [name, schema] of Object.entries(contractSchemas)) { - if (schema && typeof schema === 'object' && '_zod' in schema) { - try { - schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' }); - } catch { - schemas[name] = { type: 'object', description: `${name} (schema too complex for auto-generation)` }; - } + // `typeof` must admit BOTH 'object' and 'function': every contract schema + // here is wrapped in `lazySchema()`, whose Proxy target is + // `function lazyZod() {}`, so `typeof schema === 'function'`. Demanding + // 'object' short-circuited all nine and published an empty + // `components.schemas` behind six dangling `$ref`s (#5168). The `_zod` + // half of the guard is Proxy-safe as written — `lazySchema` maintains a + // `_zod` facade precisely so `toJSONSchema` can traverse it. + const isZodLike = + !!schema && (typeof schema === 'object' || typeof schema === 'function') && '_zod' in schema; + if (!isZodLike) continue; // reported by assertNoDegradedSchemas below + + try { + schemas[name] = z.toJSONSchema(schema as z.ZodType, { target: 'draft-2020-12' }); + } catch { + degraded.push(name); } } + // Declared = enforced: the table above is a literal list of the contract's + // nine schemas, so a name that produced nothing is a defect, never an + // optional input. Failing here is what makes the #5168 shape unrepeatable. + assertNoDegradedSchemas(Object.keys(contractSchemas), schemas, degraded); + return schemas; } @@ -316,6 +332,15 @@ const openapi: Record = { ], }; +// ─── Self-consistency gate (#5168) ─────────────────────────────────── +// +// Runs BEFORE the write, so a document whose `$ref`s do not resolve is never +// emitted at all. `gen:openapi` has no staleness gate (`check:generated` +// reports it as one of the two ungated generators), so this is the only thing +// standing between a silently-broken collector and the published +// `GET /api/v1/openapi.json`. Throwing exits non-zero and fails the build. +assertRefsResolve(openapi); + // Write output if (!fs.existsSync(OUT_DIR)) { fs.mkdirSync(OUT_DIR, { recursive: true }); diff --git a/packages/spec/scripts/lib/openapi-self-consistency.ts b/packages/spec/scripts/lib/openapi-self-consistency.ts new file mode 100644 index 0000000000..b4f6b22de4 --- /dev/null +++ b/packages/spec/scripts/lib/openapi-self-consistency.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Self-consistency assertions for the generated OpenAPI document (#5168). + * + * ## The gap this closes + * + * `gen:openapi` is one of the two completely ungated generators in the repo + * (`check:generated`'s own closing line names it: "Generated but ungated (2): + * gen:openapi, gen:sbom"). Ungated in BOTH senses — nothing verifies the + * artifact is current, and nothing verified it was even internally coherent. + * + * #5168 is what the second gap costs. Every one of the nine contract schemas + * is wrapped in `lazySchema()`, whose Proxy target is `function lazyZod() {}`, + * so `typeof schema === 'function'`. The collector's guard led with + * `typeof schema === 'object'`, short-circuited on all nine, and emitted + * `components.schemas: {}` — while the hand-written `$ref` literals in `paths` + * were written out regardless. The published document therefore carried six + * `$ref`s pointing at nothing, covering the request and response bodies of + * every CRUD operation, and the failure was visible three ways at once (empty + * components, dangling refs, a `Components: 0` line printed to the console) + * without a single thing going red. + * + * A "the artifact is coherent" assertion is cheaper than a "the artifact is + * current" one and catches strictly this class: it needs no baseline, no + * committed snapshot (`packages/spec/json-schema/` is gitignored and rebuilt + * on every `pnpm build`), and it covers `$ref`s added in the future for free. + * + * ## The two rules + * + * 1. **Every local `$ref` resolves.** Any `$ref` beginning with `#/` is a JSON + * Pointer into this same document; if it does not resolve, the document is + * broken for every consumer that parses it (Scalar's viewer at + * `GET /api/v1/docs`, and any client generator pointed at + * `GET /api/v1/openapi.json`). Resolution is by pointer rather than by a + * `#/components/schemas/` prefix match so that a future `#/$defs/…` ref + * is covered without touching this file. + * 2. **No schema is silently degraded.** See `assertNoDegradedSchemas`. + * + * Both are consulted BEFORE the document is written: a self-inconsistent + * artifact is never emitted at all, rather than emitted and then complained + * about. The gate is wired into the generator itself (not a separate `check:` + * script) because the artifact is regenerated on every build — a standalone + * checker would have to run the generator first to have anything to check. + */ + +/** One unresolvable `$ref`, with the document location that carried it. */ +export interface DanglingRef { + /** The `$ref` value verbatim, e.g. `#/components/schemas/ApiError`. */ + ref: string; + /** Where it appeared, as a readable path: `paths./api/{object}.get.…`. */ + at: string; +} + +/** + * Resolve a JSON Pointer (RFC 6901) against `root`. + * + * Returns `undefined` when any segment is missing. `~1` decodes to `/` and + * `~0` to `~`, in that order — reversing the order corrupts a literal `~1`. + */ +function resolvePointer(root: unknown, pointer: string): unknown { + // '#' alone addresses the whole document. + if (pointer === '#' || pointer === '#/') return root; + + const segments = pointer + .slice(2) // drop the leading '#/' + .split('/') + .map((s) => decodeURIComponent(s).replace(/~1/g, '/').replace(/~0/g, '~')); + + let node: unknown = root; + for (const segment of segments) { + if (node === null || typeof node !== 'object') return undefined; + const container = node as Record; + if (!Object.prototype.hasOwnProperty.call(container, segment)) return undefined; + node = container[segment]; + } + return node; +} + +/** + * Collect every local (`#/…`) `$ref` in `doc` that does not resolve. + * + * External refs (`https://…`, `./other.json#/…`) are out of scope — this + * document has never contained one, and resolving them would mean fetching. + * They are simply not reported either way. + */ +export function findDanglingRefs(doc: unknown): DanglingRef[] { + const dangling: DanglingRef[] = []; + const seen = new Set(); + + const walk = (node: unknown, at: string): void => { + if (node === null || typeof node !== 'object') return; + // Generated documents are trees, but guard against a cycle regardless: + // an unguarded walk would hang the build instead of failing it. + if (seen.has(node)) return; + seen.add(node); + + if (Array.isArray(node)) { + node.forEach((item, i) => walk(item, `${at}[${i}]`)); + return; + } + + for (const [key, value] of Object.entries(node as Record)) { + const here = at ? `${at}.${key}` : key; + if (key === '$ref' && typeof value === 'string') { + if (value.startsWith('#') && resolvePointer(doc, value) === undefined) { + dangling.push({ ref: value, at }); + } + continue; + } + walk(value, here); + } + }; + + walk(doc, ''); + return dangling; +} + +/** + * Throw when any `$ref` in `doc` dangles. Message names every offender and the + * schemas that WERE defined, because "which of the two sides is empty" is the + * first thing a reader needs (in #5168 the defined side was `[]` entirely). + */ +export function assertRefsResolve(doc: unknown): void { + const dangling = findDanglingRefs(doc); + if (dangling.length === 0) return; + + const defined = Object.keys( + ((doc as Record)?.components?.schemas ?? {}) as Record, + ); + + const lines = dangling.map((d) => ` - ${d.ref} (referenced at ${d.at || ''})`); + throw new Error( + `OpenAPI document is not self-consistent: ${dangling.length} unresolvable $ref(s).\n` + + `${lines.join('\n')}\n` + + ` defined components.schemas: [${defined.join(', ') || ''}]\n` + + `\n` + + ` A $ref that resolves to nothing breaks every consumer of the published\n` + + ` document (the Scalar viewer at GET /api/v1/docs renders an empty schema\n` + + ` panel; client generators fail at parse time). If components.schemas is\n` + + ` empty, the collector in build-openapi.ts skipped its inputs — note that\n` + + ` lazySchema() returns a Proxy whose typeof is 'function', not 'object'\n` + + ` (#5168).`, + ); +} + +/** + * Throw when any contract schema was dropped or degraded during collection. + * + * `build-openapi.ts` names its nine contract schemas in a literal table, so a + * name that fails to convert is never a "this one is optional" — it is an + * export that moved, was renamed, or stopped being a Zod schema. The original + * loop expressed that as `if (looks-like-zod) { emit }` with no `else`, which + * is precisely how nine silent skips published an empty `components.schemas`. + * Declared here therefore means enforced: every declared name must produce a + * real converted schema, or the build fails naming the ones that did not. + */ +export function assertNoDegradedSchemas( + declared: readonly string[], + emitted: Readonly>, + degraded: readonly string[], +): void { + const missing = declared.filter((name) => !(name in emitted)); + if (missing.length === 0 && degraded.length === 0) return; + + const parts: string[] = ['OpenAPI component schema collection is incomplete.']; + if (missing.length > 0) { + parts.push( + ` not emitted at all (${missing.length}): ${missing.join(', ')}\n` + + ` The export is missing, renamed, or is not a Zod schema. Note that a\n` + + ` lazySchema() Proxy has typeof 'function' — a guard demanding\n` + + ` typeof 'object' rejects every one of them (#5168).`, + ); + } + if (degraded.length > 0) { + parts.push( + ` converted to a placeholder (${degraded.length}): ${degraded.join(', ')}\n` + + ` z.toJSONSchema() threw for these. Publishing a bare {type:'object'}\n` + + ` in their place would ship a contract that describes nothing while\n` + + ` looking complete — fix the schema instead.`, + ); + } + throw new Error(parts.join('\n')); +} diff --git a/packages/spec/scripts/openapi-self-consistency.test.ts b/packages/spec/scripts/openapi-self-consistency.test.ts new file mode 100644 index 0000000000..ef796b4814 --- /dev/null +++ b/packages/spec/scripts/openapi-self-consistency.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins the self-consistency gate on the generated OpenAPI document (#5168). +// +// The defect: every contract schema is wrapped in `lazySchema()`, whose Proxy +// target is `function lazyZod() {}`. The collector in `build-openapi.ts` led +// its guard with `typeof schema === 'object'`, so all nine short-circuited and +// `components.schemas` shipped as `{}` — while the hand-written `$ref` +// literals in `paths` were emitted regardless, leaving six dangling refs +// across every CRUD request/response body in the published +// `GET /api/v1/openapi.json`. +// +// Nothing went red. `gen:openapi` is one of the two generators with no gate at +// all, so the breakage was visible three ways (empty components, dangling +// refs, a literal `Components: 0` on the console) and asserted by nothing. +// +// These tests therefore cover BOTH halves, and the second half is the one that +// prevents recurrence: +// +// 1. the pure assertions, against synthetic documents; +// 2. the REAL `build-openapi.ts`, run as a subprocess — green on the shipped +// source, and red again under each of the two ways this can break. That +// second group is the reverse verification: a gate that has never been +// observed failing is not known to be a gate. +// +// ── Why a sandbox for the subprocess group ──────────────────────────────── +// The script resolves its output dir from its own `__dirname` (`../json-schema` +// -> the package's real, gitignored artifact) and a concurrent +// `pnpm --filter @objectstack/spec build` under `turbo run test` writes that +// same file. Running the mutated copies in place would be both destructive and +// flaky, so each variant is written into a temp tree that COPIES `scripts/` and +// symlinks the read-only inputs (`src/`, `node_modules/`, `package.json`) — +// the same discipline `build-schemas-check-mode.test.ts` uses, and for the same +// reason: no test-only seam is added to the gate, because a seam is a place +// where the gate can differ from what CI runs. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + findDanglingRefs, + assertRefsResolve, + assertNoDegradedSchemas, +} from './lib/openapi-self-consistency'; + +const PKG_ROOT = path.resolve(__dirname, '..'); + +describe('findDanglingRefs', () => { + it('reports nothing for a document whose refs all resolve', () => { + const doc = { + paths: { + '/api/{object}': { + get: { responses: { '200': { schema: { $ref: '#/components/schemas/ApiError' } } } }, + }, + }, + components: { schemas: { ApiError: { type: 'object' } } }, + }; + expect(findDanglingRefs(doc)).toEqual([]); + }); + + it('reports the #5168 shape: refs present, components.schemas empty', () => { + const doc = { + paths: { + '/api/{object}': { + get: { responses: { '200': { schema: { $ref: '#/components/schemas/ListRecordResponse' } } } }, + }, + }, + components: { schemas: {} }, + }; + const dangling = findDanglingRefs(doc); + expect(dangling).toHaveLength(1); + expect(dangling[0].ref).toBe('#/components/schemas/ListRecordResponse'); + // The location is what makes the failure actionable. + expect(dangling[0].at).toContain('/api/{object}'); + }); + + it('finds refs nested inside arrays', () => { + const doc = { + paths: { '/x': { get: { anyOf: [{ $ref: '#/components/schemas/Gone' }] } } }, + components: { schemas: {} }, + }; + expect(findDanglingRefs(doc).map((d) => d.ref)).toEqual(['#/components/schemas/Gone']); + }); + + it('resolves by JSON pointer, so a future non-components ref is covered too', () => { + const ok = { $defs: { Node: { type: 'string' } }, a: { $ref: '#/$defs/Node' } }; + expect(findDanglingRefs(ok)).toEqual([]); + const bad = { $defs: {}, a: { $ref: '#/$defs/Node' } }; + expect(findDanglingRefs(bad).map((d) => d.ref)).toEqual(['#/$defs/Node']); + }); + + it('ignores external refs rather than guessing about them', () => { + const doc = { a: { $ref: 'https://example.com/schema.json#/Thing' }, components: { schemas: {} } }; + expect(findDanglingRefs(doc)).toEqual([]); + }); + + it('unescapes JSON-pointer ~1 and ~0 segments', () => { + const doc = { paths: { '/api/x': { ok: true } }, a: { $ref: '#/paths/~1api~1x' } }; + expect(findDanglingRefs(doc)).toEqual([]); + }); + + it('terminates on a cyclic document instead of hanging the build', () => { + const doc: Record = { components: { schemas: {} } }; + doc.self = doc; + expect(() => findDanglingRefs(doc)).not.toThrow(); + }); +}); + +describe('assertRefsResolve', () => { + it('passes a coherent document', () => { + const doc = { a: { $ref: '#/components/schemas/X' }, components: { schemas: { X: {} } } }; + expect(() => assertRefsResolve(doc)).not.toThrow(); + }); + + it('throws naming the offender and the (empty) defined set', () => { + const doc = { a: { $ref: '#/components/schemas/X' }, components: { schemas: {} } }; + expect(() => assertRefsResolve(doc)).toThrow(/unresolvable \$ref/); + expect(() => assertRefsResolve(doc)).toThrow(/#\/components\/schemas\/X/); + expect(() => assertRefsResolve(doc)).toThrow(//); + }); +}); + +describe('assertNoDegradedSchemas', () => { + it('passes when every declared name was emitted', () => { + expect(() => assertNoDegradedSchemas(['A', 'B'], { A: {}, B: {} }, [])).not.toThrow(); + }); + + it('throws on a silently skipped schema — the #5168 root cause', () => { + expect(() => assertNoDegradedSchemas(['A', 'B'], { A: {} }, [])).toThrow( + /not emitted at all \(1\): B/, + ); + }); + + it('throws on a placeholder-converted schema rather than publishing it', () => { + expect(() => assertNoDegradedSchemas(['A'], { A: {} }, ['A'])).toThrow( + /converted to a placeholder \(1\): A/, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────── +// The real generator, as a subprocess. +// ───────────────────────────────────────────────────────────────────────── + +let sandbox: string; + +/** Run a (possibly mutated) copy of `build-openapi.ts` in an isolated tree. */ +function runGenerator(mutate?: (src: string) => string): { status: number; output: string } { + const dir = fs.mkdtempSync(path.join(sandbox, 'gen-')); + fs.cpSync(path.join(PKG_ROOT, 'scripts'), path.join(dir, 'scripts'), { recursive: true }); + for (const entry of ['src', 'node_modules', 'package.json']) { + fs.symlinkSync(path.join(PKG_ROOT, entry), path.join(dir, entry)); + } + + const scriptPath = path.join(dir, 'scripts', 'build-openapi.ts'); + if (mutate) { + const original = fs.readFileSync(scriptPath, 'utf-8'); + const mutated = mutate(original); + expect(mutated, 'mutation must actually change the source').not.toBe(original); + fs.writeFileSync(scriptPath, mutated); + } + + const res = spawnSync('npx', ['tsx', scriptPath], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, NODE_OPTIONS: '--max-old-space-size=4096' }, + }); + return { status: res.status ?? -1, output: `${res.stdout ?? ''}${res.stderr ?? ''}` }; +} + +describe('build-openapi.ts end to end', () => { + beforeAll(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-openapi-5168-')); + }); + afterAll(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('emits all nine components WITHOUT OS_EAGER_SCHEMAS (#5168 regression pin)', () => { + const { status, output } = runGenerator(); + expect(output).toContain('Components: 9'); + // The exact symptom string from the issue, which nobody was asserting. + expect(output).not.toContain('Components: 0'); + expect(status).toBe(0); + }); + + it('writes a document in which every $ref resolves', () => { + const { status } = runGenerator(); + expect(status).toBe(0); + // Re-read the artifact the run just produced and check it independently of + // the generator's own gate. + const dirs = fs + .readdirSync(sandbox) + .map((d) => path.join(sandbox, d, 'json-schema', 'openapi.json')) + .filter((p) => fs.existsSync(p)); + const doc = JSON.parse(fs.readFileSync(dirs[dirs.length - 1], 'utf-8')); + expect(Object.keys(doc.components.schemas)).toHaveLength(9); + expect(findDanglingRefs(doc)).toEqual([]); + }); + + // ── Reverse verification ──────────────────────────────────────────────── + // Predicted direction for BOTH: RED (non-zero exit). These are not + // decoration — before #5168 the generator exited 0 on a document with six + // dangling refs, so "the gate can fail" is the claim under test. + + it('goes RED when the lazySchema Proxy is rejected again (the original bug)', () => { + const { status, output } = runGenerator((src) => + src.replace( + "!!schema && (typeof schema === 'object' || typeof schema === 'function') && '_zod' in schema", + "!!schema && typeof schema === 'object' && '_zod' in schema", + ), + ); + expect(status).not.toBe(0); + expect(output).toMatch(/not emitted at all \(9\)/); + expect(output).toContain('ApiError'); + }); + + it('goes RED when a $ref points at a schema that does not exist', () => { + const { status, output } = runGenerator((src) => + src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"), + ); + expect(status).not.toBe(0); + expect(output).toMatch(/unresolvable \$ref/); + expect(output).toContain('#/components/schemas/ApiErrorTypo'); + }); + + it('refuses to WRITE the artifact when the document is inconsistent', () => { + const dirsBefore = new Set(fs.readdirSync(sandbox)); + runGenerator((src) => + src.replace(/#\/components\/schemas\/ApiError'/g, "#/components/schemas/ApiErrorTypo'"), + ); + const newDir = fs.readdirSync(sandbox).find((d) => !dirsBefore.has(d))!; + // The gate runs before the write, so no half-broken document is published. + expect(fs.existsSync(path.join(sandbox, newDir, 'json-schema', 'openapi.json'))).toBe(false); + }); +});