zopia 0.3.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +354 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/bin/zopia.js +20 -0
  5. package/docs/01-overview.md +94 -0
  6. package/docs/02-targets.md +55 -0
  7. package/docs/03-roadmap.md +205 -0
  8. package/docs/04-architecture.md +345 -0
  9. package/docs/05-concepts.md +239 -0
  10. package/docs/06-conversions.md +493 -0
  11. package/docs/07-api-docs.md +337 -0
  12. package/docs/08-components.md +223 -0
  13. package/docs/09-configuration.md +167 -0
  14. package/docs/10-usage.md +208 -0
  15. package/docs/11-testing.md +267 -0
  16. package/docs/12-standards.md +242 -0
  17. package/docs/README.md +42 -0
  18. package/docs/publish-workflow.yml.example +48 -0
  19. package/package.json +77 -0
  20. package/src/api-docs-navigation.ts +353 -0
  21. package/src/cli-command.ts +537 -0
  22. package/src/cli.ts +4 -0
  23. package/src/config.ts +190 -0
  24. package/src/conversions/api-docs-facade.ts +42 -0
  25. package/src/conversions/api-docs-generate.ts +567 -0
  26. package/src/conversions/api-docs-layout.ts +39 -0
  27. package/src/conversions/api-docs-plan.ts +130 -0
  28. package/src/conversions/api-docs-presets.ts +246 -0
  29. package/src/conversions/json-schema-to-zod.ts +931 -0
  30. package/src/conversions/manifest-staleness.ts +211 -0
  31. package/src/conversions/manifest-to-openapi.ts +1861 -0
  32. package/src/conversions/manifest-writer.ts +778 -0
  33. package/src/conversions/openapi-contracts.ts +333 -0
  34. package/src/conversions/openapi-external-ref.ts +233 -0
  35. package/src/conversions/openapi-ir.ts +74 -0
  36. package/src/conversions/openapi-ref.ts +38 -0
  37. package/src/conversions/openapi-to-api-docs-public.ts +466 -0
  38. package/src/conversions/openapi-to-api-docs.ts +203 -0
  39. package/src/conversions/openapi.ts +80 -0
  40. package/src/conversions/reverse-security.ts +68 -0
  41. package/src/conversions/yaml.ts +876 -0
  42. package/src/conversions/zod-to-json-schema.ts +536 -0
  43. package/src/diff.ts +353 -0
  44. package/src/errors.ts +114 -0
  45. package/src/index.ts +80 -0
  46. package/src/validation.ts +299 -0
  47. package/src/warnings.ts +164 -0
@@ -0,0 +1,493 @@
1
+ # πŸ”„ Conversion Engines
2
+
3
+ The four engines of zopia, pinned to exact rules. **These mapping tables are
4
+ the contract**: the implementation and the tests are written against them, and
5
+ any deviation is a bug (or a documented change to this file, in the same
6
+ commit).
7
+
8
+ ```mermaid
9
+ flowchart LR
10
+ Z["βš›οΈ Zod v4"] -->|β‘ | JS["πŸ“ JSON Schema"]
11
+ JS -->|β‘‘| Z
12
+ SPEC["πŸ“„ swagger.json / openapi.json"] -->|β‘’ normalize + render| DOC["πŸ“‚ api_docs/**"]
13
+ DOC -->|④ import + zod→schema| SPEC
14
+ ```
15
+
16
+ > 🧭 The conversion cores are pure (P-3): object inputs produce in-memory
17
+ > results. File access belongs to public wrappers (`openApiToApiDocs` /
18
+ > `apiDocsToOpenApi`); `jsonSchemaToZod()` additionally resolves its documented
19
+ > `.json` path convenience before invoking the same in-memory emitter.
20
+
21
+ ### ⚠️ Shared warning contract
22
+
23
+ All four engines use `ZopiaWarning = { code, at?, message }`, where `code` is
24
+ one of the exported stable `ZOPIA_WARNING_CODES` and `at` is an escaped JSON
25
+ Pointer when the location is discoverable. Before an engine returns warnings
26
+ or invokes a callback, it sanitizes one-line messages, removes exact
27
+ duplicates, and sorts by pointer/code/message. Nested conversions rebase their
28
+ pointers into the containing OpenAPI location.
29
+
30
+ Engine β‘  reports through `onWarning`; engine β‘‘ returns `warnings` and mirrors
31
+ them in generated code; engines β‘’ and β‘£ return warnings on their result
32
+ objects. `ZopiaReverseOptions.onWarning` receives the same normalized warnings
33
+ returned by `apiDocsToOpenApi()`. The CLI prints canonical diagnostics to
34
+ stderr and never mixes them into reverse JSON on stdout.
35
+
36
+ ---
37
+
38
+ ## Engine β‘  β€” Zod β†’ JSON Schema
39
+
40
+ ```ts
41
+ /** βš›οΈ Convert a Zod v4 schema to a JSON Schema object. */
42
+ function zodToJsonSchema(schema: $ZodType, options?: ZodToJsonSchemaOptions): JsonSchemaObject;
43
+ ```
44
+
45
+ | βš™οΈ Option | πŸ“ Type | πŸ†” Default | πŸ“ Meaning |
46
+ | --- | --- | --- | --- |
47
+ | `target` | `'openapi-3.1' \| 'openapi-3.0' \| 'draft-2020-12' \| 'draft-07'` | `'openapi-3.1'` | dialect of the output |
48
+ | `$schema` | `boolean` | `true` | include the `$schema` URI β€” Zod emits it natively for the draft targets; for the `openapi-3.0` target (where Zod omits it) zopia adds `http://json-schema.org/draft-07/schema#` when enabled, removes it when disabled |
49
+ | `io` | `'input' \| 'output'` | `'output'` | Zod's native `io` param β€” `output` = what the schema produces; `input` = what the client sends. Engine β‘£ converts **request** schemas with `io: 'input'`, **response** schemas with `io: 'output'` (R-615) |
50
+ | `onWarning` | `(warning: ZopiaWarning) => void` | `undefined` | receives each structured, non-fatal conversion warning; engine wrappers use this collector to aggregate warnings into their own result |
51
+
52
+ **Implementation** (D-03): a thin, deterministic layer over Zod v4's built-in
53
+ `z.toJSONSchema()`. The `zod-to-json-schema` third-party package is **not used**
54
+ (deprecated since Nov 2025 β€” Zod v4 is self-sufficient).
55
+
56
+ | 🎯 zopia `target` | Zod `target` param | πŸ“ Notes |
57
+ | --- | --- | --- |
58
+ | `openapi-3.1` | `draft-2020-12` | OpenAPI 3.1 schema objects *are* 2020-12 |
59
+ | `openapi-3.0` | `openapi-3.0` | Zod's own 3.0-compatible target (`nullable`, …) |
60
+ | `draft-2020-12` | `draft-2020-12` | pure JSON Schema |
61
+ | `draft-07` | `draft-07` | legacy consumers (closest modern dialect for Swagger 2.0-era readers) |
62
+
63
+ **Fixed behaviours**
64
+
65
+ | # | Behaviour | Rule |
66
+ | --- | --- | --- |
67
+ | R-611 | πŸŒ— Nullability | `z.string().nullable()` β†’ `type: ["string", "null"]` (2020-12/3.1) or `nullable: true` (3.0 target) β€” whatever the target dialect prescribes |
68
+ | R-612 | πŸ“ Metadata | `.describe("…")` β†’ `description`; JSON-compatible `.meta({ … })` / `z.globalRegistry` entries β†’ **all** metadata fields copied verbatim (`title`, `description`, `examples`, … β€” verified); the `id` metadata key is never emitted (it would trigger Zod's `$def` extraction). Non-JSON metadata is localized to `{}` with `ZOPIA_WARN_UNREPRESENTABLE` rather than silently normalized or dropped |
69
+ | R-613 | πŸŒ€ Cycles | Zod's `cycles: "ref"` handling β€” recursive schemas become `$defs` + `$ref` |
70
+ | R-614 | 🚫 Unrepresentable | Every site reported by Zod's `unrepresentable` handler β€” including `z.bigint()`, `z.int64()`, `z.symbol()`, `z.undefined()`, `z.void()`, `z.date()`, `z.map()`, `z.set()`, `z.function()`, `z.transform()`, `z.nan()`, `z.custom()`, `z.number().multipleOf(0)`, unsupported literal/default values, symbol object keys, and dynamic catch values β€” becomes `{}` plus `ZOPIA_WARN_UNREPRESENTABLE` delivered to `onWarning` (never a throw, R-408; Zod's own default is to throw). zopia preflights defaults so non-finite numbers, symbols, functions, cycles, and other non-JSON values cannot be silently normalized or throw before the warning handler. Nested warnings carry an escaped JSON Pointer in `at`. ⚠️ `z.void()` is special-cased *before* conversion in engine β‘£ for 204-style no-content responses (R-654) |
71
+ | R-615 | πŸ”„ io semantics | default conversion represents the **output** type. zopia converts **request** schemas with `io: 'input'` (what the client sends β€” defaulted request fields stay *optional*, so source `required`/`default` round-trip exactly) and **response** schemas with the default `io: 'output'`. For transforms/pipes, `io` selects the side |
72
+ | R-616 | πŸ—ΊοΈ Records/maps | `z.record(k, v)` β†’ `{"type":"object","additionalProperties": v}` plus `propertyNames` when `k` is a constrained schema (Zod's native shape); `z.map(k, v)` β€” and `z.set(v)` β€” are **unrepresentable** (R-614: `{}` + warning; Zod throws for both by default) |
73
+ | R-617 | πŸ“ Key order | canonical (R-401): a root `$schema` header first, then `type`; reference, validation, applicator, annotation, and unknown keywords follow a fixed dictionary (unknown keys sort lexically). Schema-property names and literal data retain source order. The ordering is recursive and byte-stable (zopia re-sorts Zod's emission order) |
74
+ | R-618 | 🧹 Redundancy stripping | Zod emits built-in format schemas with a strict companion `pattern`, and `z.number().int()` with sentinel bounds `minimum: -9007199254740991` / `maximum: 9007199254740991` (Β±(2β΅Β³βˆ’1)). β‘  **strips** (a) `pattern` when paired with a known built-in `format` (`uuid`, `email`, `hostname`, `ipv4`, `ipv6`, `date-time`, `date`, `duration`, `uri`), and (b) each sentinel bound whenever present (independently). Custom patterns (`z.string().regex(…)` β€” no `format`) and real user bounds are kept. This is what keeps β‘  output spec-clean and round-trips exact |
75
+
76
+ **Example**
77
+
78
+ ```ts
79
+ const user = z.object({
80
+ id: z.uuid(),
81
+ name: z.string().min(1).describe('Display name'),
82
+ email: z.email(),
83
+ role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
84
+ });
85
+
86
+ zodToJsonSchema(user, { target: 'openapi-3.1' });
87
+ // ↓
88
+ {
89
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
90
+ type: 'object',
91
+ properties: {
92
+ id: { type: 'string', format: 'uuid' },
93
+ name: { type: 'string', minLength: 1, description: 'Display name' },
94
+ email: { type: 'string', format: 'email' },
95
+ role: { type: 'string', enum: ['admin', 'editor', 'viewer'], default: 'viewer' },
96
+ },
97
+ required: ['id', 'name', 'email', 'role'],
98
+ additionalProperties: false,
99
+ }
100
+ // ‴ output-side semantics: Zod puts defaulted keys in `required`
101
+ // (role has a default but is always present in the output).
102
+ // Request schemas are converted with io: 'input' instead (R-615):
103
+ zodToJsonSchema(user, { target: 'openapi-3.1', io: 'input' });
104
+ // β†’ required: ['id', 'name', 'email'] (role stays optional, default kept)
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Engine β‘‘ β€” JSON Schema β†’ Zod
110
+
111
+ ```ts
112
+ /** πŸ“ Convert a JSON Schema object (or .json file path) to Zod v4 code. */
113
+ function jsonSchemaToZod(schema: JsonSchemaObject | string, options?: JsonSchemaToZodOptions): JsonSchemaToZodResult;
114
+
115
+ interface JsonSchemaToZodResult {
116
+ /** πŸ“ TypeScript source β€” Zod v4 code, ready to paste into api_docs. */
117
+ code: string;
118
+ /** βš™οΈ Runtime equivalent of `code` (built by the same emitter). */
119
+ schema: $ZodType;
120
+ /** ⚠️ Every lossy/unsupported conversion (R-408 / D-12). */
121
+ warnings: ZopiaWarning[];
122
+ /** 🩹 Manifest-compatible exact restorations for non-identity mappings. */
123
+ overlays: JsonSchemaOverlay[];
124
+ }
125
+ ```
126
+
127
+ **Implementation** (D-04): a custom recursive emitter. Zod's experimental
128
+ `z.fromJSONSchema()` is *not* the output path (experimental status) β€” it is
129
+ used in tests as an independent cross-check. A string input is parsed as JSON
130
+ text unless it ends in `.json`, in which case that path is read first; object
131
+ and boolean inputs stay entirely in memory. The runtime schema retains source
132
+ property order, while emitted TypeScript sorts definition, property, dependency,
133
+ pattern, extension, and literal-object keys lexically. This code-only
134
+ canonicalization makes generation byte-stable after manifests recursively sort
135
+ JSON object keys. During reverse conversion, source `required` order (including
136
+ an explicit empty array) is restored only when runtime membership is unchanged;
137
+ a developer edit that adds or removes required fields remains authoritative.
138
+ The runtime and emitted-code results have equivalent validation behavior.
139
+
140
+ ### πŸ” The keyword map (the contract)
141
+
142
+ | πŸ“ JSON Schema | βš›οΈ Zod v4 emitted | πŸ†” Rule |
143
+ | --- | --- | --- |
144
+ | `{ "type": "string" }` | `z.string()` | R-621 |
145
+ | `{ "type": "integer" }` | `z.number().int()` | R-621 |
146
+ | `{ "type": "number" }` | `z.number()` | R-621 |
147
+ | `{ "type": "boolean" }` | `z.boolean()` | R-621 |
148
+ | `{ "type": "null" }` | `z.null()` | R-621 |
149
+ | `{ "type": "array", "items": S }` | `z.array(⟦S⟧)` | R-622 |
150
+ | `{ "type": "array", "items": [A, B] }` *(tuple, draft-04/07)* | `z.tuple([⟦A⟧.optional(), ⟦B⟧.optional()])`; `minItems` makes the corresponding leading positions required; overlay metadata restores the draft tuple spelling and `additionalItems` exactly | R-622/R-635 |
151
+ | `{ "type": "array", "prefixItems": [A, B] }` *(2020-12 tuple)* | `z.tuple([⟦A⟧.optional(), ⟦B⟧.optional()])`; `minItems` makes the corresponding leading positions required; absent `items` remains absent after reverse conversion | R-622/R-635 |
152
+ | `{ "type": "object", "properties": P, "required": R }` | `z.object({…})` β€” keys in `R` plain, others `.optional()` | R-623 |
153
+ | `{}` or annotations without `type` | `z.any()` | R-624 |
154
+ | object-, array-, string-, or numeric-only keywords without `type` | intersection of applicable-type unions: each constrained matching type plus unconstrained non-matching JSON types, preserving JSON Schema keyword applicability; frozen overlay restores the original keyword-only shape | R-624, R-635 |
155
+ | `{ "type": ["string", "null"] }` *(3.1/2020-12 nullable)* | `z.union([⟦string⟧, z.null()])`; frozen overlay restores the original type-array shape | R-625, R-635 |
156
+ | `{ "nullable": true }` *(3.0)* | `βŸ¦β€¦βŸ§.nullable()` around the complete converted schema, including a local reference and all of its validation siblings | R-625 |
157
+ | `{ "enum": ["a", "b"] }` | `z.enum(['a', 'b'])` (string enums β€” round-trips exactly) | R-626 |
158
+ | `{ "enum": [1, 2] }` / primitive mixed | `z.union([z.literal(1), z.literal(2)])` β€” ⚠️ β‘  expands this to `anyOf` of `const` nodes; engine β‘£'s serializer re-emits it as `enum` (R-654) | R-626 |
159
+ | structured/mixed `{ "enum": […] }` containing objects or arrays | one JSON-equality refinement with exact `enum` metadata, so runtime and generated code accept the same values and β‘  serializes the original enum shape | R-626 |
160
+ | `{ "const": v }` | primitives use `z.literal(v)`; arrays/objects use an exact JSON-equality refinement with `const` metadata | R-626 |
161
+ | `{ "format": "email" \| "uuid" \| "hostname" \| "ipv4" \| "ipv6" \| "date-time" \| "date" \| "duration" }` | `z.email()` / `z.uuid()` / `z.hostname()` / `z.ipv4()` / `z.ipv6()` / `z.iso.datetime()` / `z.iso.date()` / `z.iso.duration()` β€” all round-trip exactly (β‘  strips Zod's companion `pattern`, R-618) | R-627 |
162
+ | `{ "format": "uri" \| "url" }` | `z.url()` β€” Zod emits `format: "uri"`; when the source said `"url"` an overlay entry restores the exact original alias (R-635) | R-627 |
163
+ | `{ "format": "time" }` | `z.iso.time()` β€” ⚠️ Zod emits a pattern but **no** `format` key; overlay entry restores `{ "format": "time" }` and removes the pattern (R-635) | R-627 |
164
+ | `{ "format": "byte" }` *(Swagger 2.0 β€” base64)* | `z.base64()` β€” Zod emits `format: "base64"` + `contentEncoding` + pattern; overlay restores `format: "byte"` and removes the extras (R-635) | R-627 |
165
+ | `{ "format": "base64" \| "base64url" \| "emoji" }` | corresponding native Zod string check + warning/overlay because β‘ 's serialized keyword set differs from the source alias | R-627, R-635 |
166
+ | `{ "format": "int32" \| "int64" \| "uint32" \| "uint64" }` on a numeric schema | integer check plus the representable signed/unsigned bounds; warning/overlay restores the format and original user bounds. `int64` additionally uses `ZOPIA_WARN_INT64` because JavaScript has no exact 64-bit integer domain | R-627, R-635 |
167
+ | `{ "format": "<other>" }` on **any** base type | any format without an explicit row above (`password`, `binary`, `float`, `double`, `uri-reference`, `regex`, `decimal`, `json-pointer`, …) β†’ the **base type without the format** + warning `ZOPIA_WARN_CUSTOM_FORMAT` + overlay restoring the format verbatim | R-627, R-635 |
168
+ | `{ "minimum": n }` / `{ "maximum": n }` | `.min(n)` / `.max(n)` | R-628 |
169
+ | `{ "exclusiveMinimum": n }` *(number β€” 2020-12/3.1)* | `.gt(n)` β€” round-trips exactly (Zod emits numeric `exclusiveMinimum`) | R-628 |
170
+ | `{ "exclusiveMinimum": true }` *(boolean β€” draft-04/07)* | `.gt(n)` over `minimum` for numbers; `.min(n + 1)` for integers β€” **plus warning** `ZOPIA_WARN_LEGACY_EXCLUSIVE_BOUND` + overlay restoring the original boolean form (R-635) | R-628 |
171
+ | `{ "minLength": n }` / `{ "maxLength": n }` | `.min(n)` / `.max(n)` on strings | R-628 |
172
+ | `{ "pattern": p }` | `.regex(new RegExp(p))` | R-628 |
173
+ | `{ "contentEncoding": "base64" \| "base64url" \| "hex" }` | corresponding native/pattern string check; overlay restores the exact encoding keyword. Other encodings and `contentMediaType` retain base validation, warn, and are preserved by overlays | R-634, R-635 |
174
+ | `{ "multipleOf": n }` | `.multipleOf(n)` | R-628 |
175
+ | `{ "minItems": n }` / `{ "maxItems": n }` | homogeneous array `.min(n)` / `.max(n)`; tuple length refinements (with leading tuple positions required by `minItems`) | R-628 |
176
+ | `{ "uniqueItems": true }` | exact JSON-value equality refinement + **warning** `ZOPIA_WARN_UNIQUE_ITEMS` + overlay `set: { "uniqueItems": true }` (Zod cannot serialize the refinement keyword, so reverse restores it verbatim); cyclic, coercible, BigInt, and other non-JSON runtime candidates fail validation rather than throwing | D-12 |
177
+ | `{ "default": v }` | `βŸ¦β€¦βŸ§.default(v)` around the complete schema (including local references). Defaults, enum/const values, and annotation/extension payloads must be JSON values; invalid programmatic values are ignored with `ZOPIA_WARN_INVALID_SCHEMA` so runtime/code behavior cannot diverge | R-629 |
178
+ | `{ "required": [...] }` | keys listed are non-optional | R-623 |
179
+ | `{ "additionalProperties": false }` | `z.object({…}).strict()` | R-630 |
180
+ | `{ "additionalProperties": S }` | `z.object({…}).catchall(⟦S⟧)` | R-630 |
181
+ | `{ "additionalProperties": true }` *(or absent)* | `z.object({…}).passthrough()` | R-630 |
182
+ | `{ "dependencies": { "a": ["b"] } }` *(draft-04/06/07)* | object refinement requiring `b` whenever `a` is present | R-630 |
183
+ | `{ "dependencies": { "a": S } }` *(draft-04/06/07)* | object refinement applying schema `S` whenever `a` is present; boolean schemas are supported | R-630 |
184
+ | `{ "oneOf": [A, B, …] }` | `z.union([⟦A⟧, ⟦B⟧, …])` | R-631 |
185
+ | `{ "oneOf": […], "discriminator": {"propertyName": k} }` | `z.discriminatedUnion(k, [βŸ¦β€¦βŸ§])` β€” every member must be an object with a literal/enum at `k`, otherwise fall back to `z.union` + warning; the `discriminator` keyword itself is restored by an overlay entry (R-635) | R-631 |
186
+ | `{ "anyOf": […] }` | `z.union([…])` | R-631 |
187
+ | `{ "allOf": [A, B, …] }` | `z.intersection(⟦A⟧, ⟦B⟧, …)` (left-fold) β€” ⚠️ β‘  flattens object intersections into one object (structural loss) β†’ overlay `node` entry freezes the original `allOf` subtree verbatim (R-635/R-659) | R-632 |
188
+ | Boolean schema / empty object | `true` and `{}` β†’ `z.any()` Β· `false` β†’ `z.never()`; boolean syntax is recorded as a no-warning overlay because Zod serializes these as `{}` / `{ "not": {} }`, while exact `{ "not": {} }` maps natively back to `z.never()` | R-632/R-635 |
189
+ | Keyword-only / type-array schema | union that constrains only applicable JSON instance types + warning `ZOPIA_WARN_FROZEN_SUBTREE` + overlay `node` (restores the original applicability structure exactly) | R-633, D-12 |
190
+ | `{ "not": S }` | refinement rejecting values accepted by `S` + warning `ZOPIA_WARN_NOT` + overlay `node` (Zod cannot serialize `not`, so the original subtree is restored verbatim) | D-12 |
191
+ | `{ "$schema": … } / { "$id": … } / { "$comment": … }` | no Zod runtime effect; preserved verbatim by an overlay `set` entry | R-636 |
192
+ | `{ "title": t }` / `{ "description": d }` / `{ "example": v }` / `{ "examples": […] }` | a single `.meta({ title?, description?, examples? })` call on the schema (only the fields present) β€” verified copied verbatim back by β‘  (R-612), plus a JSDoc comment for human readers. `example` (single) is normalized to `examples: [v]` | R-633 |
193
+ | `{ "readOnly": … }` / `{ "writeOnly": … }` / `{ "deprecated": … }` / XML, discriminator, external-doc, and `x-…` annotations | copied into `.meta(…)` and retained by Engine β‘ , including when they are siblings of a local reference | R-633 |
194
+ | `{ "$ref": "#/…/schemas/X" }` | engine β‘‘ resolves local definitions (including percent-encoded URI-fragment segments) through its `$defs` closure and intersects sibling constraints; engine β‘’ default mode embeds needed component definitions, reference mode imports direct `XSchema` targets, and nested component pointers are embedded rather than misclassified as component names | R-402/R-403/R-634 |
195
+ | `{ "$defs": { … } }` / `{ "definitions": { … } }` | file-local consts, in definition order; malformed containers and entries emit exact-pointer `ZOPIA_WARN_INVALID_SCHEMA` warnings instead of disappearing | R-634 |
196
+ | `{ "if": I, "then": T, "else": E }` | base schema plus a refinement that validates `T` when `I` succeeds and `E` otherwise; boolean branches and exact keyword-only applicability are supported, while malformed/detached branches warn | R-632 |
197
+ | `{ "propertyNames": { "type": "string", <pattern/minLength/maxLength> }, "additionalProperties": <schema> }` with no other object-structure keywords | `z.record(⟦propertyNames⟧, ⟦additionalProperties⟧)` β€” an exact conversion: Zod emits the identical `{ type: 'object', propertyNames, additionalProperties }` shape back, so this form produces **no** warning and **no** overlay | D-22 |
198
+ | `{ "patternProperties": … }` / non-native `{ "propertyNames": … }` forms / `{ "minProperties": n }` / `{ "maxProperties": n }` / `{ "contains": … }` | runtime refinements apply each matching pattern/property/count/containment constraint; `additionalProperties` applies only to keys unmatched by declared properties and patterns; warning + overlay `node` restores the original unsupported structure | D-12 |
199
+
200
+ > ⟦S⟧ = "the Zod code of the sub-schema S" (recursion).
201
+
202
+ > πŸ“Œ **Rule R-635** β€” *overlay recording.* Every β‘‘ mapping that is not
203
+ > round-trip-identity records a **manifest overlay entry** preserving the
204
+ > original keywords verbatim, so engine β‘£ can restore them. Overlay entry:
205
+ > `{ at: <JSON pointer>, set?: <keywords>, remove?: <keys>, node?: <sub-tree> }` β€”
206
+ > `set`/`remove` for surgical keyword restoration (format aliases, `time`,
207
+ > false/legacy exclusive bounds, absent tuple/object keys, empty `required`,
208
+ > local definitions, `discriminator`, `uniqueItems`), `node` for exact boolean
209
+ > schema syntax and structural freezes (`allOf`-of-objects, `not`, `if/then/else`,
210
+ > `patternProperties`, … β€” each emits warning `ZOPIA_WARN_FROZEN_SUBTREE`).
211
+ > A schema with no lossy keywords produces **no** overlay entries β€” the
212
+ > canonical Admin API fixture asserts exactly that. Standalone callers receive
213
+ > these entries in `JsonSchemaToZodResult.overlays`; engine β‘’ prefixes each
214
+ > pointer with the operation/component location and writes the entries to the
215
+ > manifest. Engine β‘£ applies them after runtime Zod serialization.
216
+
217
+ ### πŸ“ Emitted code style (fixed)
218
+
219
+ | πŸ“ Rule | Example |
220
+ | --- | --- |
221
+ | deterministic TypeScript/JSON literals, semicolon-terminated declarations, one trailing newline | `const schema = z.literal("ready");` |
222
+ | chainable checks stay in one expression; warning-marker wrappers use stable multiline indentation | `z.string().min(1).max(100).regex(new RegExp("x"))` |
223
+ | component consts are `PascalCase + 'Schema'`; local `$defs` names are sanitized lower-camel identifiers | `UserSchema`, `node` |
224
+ | schema reuse is identity-driven (R-403) | default endpoints inline each occurrence; component-reference endpoints reuse one import |
225
+ | circular refs β†’ `z.lazy(() => XSchema)` (R-402) | β€” |
226
+ | warnings mirrored inside the containing emitted expression as `// @zopia:warn <CODE> <keyword> β€” <message> (<JSON pointer>)`; the pointer identifies the exact source node | β€” |
227
+
228
+ **Example**
229
+
230
+ ```ts
231
+ jsonSchemaToZod({
232
+ type: 'object',
233
+ required: ['name', 'email'],
234
+ properties: {
235
+ name: { type: 'string', minLength: 1 },
236
+ email: { type: 'string', format: 'email' },
237
+ role: { type: 'string', enum: ['admin', 'editor', 'viewer'], default: 'viewer' },
238
+ id: { type: 'string', format: 'uuid' },
239
+ },
240
+ });
241
+ // ↓ code (rootName default: 'schema' β€” see Configuration)
242
+ `
243
+ const schema = z.object({
244
+ name: z.string().min(1),
245
+ email: z.email(),
246
+ role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
247
+ id: z.uuid().optional(),
248
+ });
249
+ `
250
+ ```
251
+
252
+ ---
253
+
254
+ ## Engine β‘’ β€” OpenAPI β†’ api docs
255
+
256
+ ```ts
257
+ /** πŸ“„ Generate the api_docs tree from a spec (object, JSON/YAML text, or .json/.yaml/.yml file path). */
258
+ function openApiToApiDocs(input: string | Record<string, unknown>, options?: ZopiaGenerateOptions): Promise<ZopiaGenerateResult>;
259
+ ```
260
+
261
+ Pipeline (see [Architecture β†’ The pipeline](04-architecture.md#-the-pipeline)):
262
+ **detect β†’ bundle external refs (file inputs, D-17) β†’ normalize (v2 | v3) β†’ refs β†’ render (directory | flat) β†’ manifest**.
263
+
264
+ **Input parsing (v0.2.x, D-16):** text starting with `{`/`[` is parsed as
265
+ JSON; otherwise an unreadable-path-looking string is read as a file, and the
266
+ extension picks the parser (`.json` β†’ JSON, `.yaml`/`.yml` β†’ YAML, anything
267
+ else β†’ JSON with YAML fallback). Multi-line non-JSON text and single-line
268
+ mapping entries (`swagger: "2.0"`, …) are parsed as inline YAML. YAML is a
269
+ deterministic, owned YAML 1.2 core-schema parser (`src/conversions/yaml.ts`):
270
+ block/flow collections (comments, blank lines, and dedented closers inside
271
+ multi-line flow; optional trailing commas), plain/single/double-quoted scalars,
272
+ literal/folded block scalars (`#` lines indented as deeply as the content are
273
+ literal content; shallower ones are ignorable comments), comments,
274
+ anchors/aliases (resolutions clone the anchored value), `<<` merge keys,
275
+ `%YAML 1.x` directives, and single-document `---`/`...` markers are
276
+ supported; tab indentation, duplicate keys, undefined aliases, custom tags,
277
+ multiple documents, complex `?` keys, content after the `...` marker,
278
+ block-scalar header junk, structure-looking plain continuations (`k: word`
279
+ followed by a deeper `a: b`/`- x` line), bare `key: value` pairs inside
280
+ flow sequences, and `.inf`/`.nan` are rejected. Parsed YAML produces the same plain values as
281
+ an equivalent JSON document, so every downstream rule in this document is
282
+ identical for both input formats.
283
+
284
+ **External `$ref` bundling (v0.2.x, D-17):** inputs that resolve to a *file
285
+ path* bundle same-folder external references before normalization β€” `other.yaml`
286
+ (with `.json`/`.yml` variants, `./…` spellings, an optional `#` JSON Pointer,
287
+ and whole-file targets without a fragment). Every sibling file is read and
288
+ parsed once (JSON/YAML by extension, same rules as the primary input), bundled
289
+ content is deep-cloned into place, sibling keys next to a `$ref` win over the
290
+ bundled content, a local ref inside bundled content keeps resolving against its
291
+ own file, and host-document local refs (`#/…`) stay untouched. Traversal skips
292
+ literal/example payload positions exactly like the reference preflight walker.
293
+ References outside the spec folder (URLs, `../`, absolute paths, subdirectories,
294
+ drives, non-spec extensions) β€” and *any* external ref in object or text inputs β€”
295
+ keep failing with `ZOPIA_REF_EXTERNAL`; unreadable targets keep that code with
296
+ the file as `cause`; unparsable targets fail with
297
+ `ZOPIA_SPEC_INVALID_JSON`/`ZOPIA_SPEC_INVALID_YAML` at the target path; missing
298
+ pointers, bad fragments, circular external chains, sibling keys on non-object
299
+ targets, and chains deeper than 512 fail with `ZOPIA_REF_NOT_FOUND`. Because
300
+ bundling runs before the manifest's source hash, the bundled spec, an
301
+ equivalent inline spec, and everything derived from them (generated trees,
302
+ warnings, reverse conversion output) are byte-identical; reverse conversion
303
+ emits the bundled single-file document and never re-splits files.
304
+
305
+ ### πŸ” Step 1 β€” detect
306
+
307
+ | 🧾 Top-level | 🏷️ Kind |
308
+ | --- | --- |
309
+ | `swagger === '2.0'` | `openapi-2.0` |
310
+ | `openapi === '3.0.x'` | `openapi-3.0` |
311
+ | `openapi === '3.1.x'` | `openapi-3.1` |
312
+ | anything else | πŸ›‘ `ZOPIA_SPEC_UNSUPPORTED_VERSION` |
313
+
314
+ Missing `paths` β†’ `ZOPIA_SPEC_MISSING_PATHS`. Invalid JSON β†’ `ZOPIA_SPEC_INVALID_JSON`; invalid YAML β†’ `ZOPIA_SPEC_INVALID_YAML`. `swagger` and `openapi` are mutually exclusive, and root keys must belong to the selected dialect or be `x-…` extensions. Path Item keys must be supported lowercase HTTP methods, dialect-appropriate fixed fields, or `x-…` extensions; typos and unsupported fields fail instead of disappearing from the generated tree.
315
+
316
+ ### πŸ”§ Step 2 β€” normalize (the dialect tables)
317
+
318
+ #### Swagger 2.0 β†’ IR
319
+
320
+ | πŸ“ Swagger 2.0 | 🧬 IR |
321
+ | --- | --- |
322
+ | `definitions` | `components` (R-503: 2020-12-flavoured) |
323
+ | `host` + `schemes[0]` + `basePath` | `servers: [ "<scheme>://<host><basePath>" ]` (or `[basePath]` / `['/']` without host) |
324
+ | parameter `in: body` | `request.body` = its `schema`; `requestContentType` from operation `consumes` (else global). Multiple body parameters and OpenAPI 3-style operation `requestBody` are rejected |
325
+ | parameters `in: formData` | `request.body` = object of the formData params (`required` flags kept); valid primitive/array values plus top-level `type: file` are supported, while `type: object` and arrays with illegal or incomplete nested Items Objects are rejected; `requestContentType` = `multipart/form-data` if in `consumes`, else `application/x-www-form-urlencoded` |
326
+ | parameters `in: query/header/path` | `request.query/headers/params` β€” Swagger primitive/array params (`type`, `format`, `enum`, …) become their `schema`; every nested array Items Object must declare a legal primitive/array type. OpenAPI 3-style `schema`/`content`, plus `object` and `file` outside their legal body/form locations, are rejected; `type: integer, format: int32/int64` β†’ `{ "type": "integer" }` (`int64` β†’ + warning `ZOPIA_WARN_INT64`) |
327
+ | operation/global `consumes` | must be an array of non-empty strings; `requestContentType` uses the first JSON-ish type (else first) |
328
+ | operation/global `produces` | must be an array of non-empty strings; `responseContentType` uses the first JSON-ish type (else first), and each response's single `schema` β†’ `response.statuses[].schema` under that media type. OpenAPI 3-style response `content` and response-level `produces` are rejected. Swagger response keys are exact `100`–`599` statuses or `default` (OpenAPI `4XX`-style ranges are rejected) |
329
+ | response `examples` (media-type β†’ single value, the legacy shape) | wrapped as one `default`-named example under the primary media type |
330
+ | `securityDefinitions` (basic/apiKey/oauth2) | `securitySchemes` (OpenAPI 3 shapes) |
331
+ | `security` (op or global) | op-level `security` / global `defaultSecurity` (verbatim requirement lists) + `auth: 'YES'` where a requirement applies (explicit `security: []` β‡’ `auth: 'NO'`) |
332
+ | `deprecated: true` | `deprecated: true` |
333
+ | `basePath` / version | manifest `source` + `servers` |
334
+
335
+ #### OpenAPI 3.0/3.1 β†’ IR
336
+
337
+ | πŸ“ OpenAPI 3.x | 🧬 IR |
338
+ | --- | --- |
339
+ | `components.schemas` | `components` |
340
+ | `servers[]` | full entries preserved in the manifest; `variables` emit `ZOPIA_WARN_SERVER_VARIABLES` because endpoint modules have no representation |
341
+ | `requestBody.content` | primary media type (R-641) β†’ `request.body` + `requestContentType`; others recorded in manifest |
342
+ | parameters `in: path/query/header/cookie` | `request.params/query/headers/cookies`; every `{name}` path placeholder requires exactly one matching required path parameter and unrelated path parameters are rejected; OpenAPI 3 rejects legacy `in: body/formData` parameters and top-level Swagger schema keywords (`schema`/`content` must be used), while `cookie` is accepted only for OpenAPI 3.x because Swagger 2.0 has no cookie parameter location |
343
+ | `responses` (per media type) | `response.statuses[]` β€” primary media type per response (R-641); description required by spec β†’ kept; legacy top-level response `schema` is rejected in favor of `content` |
344
+ | `nullable: true` *(3.0)* | `type: [t, "null"]` in the IR (R-503) |
345
+ | `exclusiveMinimum/Maximum` boolean *(3.0)* | numeric form + warning (R-628) |
346
+ | `components.securitySchemes` | `securitySchemes` |
347
+ | `security` (op or global) | op-level `security` / global `defaultSecurity` (verbatim requirement lists) + `auth: 'YES'` where a requirement applies (explicit `security: []` β‡’ `auth: 'NO'`) |
348
+ | `example` (single) | `examples` single-name map |
349
+ | the `default` response & non-standard codes (`419`, `499`, `512`, …) | emitted verbatim as the `default` / numeric response keys (km-api β‰₯ 0.4.1 accepts both) |
350
+ | parameter extras (`allowEmptyValue`, `style`, `explode`, `deprecated`, `example`) | no home in Zod/km-api β†’ overlay entries on the operation subtree pointers (R-635) |
351
+ | response `headers` | no home in km-api β†’ `apis[].responseOverlay` entries (re-emitted verbatim, R-654c) |
352
+ | 3.1 `webhooks` object | webhook operations emit endpoint files under `webhooks/<name>/<method>/index.ts` (D-23) with the same component/`$ref` handling as path endpoints; operation-less webhook maps stay manifest-only with `ZOPIA_WARN_WEBHOOKS` |
353
+ | path item that is a local `$ref` | resolve the local JSON Pointer (including chained references); external, missing, malformed, and circular references are rejected |
354
+ | `deprecated: true` | `deprecated: true` |
355
+
356
+ > πŸ“Œ **Rule R-641** β€” *primary media type*: when a `content` map has several
357
+ > entries, `application/json` wins; otherwise the first key in document order.
358
+ > Non-primary media types are recorded in the manifest and produce warning
359
+ > `ZOPIA_WARN_MULTI_CONTENT`.
360
+ >
361
+ > πŸ“Œ **Rule R-642** β€” *the km-api 0.4.1 contract.* zopia **requires km-api β‰₯
362
+ > 0.4.1**: `makeApiConfig` is a type-level factory (no runtime validation), so
363
+ > the generated tree must **typecheck** against installed published km-api
364
+ > 0.4.1 (D-14; the golden-tree contract test enforces this, R-126). All 8
365
+ > methods (including `trace`), custom numeric and `default` response statuses,
366
+ > arbitrary OpenAPI MIME keys, and `operationId` are emitted as code. Because
367
+ > km-api 0.4.1's published declarations enumerate MIME values even though
368
+ > OpenAPI allows extension strings, zopia keeps the exact runtime string behind
369
+ > a narrow type-only assertion at that package boundary; the strict generated
370
+ > call still checks the rest of `makeApiConfig`. The remaining km-api gaps
371
+ > (per-parameter metadata, response `headers`) are preserved in the manifest
372
+ > (overlay / `apis[].responseOverlay`, R-635/R-754).
373
+
374
+ ### πŸ”— Step 3 β€” refs
375
+
376
+ Per [Architecture β†’ The reference graph](04-architecture.md#-the-reference-graph)
377
+ (R-402): file-path inputs first bundle *same-folder* external refs inline
378
+ (D-17, above); afterwards unknown β†’ `ZOPIA_REF_NOT_FOUND`; remaining external
379
+ β†’ `ZOPIA_REF_EXTERNAL`; cycles β†’ `z.lazy` plan. Refs to **non-schema** reusable objects (global
380
+ `parameters`/`responses` in 2.0, `components.parameters/responses/examples`
381
+ in 3.x) stay out of the schema graph. In components mode, bare-namespace `$ref`
382
+ use sites import the declaration's module (`components/parameters/<Name>`,
383
+ `components/responses/<Name>` β€” D-18); merged `$ref`-sibling forms resolve at
384
+ use sites for endpoint rendering as before. Every declaration and exact ref
385
+ placement remains in the manifest and is restored by engine β‘£, with reusable
386
+ parameter/response declarations refreshed from their current modules
387
+ (R-402/R-655/R-659).
388
+
389
+ ### πŸ–¨οΈ Step 4 β€” render
390
+
391
+ Lays out files per the mode and emits code:
392
+
393
+ - πŸ“‚ layout β€” [API docs format](07-api-docs.md) (trees, naming, collisions)
394
+ - πŸ“„ endpoint files β€” the **`index.ts` contract** ([07 β†’ contract](07-api-docs.md#-the-indexts-contract)); every *practical* field of `makeApiConfig` is filled from the IR when the source provides it (T-7)
395
+ - 🧱 components β€” [Components](08-components.md)
396
+ - πŸ“¦ manifest β€” [07 β†’ The manifest](07-api-docs.md)
397
+
398
+ **Result**
399
+
400
+ ```ts
401
+ interface ZopiaGenerateResult {
402
+ /** πŸ“‚ Every file written, relative to outDir, sorted (R-401). */
403
+ files: Array<{ path: string; kind: 'endpoint' | 'component' | 'manifest' }>;
404
+ /** ⚠️ All warnings (R-408). */
405
+ warnings: ZopiaWarning[];
406
+ /** πŸ“¦ Manifest path relative to outDir; absent when `manifest: false`. */
407
+ manifestPath?: string;
408
+ }
409
+ ```
410
+
411
+ The public wrapper validates all options before filesystem access, reads file
412
+ inputs, rejects missing/external references with stable `ZopiaError` codes,
413
+ preflights every operation contract, and returns paths sorted independently of
414
+ source document order. Before regeneration it compares the existing manifest's
415
+ canonical source hash, layout, component options, and owned-file presence.
416
+ Any drift (or an invalid manifest) emits one `ZOPIA_WARN_STALE_TREE`; after new
417
+ files are written, obsolete files claimed by the previous valid manifest are
418
+ pruned without touching custom files. Symlinked ancestors are rejected with
419
+ `ZOPIA_FS_OUTSIDE_OUTDIR`. `manifest: false` removes a previous manifest and
420
+ omits both the new manifest file and `manifestPath`.
421
+
422
+ ---
423
+
424
+ ## Engine β‘£ β€” api docs β†’ OpenAPI
425
+
426
+ ```ts
427
+ /** πŸ“¦ Documented directory-level API; defaults to OpenAPI 3.1. */
428
+ function apiDocsToOpenApi(path: string, options?: ZopiaReverseOptions): Promise<{ openapi: Record<string, unknown>; warnings: ZopiaWarning[] }>;
429
+
430
+ /** πŸ“¦ Low-level snapshot helper; omission preserves the manifest source dialect. */
431
+ function manifestToOpenApi(manifest: ZopiaManifest, options?: ZopiaReverseOptions): Record<string, unknown>;
432
+ function manifestFileToOpenApi(file: string, options?: ZopiaReverseOptions): Promise<Record<string, unknown>>;
433
+
434
+ interface ZopiaReverseOptions {
435
+ version?: '2.0' | '3.0' | '3.1';
436
+ onWarning?: (warning: ZopiaWarning) => void;
437
+ }
438
+
439
+ interface ZopiaManifest {
440
+ $schema: 'zopia:manifest@1';
441
+ source: { kind: string; title?: string; version?: string };
442
+ pathOrder?: string[];
443
+ schemaComponentsPresent?: boolean;
444
+ components?: Array<{ name: string; file: string | null; schema: unknown }>;
445
+ apis: Array<{ file: string; path: string; method: string; sourceOperation?: Record<string, unknown>; refs?: Array<{ at: string; ref?: string; component?: string }>; overlay?: Array<{ at?: string; set?: Record<string, unknown>; remove?: string[]; node?: unknown; key?: string; value?: unknown }>; responseOverlay?: unknown }>;
446
+ }
447
+ ```
448
+
449
+ `manifestFileToOpenApi()` imports each trusted `apis[].file`, each `webhooks[].file`, and every emitted `components[].file` relative to the manifest. Runtime km-api metadata and edited request/response Zod schemas override their manifest snapshots; request-side schemas use Engine β‘  input semantics, response-side schemas use output semantics, and imported component references remain `$ref`s. A manifest `$ref` never replaces a different component selected in the runtime schema. Passing `version: '3.0' | '3.1'` selects both the document envelope and Engine β‘  schema target; Swagger source operations and reusable objects are normalized to the selected OpenAPI 3 dialect. Passing `version: '2.0'` (D-20) downgrades 3.x-sourced manifests through the source-dialect Swagger reconstruction path: `nullable` spellings become `x-nullable`, `requestBody` becomes `body`/`formData` parameters, `components.schemas` becomes `definitions`, reusable parameters/responses move to the top-level `parameters`/`responses` maps, `servers[0]` decomposes into `host`/`basePath`/`schemes`, and unrepresentable 3.x features drop with deterministic `ZOPIA_WARN_DIALECT_DOWNGRADE`/`ZOPIA_WARN_WEBHOOKS` warnings. `apiDocsToOpenApi()` supplies the documented 3.1 default, while the low-level manifest helpers preserve the source dialect when options are omitted for snapshot/backward compatibility. `manifestToOpenApi()` remains synchronous and never imports files.
450
+
451
+ | # | Step | Rules |
452
+ | --- | --- | --- |
453
+ | R-651 | πŸ“¦ **Manifest required** | no `.zopia-manifest.json` β†’ `ZOPIA_DOCS_MISSING_MANIFEST`; a missing file field or a missing/renamed endpoint or emitted-component file β†’ `ZOPIA_DOCS_MANIFEST_MISMATCH`. All listed paths are preflighted (including containment and regular-file checks) before any generated module is imported, so a stale manifest cannot partially execute the tree. (The manifest is what makes flat mode unambiguous β€” D-06.) |
454
+ | R-652 | 🧬 **Trusted import** (D-08) | each `apis[].file` is imported at runtime (Bun executes the `.ts`). The module must export a `makeApiConfig` result β€” default or named; otherwise `ZOPIA_DOCS_IMPORT_FAILED`. |
455
+ | R-653 | 🧩 **Extraction** | from the config result: `method`, `pathShape β†’ makeOpenApiPathShape()` (guarantees `{param}` form; identity on already-OpenAPI paths), `summary`, `description`, `operationId`, `tags` (strip `#`), `auth` (`'YES' | 'NO'`), `deprecated === 'YES'` β†’ `deprecated: true`; `disable` remains an independent status field, `requestContentType`/`responseContentType` (the actual media types β€” km-api 0.4.1's open unions), `examples`. Edited media types replace the former selected media entry rather than retaining its stale manifest schema; runtime examples likewise replace `example`/`examples` snapshots. The operation's **`security` requirement** comes from the manifest, not the config (km-api stores only the `auth` status): `apis[].security` when present, else the top-level `defaultSecurity` β€” see R-656. Malformed edited path templates, missing or unrelated runtime path parameters, duplicate reconstructed operation IDs, and edited path/method collisions fail as generated-module errors instead of producing an invalid OpenAPI document; synchronous manifest-only reconstruction validates path parameters, request bodies, and response status/description/dialect contracts as `ZOPIA_MANIFEST_INVALID`. Response keys β€” including valid custom codes and `default` β€” come straight from the config after dialect-aware validation (`1XX`–`5XX` ranges are OpenAPI-only); response `headers` arrive via `apis[].responseOverlay`. |
456
+ | R-654 | πŸ“ **Schemas** | every request/response Zod schema β†’ engine β‘  with `target: version === '3.0' ? 'openapi-3.0' : 'openapi-3.1'`; **request** schemas with `io: 'input'`, **response** schemas with `io: 'output'` (R-615) β€” so defaulted request fields naturally stay out of `required`. Engine β‘  losses are collected and rebased to the exact output operation/component pointer. Source-only structural detailsβ€”boolean schema spelling, `required` order/presence, empty `properties`, unused local definitions, and the distinction between absent/`true` object opennessβ€”are restored only when the corresponding runtime structure is still equivalent; edited boolean children, required membership, strictness/passthrough, properties, and definitions remain authoritative. `z.any()` body β†’ no `requestBody` unless the source body itself was unconstrained or schema-less (the manifest disambiguates those reversible cases). `z.void()` responses are detected **before** engine β‘  (Zod lists `z.void()` as unrepresentable β€” it would become `{}` + warning) β†’ no `content` (e.g. 204). Empty `z.object({})` in params/query/headers/cookies β†’ omitted. Swagger form-data is changed to a body parameter when code changes the request media type away from a form media type; cookie parameters and non-body object/reference schemas fail explicitly because Swagger 2.0 cannot represent them. The serializer then applies **value normalizations**: (a) drop sentinel safe-integer bounds (R-618), (b) re-emit const-literal `anyOf`/`oneOf` as `enum` (inverse of Zod's expansion), (c) `apis[].responseOverlay` entries (response `headers`) are re-emitted verbatim into the matching `responses` entry. |
457
+ | R-655 | 🧱 **Components** | the manifest **always** lists schema components (name, full `schema`, `file: <path> \| null`), and β€” in components mode β€” reusable parameter/response modules as `kind: "parameter"` / `"response"` entries with their derived schema (v0.2.x, D-18; schema-less responses get no entry), and uses `schemaComponentsPresent` to distinguish an absent container from explicit empty `definitions` / `components.schemas`; other OpenAPI component sections remain in `componentsOverlay`, while Swagger reusable `parameters` and `responses` are retained separately. `file` set (components mode): the component file is imported and converted β€” developer edits win where runtime Zod can express them, followed by exact syntax overlays. `file: null` (default mode): the manifest `schema` is re-emitted verbatim. File-based endpoint use-sites become `$ref`s from imported Zod identities; snapshot ref pointers never overwrite a developer-selected component (R-752). |
458
+ | R-656 | πŸ” **Security** | `securitySchemes` **and the requirement lists** (top-level `defaultSecurity`, per-operation `apis[].security`) are restored from the manifest and validated structurally. Manifest facts are authoritative even when an edited runtime `auth` flag conflicts: an operation emits its own `security` key iff `apis[].security` is present (an explicit `[]` is re-emitted as `security: []`), otherwise the global `security` is re-emitted from `defaultSecurity` (including `defaultSecurity: []`); readers also recover an explicit requirement from a legacy `sourceOperation` when its dedicated field is absent. Only when `auth: YES` has no recoverable requirement does reverse conversion synthesize a bearer requirement and report `ZOPIA_WARN_DEFAULT_SECURITY` at that operation's `/security` pointer. The fallback scans `bearerAuth`, `bearerAuth2`, and so on, reusing the first compatible definition or selecting the first free name without replacing existing definitions; OpenAPI 3 uses `http`/`bearer`, while source-dialect Swagger output uses its representable `apiKey`/`Authorization` header form. |
459
+ | R-657 | 🏷️ **Document frame** | `info` from the manifest `source` (title/version/description); the exact `openapi` patch string from `source.openapiVersion`; `servers`, `tags`, component/security-section presence, and path-item metadata/extensions from the manifest. `pathOrder` retains operation-free/empty Path Items and keeps reverse regeneration collision-stable. Absent and explicitly empty frame collections remain distinct. Legacy manifests missing title/version use `title: 'Zopia API'` / `version: '0.0.0'` and emit `ZOPIA_WARN_DEFAULT_INFO` at `#/info/title` / `#/info/version`. Newly generated manifests still require both values. |
460
+ | R-658 | πŸ“ **Shape** | `version: '3.0'` emits `openapi: '3.0.0'` with OpenAPI 3.0 schemas; `version: '3.1'` emits `openapi: '3.1.0'` with JSON Schema 2020-12 semantics. The directory-level API and CLI default to 3.1 (D-09); invalid versions fail with `ZOPIA_CONFIG_INVALID` before generated code is imported. Translation preserves nullable refs, literal annotation data, effective exclusive bounds, and every Swagger `consumes`/`produces` media type. A 3.1β†’3.0 conversion omits `webhooks`, `jsonSchemaDialect`, and `components.pathItems` only with source-located `ZOPIA_WARN_WEBHOOKS` / `ZOPIA_WARN_DIALECT_DOWNGRADE` diagnostics, and 2.0 output applies the same omissions plus the Swagger rewriting set (D-21). A path-item `$ref` whose target lives in an omitted container (`#/components/pathItems/…` or `#/webhooks/…`) expands its operation in place so the output never carries a dangling reference; references into surviving containers stay verbatim. |
461
+ | R-659 | 🩹 **Refs & overlays applied last** | after Zod serialization, file-backed conversion restores each `apis[].refs` entry at its RFC 6901 pointer, then applies schema overlays (`set`/`remove` or frozen `node`), operation overlays, and `responseOverlay`. A source ref is not restored when runtime code already points at a different component, and overlays beneath that skipped ref are skipped too; developer-selected reference changes therefore win. Draft-tuple overlays compare tuple members first, and reference-free frozen overlays compare the runtime subtree with a deterministic source baseline; an edited subtree skips the source overlay instead of losing the edit. Response overlays restore non-schema response facts without replacing code-derived `content`, Swagger `schema`, or examples. |
462
+
463
+ ### πŸ” Why the round-trip closes
464
+
465
+ Two sources of truth, one rule each:
466
+
467
+ | πŸ“¦ Source | Carries |
468
+ | --- | --- |
469
+ | πŸ“„ **the generated code** | schema *content* β€” what developers may edit. Converted back by engine β‘  + serializer normalizations (R-654) |
470
+ | πŸ“¦ **the manifest** | *placement & non-representable facts* β€” full component schemas, `$ref` pointers (`refs`), path-item metadata/ref inheritance (`pathsOverlay` / `pathItemRef`), keyword-level restorations (`overlay`: format aliases, `time`, boolean schemas/bounds, tuple spelling, `discriminator`, `uniqueItems`, parameter extras, …) plus conditional source-structure restoration for local definitions, empty/absent schema keys, and object openness and frozen subtrees (`overlay.node`: `allOf`-of-objects, `not`, `if/then/else`, `patternProperties`, …), km-api-less response facts (`apis[].responseOverlay`: response `headers` β€” R-754), plus the exact document frame and presence (OpenAPI patch version, info, servers, tags, security schemes, security requirements, non-primary media types, titles, examples) |
471
+
472
+ Engine β‘£ applies them in the fixed order **convert β†’ refs β†’ schema/operation overlay β†’ response overlay**
473
+ (R-659). The union reproduces the original document; the only remaining
474
+ difference is key order, which canonicalization (R-401) resolves. That is
475
+ tested as a property for every fixture
476
+ ([Testing](11-testing.md#-round-trip-property-tests)).
477
+
478
+ ### ⚠️ Honest limits (documented, warned, manifest-recorded)
479
+
480
+ | 🧩 Fact | What happens |
481
+ | --- | --- |
482
+ | `title`, `example(s)` | manifest β†’ re-emitted verbatim |
483
+ | non-primary media types | manifest β†’ re-emitted as extra `content` entries |
484
+ | keyword-level losses (`uniqueItems`, `discriminator`, `time`/`url` formats, boolean exclusive bounds, custom formats) | overlay `set`/`remove` β†’ restored verbatim (R-635) |
485
+ | structural losses (`allOf`-of-objects, `not`, `if/then/else`, `patternProperties`, …) | overlay `node` β†’ **frozen subtree** restored verbatim + warning `ZOPIA_WARN_FROZEN_SUBTREE` β€” code edits to a frozen subtree do not propagate in Phase 1 (documented in the generated comment) |
486
+ | parameter extras (`allowEmptyValue`, `style`, `explode`, …) & response `headers` β€” no home in km-api (R-642) | overlay / `apis[].responseOverlay` β†’ restored verbatim |
487
+ | 3.1 `webhooks` | runtime-refreshed from the generated `webhooks/` endpoint files and reassembled in exact `webhookOrder` order for 3.1 output (D-23); omitted with `ZOPIA_WARN_WEBHOOKS` for 3.0/2.0 output |
488
+ | server `variables` | no endpoint-code representation + `ZOPIA_WARN_SERVER_VARIABLES`; preserved and restored through the manifest |
489
+
490
+ ## πŸ”— Next
491
+
492
+ - πŸ“‚ Where every file lands β†’ [API docs format](07-api-docs.md)
493
+ - 🧱 Component options in depth β†’ [Components](08-components.md)