@erenthedeveloper0/zen-openapi 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@erenthedeveloper0/zen-openapi` — `AppGraph → OpenAPI 3.1`, plus breaking-change detection.
3
+ *
4
+ * rfcs/0001 §29. Depends on `@erenthedeveloper0/zen-core` and nothing else — in particular not
5
+ * on the router, which is why param-type schemas arrive through the frozen
6
+ * graph rather than through a second copy of the built-in table (§24.3).
7
+ */
8
+
9
+ export { openapiDocument, pathVariants, type OpenApiOptions, type OpenApiResult, type PathVariant } from './document.ts'
10
+ export { openapiPlugin, type OpenApiPluginOptions } from './plugin.ts'
11
+ export { renderReference } from './ui.ts'
12
+ export {
13
+ diffDocuments, type ApiChange, type ChangeKind, type DiffResult,
14
+ } from './diff.ts'
15
+ export {
16
+ Components, canonical, declaredName, isObjectShape, projectSchema, sanitizeName,
17
+ type DocDiagnostic, type ProjectOptions,
18
+ } from './schema.ts'
19
+ export type * from './types.ts'
20
+ export { DOC_META_KEYS } from './types.ts'
package/src/plugin.ts ADDED
@@ -0,0 +1,135 @@
1
+ import type { AppGraph, Diagnostic, Plugin } from '@erenthedeveloper0/zen-core'
2
+ import { BootError, definePlugin } from '@erenthedeveloper0/zen-core'
3
+ import { openapiDocument, type OpenApiOptions, type OpenApiResult } from './document.ts'
4
+ import type { DocDiagnostic } from './schema.ts'
5
+ import { renderReference } from './ui.ts'
6
+
7
+ /**
8
+ * Serve the generated document — rfcs/0001 §29.2.
9
+ *
10
+ * The document is built **once, in `onBoot`, from the frozen graph**, and what
11
+ * the endpoint serves is a pre-encoded string. Nothing here runs per request
12
+ * except an ETag comparison: the documentation costs one string in memory and
13
+ * zero work on the hot path, which is the whole reason it is architecture (§29.1)
14
+ * rather than a middleware that walks a registry.
15
+ */
16
+
17
+ export interface OpenApiPluginOptions extends OpenApiOptions {
18
+ /** Where the JSON document is served. `false` disables the endpoint. */
19
+ readonly json?: string | false | undefined
20
+ /** Where the reference viewer is served. `false` disables it. */
21
+ readonly ui?: string | false | undefined
22
+ /**
23
+ * Turn generator warnings into a boot failure.
24
+ *
25
+ * Worth switching on in CI once an API is public: "this route has no response
26
+ * schema" is a documentation hole *and* a missing serializer contract, and the
27
+ * cheapest moment to notice either is before the process starts.
28
+ */
29
+ readonly strict?: boolean | undefined
30
+ /** Receives the finished document — how `zen openapi --out` will write it. */
31
+ readonly onDocument?: ((result: OpenApiResult) => void | Promise<void>) | undefined
32
+ }
33
+
34
+ interface Served {
35
+ result: OpenApiResult | null
36
+ json: string
37
+ etag: string
38
+ html: string
39
+ }
40
+
41
+ export const openapiPlugin: Plugin<OpenApiPluginOptions, {}> = definePlugin<OpenApiPluginOptions, {}>({
42
+ name: 'openapi',
43
+ version: '0.1.0',
44
+
45
+ setup(app, options) {
46
+ const jsonPath = options.json === undefined ? '/openapi.json' : options.json
47
+ const uiPath = options.ui === undefined ? '/docs' : options.ui
48
+ const served: Served = { result: null, json: '{}', etag: '', html: '' }
49
+
50
+ if (jsonPath !== false) {
51
+ app.route({
52
+ method: 'GET',
53
+ path: jsonPath,
54
+ name: 'openapi.json',
55
+ // Excluded from the document it serves. A docs endpoint that documents
56
+ // itself is noise in every generated client.
57
+ meta: { hidden: true },
58
+ handler: (ctx: DocsContext) => {
59
+ if (ctx.headers['if-none-match'] === served.etag) return ctx.empty(304)
60
+ return ctx.text(served.json, {
61
+ media: 'application/json; charset=utf-8',
62
+ headers: { etag: served.etag, 'cache-control': 'no-cache' },
63
+ })
64
+ },
65
+ })
66
+ }
67
+
68
+ if (uiPath !== false) {
69
+ app.route({
70
+ method: 'GET',
71
+ path: uiPath,
72
+ name: 'openapi.ui',
73
+ meta: { hidden: true },
74
+ handler: (ctx: DocsContext) => ctx.html(served.html),
75
+ })
76
+ }
77
+
78
+ app.onBoot(async (graph) => {
79
+ const result = openapiDocument(graph as AppGraph, options)
80
+ served.result = result
81
+ served.json = JSON.stringify(result.document, null, 2)
82
+ served.etag = weakEtag(served.json)
83
+ served.html = uiPath === false
84
+ ? ''
85
+ : renderReference(result.document, { jsonPath: jsonPath === false ? null : jsonPath })
86
+
87
+ if (options.strict === true && result.diagnostics.length > 0) {
88
+ throw new BootError(result.diagnostics.map(toBootDiagnostic))
89
+ }
90
+ await options.onDocument?.(result)
91
+ })
92
+
93
+ return {
94
+ exports: {
95
+ /** The finished document, once `ready()` has run. */
96
+ document: () => served.result?.document ?? null,
97
+ diagnostics: () => served.result?.diagnostics ?? [],
98
+ json: () => served.json,
99
+ },
100
+ }
101
+ },
102
+ })
103
+
104
+ /** Only the two accessors the handlers use — the plugin needs no decorations. */
105
+ interface DocsContext {
106
+ readonly headers: Readonly<Record<string, string | undefined>>
107
+ empty(status?: 204 | 205 | 304): unknown
108
+ text(body: string, init?: { media?: string; headers?: Record<string, string> }): unknown
109
+ html(body: string): unknown
110
+ }
111
+
112
+ function toBootDiagnostic(diagnostic: DocDiagnostic): Diagnostic {
113
+ return {
114
+ severity: diagnostic.severity === 'info' ? 'warning' : diagnostic.severity,
115
+ code: diagnostic.code,
116
+ message: diagnostic.message,
117
+ hint: diagnostic.hint,
118
+ locations: [diagnostic.where],
119
+ }
120
+ }
121
+
122
+ /**
123
+ * FNV-1a over the document string. Weak, and correctly labelled `W/`: the
124
+ * document is regenerated on every boot and two boots of the same code produce
125
+ * byte-identical output (every map is emitted sorted), so this is a content
126
+ * hash, not a resource version.
127
+ */
128
+ function weakEtag(body: string): string {
129
+ let hash = 0x811c9dc5
130
+ for (let i = 0; i < body.length; i++) {
131
+ hash ^= body.charCodeAt(i)
132
+ hash = Math.imul(hash, 0x01000193)
133
+ }
134
+ return `W/"${body.length.toString(36)}-${(hash >>> 0).toString(36)}"`
135
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,497 @@
1
+ import type { JsonSchema, JsonSchemaNode, JsonType } from '@erenthedeveloper0/zen-core'
2
+ import { resolveRef } from '@erenthedeveloper0/zen-core'
3
+ import type { OpenApiSchema } from './types.ts'
4
+
5
+ /**
6
+ * JSON Schema → OpenAPI 3.1 Schema Object — rfcs/0001 §29.2, §29.3.
7
+ *
8
+ * Two things happen here, and only one of them is mechanical.
9
+ *
10
+ * The mechanical part is hoisting `$defs` into `components.schemas`, rewriting
11
+ * local `$ref`s, and up-converting the OpenAPI 3.0 spellings (`nullable`) that
12
+ * real converters still emit.
13
+ *
14
+ * The load-bearing part is **closure**. Zen's serializer inverts JSON Schema's
15
+ * default: an object with no `additionalProperties` drops undeclared keys rather
16
+ * than passing them through (§13.3.1). A document generated from the raw schema
17
+ * would therefore describe an endpoint that permits fields the wire will never
18
+ * carry — documentation that is wrong in the one direction that matters, since a
19
+ * client author would write code to read a field that can never arrive. So
20
+ * response schemas are projected *closed*, mirroring `serializer-ir.ts` node for
21
+ * node, and `test/openapi.test.ts` proves the two agree by running values
22
+ * through the real compiled serializer.
23
+ *
24
+ * Request schemas are **not** closed. There the validator is the authority, the
25
+ * validator is the user's Standard Schema library, and its converter's output is
26
+ * the honest description of what it accepts.
27
+ */
28
+
29
+ export interface DocDiagnostic {
30
+ readonly severity: 'error' | 'warning' | 'info'
31
+ readonly code: string
32
+ readonly message: string
33
+ /** Where the problem is, in human terms: `GET /users/:id → response 200`. */
34
+ readonly where: string
35
+ readonly hint?: string | undefined
36
+ }
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Component registry — §29.3
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Deduplication runs in three passes, cheapest and most authoritative first:
44
+ * schema *identity* (the same imported object used by four routes is one
45
+ * component), then a *declared* name (`$id` / `title`), then *structural*
46
+ * equality. The third pass is a backstop, not the strategy — two schemas that
47
+ * happen to have the same shape today are not necessarily the same concept, so
48
+ * structural merging only ever fires for schemas that had no name to begin with.
49
+ */
50
+ export class Components {
51
+ readonly #byName = new Map<string, OpenApiSchema>()
52
+ readonly #byKey = new Map<string, string>()
53
+ /** `name + shape` → the name that owns it. What makes two arrivals one component. */
54
+ readonly #byIdentity = new Map<string, string>()
55
+ /** A name that turned out to duplicate another, and the name it defers to. */
56
+ readonly #aliases = new Map<string, string>()
57
+ readonly #reserved = new Set<string>()
58
+
59
+ /**
60
+ * Claim a name for a shape that is already projected.
61
+ *
62
+ * `key` is the identity the caller wants deduplicated on, and it is a
63
+ * parameter rather than always the shape for a reason: two *named* schemas
64
+ * that happen to look alike today are not the same concept, so the caller
65
+ * folds the name into the key and they stay separate. Anonymous schemas pass
66
+ * the shape and do collapse. Different keys wanting the same name get `Name2`.
67
+ */
68
+ claim(preferred: string, schema: OpenApiSchema, key: string, variant?: string): string {
69
+ const existing = this.#byKey.get(key)
70
+ if (existing !== undefined) return existing
71
+
72
+ const identity = identityOf(preferred, schema)
73
+ const owner = this.#byIdentity.get(identity)
74
+ if (owner !== undefined) {
75
+ this.#byKey.set(key, owner)
76
+ return owner
77
+ }
78
+
79
+ const name = this.#free(preferred, variant)
80
+ this.#reserved.add(name)
81
+ this.#byName.set(name, schema)
82
+ this.#byKey.set(key, name)
83
+ this.#byIdentity.set(identity, name)
84
+ return name
85
+ }
86
+
87
+ /**
88
+ * Reserve a name for a shape that cannot be projected yet — a `$def` whose body
89
+ * may refer back to itself, or refer to a sibling def not yet visited. Keyed on
90
+ * the *raw* schema so recursion terminates.
91
+ *
92
+ * The reservation is provisional: the same concept can arrive twice, once as a
93
+ * converter's `$defs` entry and once as an inline titled object, and which one
94
+ * is seen first depends on route order. `fill` reconciles that.
95
+ */
96
+ reserve(preferred: string, rawKey: string, variant?: string): { readonly name: string; readonly fresh: boolean } {
97
+ const existing = this.#byKey.get(rawKey)
98
+ if (existing !== undefined) return { name: existing, fresh: false }
99
+ const name = this.#free(preferred, variant)
100
+ this.#reserved.add(name)
101
+ this.#byKey.set(rawKey, name)
102
+ return { name, fresh: true }
103
+ }
104
+
105
+ /**
106
+ * Attach a body to a reserved name — and collapse it onto an existing
107
+ * component if it turns out to be the same concept under the same name.
108
+ *
109
+ * This is the fix for a bug the first real application found: Zod hoists a
110
+ * schema carrying an `id` into `$defs` when it is nested, and emits it inline
111
+ * when it is not, so `PublicUser` arrived by both routes and the document
112
+ * published `PublicUser` *and* `PublicUser2`. Two names for one type in a
113
+ * generated client is exactly the drift this subsystem exists to prevent.
114
+ */
115
+ fill(name: string, schema: OpenApiSchema): void {
116
+ const identity = identityOf(name, schema)
117
+ const owner = this.#byIdentity.get(identity)
118
+ if (owner !== undefined && owner !== name) {
119
+ this.#aliases.set(name, owner)
120
+ this.#reserved.delete(name)
121
+ return
122
+ }
123
+ this.#byIdentity.set(identity, name)
124
+ this.#byName.set(name, schema)
125
+ }
126
+
127
+ /** `duplicate → canonical`, for the final `$ref` rewrite. Empty in the common case. */
128
+ aliases(): ReadonlyMap<string, string> {
129
+ return this.#aliases
130
+ }
131
+
132
+ has(name: string): boolean {
133
+ return this.#byName.has(name)
134
+ }
135
+
136
+ get size(): number {
137
+ return this.#byName.size
138
+ }
139
+
140
+ /** Sorted, because a document that reorders itself between runs cannot be diffed. */
141
+ toRecord(): Record<string, OpenApiSchema> {
142
+ const out: Record<string, OpenApiSchema> = {}
143
+ for (const name of [...this.#byName.keys()].sort()) {
144
+ if (this.#aliases.has(name)) continue
145
+ out[name] = this.#byName.get(name) as OpenApiSchema
146
+ }
147
+ return out
148
+ }
149
+
150
+ /**
151
+ * `variant` is tried before the numeric fallback, and it exists for one very
152
+ * common case: the same named schema appears on both sides of the API, and the
153
+ * two are not identical — a converter describes a request body as it is
154
+ * *received* (extra keys tolerated) and a response as it is *emitted* (extra
155
+ * keys impossible, §13.3.1).
156
+ *
157
+ * They are genuinely two schemas, so they must be two components. But
158
+ * `OrderLine` and `OrderLineInput` tell a client author which is which, and
159
+ * `OrderLine` and `OrderLine2` do not.
160
+ */
161
+ #free(preferred: string, variant?: string): string {
162
+ const base = sanitizeName(preferred)
163
+ if (!this.#reserved.has(base)) return base
164
+ if (variant !== undefined) {
165
+ const varied = `${base}${variant}`
166
+ if (!this.#reserved.has(varied)) return varied
167
+ }
168
+ for (let n = 2; ; n++) {
169
+ const candidate = `${base}${n}`
170
+ if (!this.#reserved.has(candidate)) return candidate
171
+ }
172
+ }
173
+ }
174
+
175
+ /**
176
+ * A component's identity: its name plus its *shape*, with the top-level
177
+ * annotations removed.
178
+ *
179
+ * `title` and `description` are stripped because converters do not emit them
180
+ * consistently between an inline occurrence and a `$defs` entry for the very
181
+ * same schema — and two schemas with the same name and the same structure are
182
+ * one type whichever prose happens to be attached. Nested annotations are left
183
+ * alone; they are part of the shape as far as a client generator is concerned.
184
+ */
185
+ function identityOf(name: string, schema: OpenApiSchema): string {
186
+ const { title: _title, description: _description, ...rest } = schema as Record<string, unknown>
187
+ return `${sanitizeName(name)}::${canonical(rest)}`
188
+ }
189
+
190
+ const NAME_ILLEGAL = /[^A-Za-z0-9_.-]/g
191
+
192
+ export function sanitizeName(raw: string): string {
193
+ const cleaned = raw.replace(NAME_ILLEGAL, '_').replace(/^_+|_+$/g, '')
194
+ return cleaned.length > 0 ? cleaned : 'Schema'
195
+ }
196
+
197
+ /**
198
+ * Key-sorted JSON. Used for structural identity, so it must be stable across
199
+ * runs and across property insertion order — `{a,b}` and `{b,a}` are one shape.
200
+ */
201
+ export function canonical(value: unknown): string {
202
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
203
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
204
+ const entries = Object.entries(value as Record<string, unknown>)
205
+ .filter(([, v]) => v !== undefined)
206
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
207
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(',')}}`
208
+ }
209
+
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ // Projection
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+
214
+ export interface ProjectOptions {
215
+ readonly components: Components
216
+ /** True for responses: apply Zen's drop-by-default (§13.3.1). */
217
+ readonly closed: boolean
218
+ readonly diagnostics: DocDiagnostic[]
219
+ readonly where: string
220
+ }
221
+
222
+ /** Keywords handled structurally; everything else is copied through verbatim. */
223
+ const STRUCTURAL = new Set([
224
+ '$ref', '$defs', 'definitions', '$schema', '$id',
225
+ 'type', 'nullable', 'properties', 'required', 'additionalProperties',
226
+ 'items', 'prefixItems', 'anyOf', 'oneOf', 'allOf',
227
+ ])
228
+
229
+ export function projectSchema(raw: JsonSchema, options: ProjectOptions): OpenApiSchema {
230
+ return new Projector(raw, options).walk(raw, new Set(), 0)
231
+ }
232
+
233
+ class Projector {
234
+ readonly #root: JsonSchema
235
+ readonly #opts: ProjectOptions
236
+ /** `#/$defs/User` → component name. Populated before the body is walked. */
237
+ readonly #refs = new Map<string, string>()
238
+ /** Name suffix for the open (request) side, so both sides can coexist. */
239
+ readonly #variant: string | undefined
240
+
241
+ constructor(root: JsonSchema, options: ProjectOptions) {
242
+ this.#root = root
243
+ this.#opts = options
244
+ this.#variant = options.closed ? undefined : 'Input'
245
+ this.#hoistDefs(root.$defs, '#/$defs/')
246
+ this.#hoistDefs(root.definitions, '#/definitions/')
247
+ }
248
+
249
+ #hoistDefs(defs: Readonly<Record<string, JsonSchemaNode>> | undefined, prefix: string): void {
250
+ if (defs === undefined) return
251
+ // Names are reserved for every def first, then bodies are projected. A
252
+ // recursive `$def` refers to itself through a name that must already exist.
253
+ const pending: Array<{ name: string; schema: JsonSchemaNode }> = []
254
+ for (const key of Object.keys(defs)) {
255
+ const schema = defs[key]
256
+ if (schema === undefined) continue
257
+ // The closure flag is part of the identity: the same raw schema projected
258
+ // open (a request) and closed (a response) is two different documents.
259
+ const rawKey = `def:${this.#opts.closed ? 'closed' : 'open'}:${canonical(schema)}`
260
+ const claimed = this.#opts.components.reserve(key, rawKey, this.#variant)
261
+ this.#refs.set(`${prefix}${key}`, claimed.name)
262
+ if (claimed.fresh) pending.push({ name: claimed.name, schema })
263
+ }
264
+ for (const job of pending) {
265
+ this.#opts.components.fill(job.name, this.walk(job.schema, new Set(), 0))
266
+ }
267
+ }
268
+
269
+ /**
270
+ * `depth` exists for one rule: **a titled subschema becomes a component
271
+ * wherever it appears.**
272
+ *
273
+ * A response of `{ users: PublicUser[] }` should say `$ref: PublicUser`, not
274
+ * inline a second copy of the same object — otherwise a generated client
275
+ * grows `UserListUsersItem` alongside `PublicUser` and the two drift the first
276
+ * time someone edits one. A `title` is the author naming a type, so it is
277
+ * honoured at every level. Anonymous subschemas stay inline, because hoisting
278
+ * those would fill `components` with `Schema1..Schema40`.
279
+ *
280
+ * Depth 0 is exempt: the caller decides whether the *root* is hoisted, since
281
+ * only it knows how many operations share it (§29.3).
282
+ */
283
+ walk(node: JsonSchema | boolean | undefined, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
284
+ if (node === undefined || node === true) return {}
285
+ if (node === false) return { not: {} }
286
+ if (typeof node !== 'object') return {}
287
+
288
+ if (typeof node.$ref === 'string') return this.#ref(node.$ref, seen, depth)
289
+
290
+ const title = depth > 0 ? declaredName(node) : null
291
+ if (title !== null) {
292
+ const body = this.#body(node, seen, depth)
293
+ const name = this.#opts.components.claim(title, body, `named:${title}:${canonical(body)}`, this.#variant)
294
+ if (name !== title && name !== `${title}${this.#variant ?? ''}`) {
295
+ this.#opts.diagnostics.push({
296
+ severity: 'warning',
297
+ code: 'ZEN_OAS_TITLE_COLLISION',
298
+ message: `Two different schemas are both named "${title}"; this one was published as "${name}".`,
299
+ where: this.#opts.where,
300
+ hint: 'Give them distinct titles — a generated client names its types from these.',
301
+ })
302
+ }
303
+ return { $ref: `#/components/schemas/${name}` }
304
+ }
305
+
306
+ return this.#body(node, seen, depth)
307
+ }
308
+
309
+ #body(node: JsonSchema, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
310
+ const out: Record<string, unknown> = {}
311
+ for (const key of Object.keys(node)) {
312
+ if (STRUCTURAL.has(key)) continue
313
+ const value = node[key]
314
+ if (value !== undefined) out[key] = value
315
+ }
316
+
317
+ // `const` and `enum` short-circuit shape in the IR, so they do here too:
318
+ // closing an object that can only ever be one literal adds nothing.
319
+ if (node.const !== undefined || Array.isArray(node.enum)) {
320
+ this.#applyType(node, out)
321
+ return out as OpenApiSchema
322
+ }
323
+
324
+ const allOf = node.allOf
325
+ if (Array.isArray(allOf) && allOf.length > 0) return this.#allOf(node, allOf, out, seen, depth)
326
+
327
+ const union = node.oneOf ?? node.anyOf
328
+ if (Array.isArray(union) && union.length > 0) {
329
+ const keyword = node.oneOf !== undefined ? 'oneOf' : 'anyOf'
330
+ out[keyword] = union.map((member) => this.walk(member, seen, depth + 1))
331
+ this.#applyType(node, out)
332
+ return out as OpenApiSchema
333
+ }
334
+
335
+ this.#applyType(node, out)
336
+
337
+ if (isObjectShape(node)) this.#object(node, out, seen, depth)
338
+ else if (node.items !== undefined || node.prefixItems !== undefined) this.#array(node, out, seen, depth)
339
+
340
+ return out as OpenApiSchema
341
+ }
342
+
343
+ /** OAS 3.1 has no `nullable`; `type: [T, 'null']` is the same statement. */
344
+ #applyType(node: JsonSchema, out: Record<string, unknown>): void {
345
+ const declared = node.type
346
+ if (declared === undefined) {
347
+ if (node.nullable === true) out['type'] = 'null'
348
+ return
349
+ }
350
+ const types: readonly JsonType[] = Array.isArray(declared) ? declared : [declared]
351
+ const withNull = node.nullable === true && !types.includes('null') ? [...types, 'null' as JsonType] : types
352
+ out['type'] = withNull.length === 1 ? withNull[0] : withNull
353
+ }
354
+
355
+ #object(node: JsonSchema, out: Record<string, unknown>, seen: ReadonlySet<string>, depth: number): void {
356
+ const properties = node.properties
357
+ if (properties !== undefined) {
358
+ const projected: Record<string, OpenApiSchema> = {}
359
+ for (const key of Object.keys(properties)) projected[key] = this.walk(properties[key], seen, depth + 1)
360
+ out['properties'] = projected
361
+ }
362
+ if (node.required !== undefined && node.required.length > 0) out['required'] = [...node.required]
363
+
364
+ const additional = node.additionalProperties
365
+ if (additional === undefined) {
366
+ // §13.3.1 — the one deliberate deviation, written down in the document
367
+ // rather than left as folklore. Absent means *drop*, so the document says
368
+ // `false` and a generated client will not offer a field that cannot arrive.
369
+ if (this.#opts.closed) out['additionalProperties'] = false
370
+ } else if (typeof additional === 'boolean') {
371
+ out['additionalProperties'] = additional
372
+ } else {
373
+ out['additionalProperties'] = this.walk(additional, seen, depth + 1)
374
+ }
375
+ }
376
+
377
+ #array(node: JsonSchema, out: Record<string, unknown>, seen: ReadonlySet<string>, depth: number): void {
378
+ if (Array.isArray(node.prefixItems)) {
379
+ out['prefixItems'] = node.prefixItems.map((item) => this.walk(item, seen, depth + 1))
380
+ // Draft 2020-12: with `prefixItems`, `items` constrains the tail. `false`
381
+ // is how a fixed-length tuple is spelled, and the IR reads it the same way.
382
+ out['items'] = node.items === undefined || node.items === false ? false : this.walk(node.items, seen, depth + 1)
383
+ return
384
+ }
385
+ if (node.items !== undefined) out['items'] = this.walk(node.items, seen, depth + 1)
386
+ }
387
+
388
+ /**
389
+ * `allOf` is an intersection, and the serializer resolves it by *merging* the
390
+ * members into one object (§13.3, `Builder#allOf`). The document must say the
391
+ * same thing — and there is a second reason beyond fidelity: an `allOf` of two
392
+ * closed objects is unsatisfiable, because every property of one member is
393
+ * "additional" to the other. Emitting the merge avoids publishing a schema
394
+ * that nothing can validate against.
395
+ */
396
+ #allOf(
397
+ node: JsonSchema,
398
+ members: readonly JsonSchema[],
399
+ out: Record<string, unknown>,
400
+ seen: ReadonlySet<string>,
401
+ depth: number,
402
+ ): OpenApiSchema {
403
+ if (!this.#opts.closed) {
404
+ out['allOf'] = members.map((member) => this.walk(member, seen, depth + 1))
405
+ this.#applyType(node, out)
406
+ return out as OpenApiSchema
407
+ }
408
+
409
+ const resolved: JsonSchema[] = []
410
+ for (const member of members) {
411
+ const target = typeof member.$ref === 'string' ? resolveRef(this.#root, member.$ref) : member
412
+ if (target === null || !isObjectShape(target)) {
413
+ this.#opts.diagnostics.push({
414
+ severity: 'info',
415
+ code: 'ZEN_OAS_ALLOF_UNMERGED',
416
+ message: 'allOf member is not an object schema, so the document keeps allOf rather than merging it.',
417
+ where: this.#opts.where,
418
+ hint: 'The serializer reports the same case as a boot diagnostic; see rfcs/0001 §13.3.',
419
+ })
420
+ out['allOf'] = members.map((m) => this.walk(m, seen, depth + 1))
421
+ this.#applyType(node, out)
422
+ return out as OpenApiSchema
423
+ }
424
+ resolved.push(target)
425
+ }
426
+
427
+ const properties: Record<string, JsonSchemaNode> = {}
428
+ const required = new Set<string>()
429
+ let additional: JsonSchemaNode | undefined
430
+ for (const member of [...resolved, node]) {
431
+ for (const [key, value] of Object.entries(member.properties ?? {})) properties[key] = value
432
+ for (const key of member.required ?? []) required.add(key)
433
+ if (member.additionalProperties !== undefined) additional = member.additionalProperties
434
+ }
435
+
436
+ const merged: JsonSchema = {
437
+ type: 'object',
438
+ properties,
439
+ ...(required.size > 0 ? { required: [...required] } : {}),
440
+ ...(additional !== undefined ? { additionalProperties: additional } : {}),
441
+ }
442
+ const projected = this.#body(merged, seen, depth) as Record<string, unknown>
443
+ return { ...out, ...projected } as OpenApiSchema
444
+ }
445
+
446
+ #ref(ref: string, seen: ReadonlySet<string>, depth: number): OpenApiSchema {
447
+ const hoisted = this.#refs.get(ref)
448
+ if (hoisted !== undefined) return { $ref: `#/components/schemas/${hoisted}` }
449
+
450
+ // Not a `$defs` entry: resolve and inline. A pointer into `properties` is
451
+ // legal JSON Schema and rare, but inlining beats emitting a `$ref` that
452
+ // points nowhere in the published document.
453
+ if (seen.has(ref)) {
454
+ this.#opts.diagnostics.push({
455
+ severity: 'warning',
456
+ code: 'ZEN_OAS_REF_CYCLE',
457
+ message: `Circular $ref "${ref}" outside $defs; the document inlines it as an unconstrained schema.`,
458
+ where: this.#opts.where,
459
+ hint: 'Move the recursive schema into $defs so it can be hoisted into components.schemas.',
460
+ })
461
+ return {}
462
+ }
463
+ const target = resolveRef(this.#root, ref)
464
+ if (target === null) {
465
+ this.#opts.diagnostics.push({
466
+ severity: 'warning',
467
+ code: 'ZEN_OAS_REF_UNRESOLVED',
468
+ message: `Unresolvable $ref "${ref}"; the document describes this position as unconstrained.`,
469
+ where: this.#opts.where,
470
+ hint: 'Only same-document refs are supported (#/$defs/Name, #/definitions/Name).',
471
+ })
472
+ return {}
473
+ }
474
+ return this.walk(target, new Set([...seen, ref]), depth)
475
+ }
476
+ }
477
+
478
+ /** Mirrors `Builder#byType`/`#inferShape`: when does the IR build an object node? */
479
+ export function isObjectShape(node: JsonSchema): boolean {
480
+ const declared = node.type
481
+ const types: readonly JsonType[] =
482
+ declared === undefined ? [] : Array.isArray(declared) ? declared : [declared]
483
+ if (types.includes('object')) return true
484
+ if (types.filter((t) => t !== 'null').length > 0) return false
485
+ return node.properties !== undefined || node.required !== undefined || node.additionalProperties !== undefined
486
+ }
487
+
488
+ /** A component name to suggest for a schema that carries one. */
489
+ export function declaredName(schema: JsonSchema): string | null {
490
+ const id = schema['$id']
491
+ if (typeof id === 'string' && id.length > 0) {
492
+ const tail = id.split('/').pop()
493
+ if (tail !== undefined && tail.length > 0) return tail.replace(/\.json$/, '')
494
+ }
495
+ const title = schema.title
496
+ return typeof title === 'string' && title.length > 0 ? title : null
497
+ }