zopia 0.3.0 โ†’ 0.5.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.
@@ -1,223 +0,0 @@
1
- # ๐Ÿงฑ Components
2
-
3
- A spec keeps DRY with **components** โ€” named, reusable schemas referenced via
4
- `$ref` (Swagger 2.0: `definitions`; OpenAPI 3.x: `components.schemas`).
5
- Components can reference *other* components, and even themselves (cycles).
6
- This document defines how zopia treats them in the generated tree.
7
-
8
- ## ๐Ÿ”— Local references
9
-
10
- OpenAPI local JSON Pointer references are resolved only when their target exists
11
- in the same document. The resolver supports `#` for the document root and the
12
- standard `~1` and `~0` pointer escapes. External references and invalid pointer
13
- escapes are rejected explicitly rather than silently dropped. Component files
14
- and reusable endpoint references are part of the full rendering phase.
15
-
16
- ## โš™๏ธ The two options
17
-
18
- > ๐ŸŽฏ **T-8 / T-9** โ€” both options are **booleans, both default `false`**, and
19
- > `useComponentAsReference` only makes sense when `insertComponents` is `true`.
20
- > Component files and exact endpoint schema-reference imports are implemented;
21
- > nested component-reference imports are recursively supported across the complete Engine โ‘ก schema surface, including objects, arrays, compositions, conditionals/refinements, nullable schemas, enums, constants, named schema maps, and additional-property schemas. Direct aliases retain distinct lazy identities for reverse conversion, and direct/mutual cyclic imports use lazy schemas.
22
-
23
- | โš™๏ธ `insertComponents` | โš™๏ธ `useComponentAsReference` | ๐Ÿ“‚ What is generated | ๐Ÿ“„ What endpoint files do |
24
- | :---: | :---: | --- | --- |
25
- | `false` | `false` *(forced)* | nothing extra | inline every referenced schema occurrence (R-403) โ€” **fully self-contained** |
26
- | `false` | `true` | ๐Ÿ›‘ **invalid** โ†’ `ZOPIA_CONFIG_INVALID` ("enable `insertComponents` first") | โ€” |
27
- | `true` | `false` | `components/**` written | still **inline** (components exist as standalone files, but endpoints do not import them) |
28
- | `true` | `true` | `components/**` written | **import** components โ€” single source of truth, no duplication |
29
-
30
- > ๐Ÿ“Œ **Rule R-801** โ€” `insertComponents: true` emits **every** declared
31
- > component (even ones no operation references) โ€” the tree mirrors the spec.
32
- > Unused components are still useful documentation, and the mirror keeps the
33
- > reverse conversion complete.
34
-
35
- ## ๐Ÿ“‚ Layout (when `insertComponents: true`)
36
-
37
- Components land in `api_docs/components/`, one directory **per component,
38
- named exactly as declared** (case preserved โ€” the name is data, D-06):
39
-
40
- ```text
41
- api_docs/
42
- โ”œโ”€โ”€ .zopia-manifest.json
43
- โ”œโ”€โ”€ components/
44
- โ”‚ โ”œโ”€โ”€ index.ts # ๐Ÿšช barrel โ€” re-exports every component
45
- โ”‚ โ”œโ”€โ”€ CreateUser/
46
- โ”‚ โ”‚ โ””โ”€โ”€ index.ts # export const CreateUserSchema = โ€ฆ
47
- โ”‚ โ”œโ”€โ”€ User/
48
- โ”‚ โ”‚ โ””โ”€โ”€ index.ts # export const UserSchema = โ€ฆ
49
- โ”‚ โ”œโ”€โ”€ parameters/
50
- โ”‚ โ”‚ โ”œโ”€โ”€ index.ts # ๐Ÿšช reusable-parameter barrel (only when declared)
51
- โ”‚ โ”‚ โ””โ”€โ”€ TraceId/
52
- โ”‚ โ”‚ โ””โ”€โ”€ index.ts # export const TraceIdParameter = โ€ฆ
53
- โ”‚ โ””โ”€โ”€ responses/
54
- โ”‚ โ”œโ”€โ”€ index.ts # ๐Ÿšช reusable-response barrel (only when declared)
55
- โ”‚ โ””โ”€โ”€ Problem/
56
- โ”‚ โ””โ”€โ”€ index.ts # export const ProblemResponse = โ€ฆ
57
- โ”œโ”€โ”€ health/get/index.ts
58
- โ””โ”€โ”€ users/{userId}/
59
- โ”œโ”€โ”€ get/index.ts
60
- โ””โ”€โ”€ patch/index.ts
61
- ```
62
-
63
- ### ๐Ÿ“„ Component file format
64
-
65
- ```ts
66
- /** Generated by zopia โ€” do not edit by hand. */
67
- import { z } from 'zod';
68
-
69
- export const UserSchema = (() => { /**
70
- * User
71
- * A user account
72
- */
73
- const UserSchema = z.object({ ["email"]: z.string().email(), ["id"]: z.string().uuid(), ["nickname"]: z.string().nullable().optional(), ["role"]: z.enum(["admin","viewer"]).optional() }).strict().meta({"title":"User","description":"A user account"}); return UserSchema; })();
74
-
75
- export default UserSchema;
76
- ```
77
-
78
- Component expressions are emitted through the same complete Engine โ‘ก converter
79
- used for endpoint schemas, then structural component references are replaced by
80
- imports. Constraints such as `not`, property-name checks, tuple/array bounds,
81
- `uniqueItems`, and `$ref` siblings therefore remain live runtime validation;
82
- root and nested annotations (`deprecated`, access flags, XML/external docs, and
83
- `x-โ€ฆ`) remain Zod metadata for reverse conversion.
84
-
85
- ### ๐Ÿšช The barrel
86
-
87
- `components/index.ts` re-exports every component in name order:
88
-
89
- ```ts
90
- export { CreateUserSchema } from "./CreateUser/index";
91
- export { UserSchema } from "./User/index";
92
- ```
93
-
94
- > ๐Ÿ“Œ **Rule R-802** โ€” endpoint files import components **through the barrel**
95
- > (`from '<relative>/components/index'`), never from a deep path. A component
96
- > named `index.ts` (case-insensitively) is rejected before any write because its
97
- > required directory would collide with this barrel file. The relative
98
- > prefix is computed per file and per mode:
99
- >
100
- > | mode | file depth | import |
101
- > | --- | --- | --- |
102
- > | directory ยท `users/{userId}/get/index.ts` | 3 | `import { UserSchema } from '../../../components/index';` |
103
- > | flat ยท `users-{userId}/get/index.ts` | 2 | `import { UserSchema } from '../../components/index';` |
104
-
105
- ## ๐Ÿ”— Component โ†’ component references
106
-
107
- When a component uses another component, the dependency becomes a **relative
108
- import between component files** โ€” the same `$ref` graph, now in TypeScript:
109
-
110
- ```ts
111
- // components/Post/index.ts
112
- import { AuthorSchema } from '../Author/index';
113
-
114
- export const PostSchema = z.object({
115
- title: z.string(),
116
- author: AuthorSchema, // โคต $ref #/components/schemas/Author
117
- });
118
- ```
119
-
120
- | # | Rule |
121
- | --- | --- |
122
- | R-811 | ๐Ÿงฉ Import graph mirrors structural `$ref` edges exactly (R-402); `$ref`-looking values inside `default`, `example(s)`, `enum`, `const`, and `x-โ€ฆ` data stay literal. Component files import only *component files* (never endpoint files), and valid `$ref` siblings retain their runtime constraints and annotations |
123
- | R-812 | ๐ŸŒ€ **Cycles** (e.g. `Comment.replies โ†’ Comment`) become `z.lazy(() => CommentSchema)` on the *cyclic edge only* โ€” the file still loads (R-402). Direct aliases also use a lazy wrapper so alias and target remain distinct runtime identities during reverse conversion |
124
- | R-813 | ๐Ÿ“ฆ Components imported from the barrel by endpoints never create import cycles: components never import endpoints (R-811) |
125
-
126
- ## ๐Ÿ“„ Generated endpoint files with `useComponentAsReference: true`
127
-
128
- ```ts
129
- /** Generated by zopia โ€” do not edit by hand. */
130
- import { z } from 'zod';
131
- import { makeApiConfig } from 'km-api';
132
- import { UserSchema } from '../../../components/index';
133
- import { TraceIdParameter } from '../../../components/parameters/index';
134
- import { ProblemResponse } from '../../../components/responses/index';
135
-
136
- export const getUser = makeApiConfig({
137
- method: "GET",
138
- pathShape: "/users/{userId}",
139
- operationId: "getUser",
140
-
141
- responseContentType: "application/json" as unknown as import('km-api').IResponseContentType,
142
- deprecated: 'YES',
143
- auth: "YES",
144
- summary: "Get a user",
145
- description: "Returns one user",
146
- tags: ["#users"],
147
- examples: JSON.parse("{\"response\":{\"200\":{\"default\":{\"value\":{\"email\":\"admin@example.test\",\"id\":\"22ccbc6a-436b-4b1c-9e64-7440ce63a90e\",\"role\":\"admin\"}}}}}"),
148
- request: { body: z.any(), params: z.object({ ["userId"]: z.string().uuid() }), query: z.object({ }), headers: z.object({ ["X-Trace-Id"]: TraceIdParameter.optional() }), cookies: z.object({ }) },
149
- response: { 200: UserSchema, 404: ProblemResponse },
150
- });
151
-
152
- export default getUser;
153
- // Source: "Admin API v1.2.0"
154
- ```
155
-
156
- With references on, each component export is imported once per endpoint file
157
- and reused at every matching identity (R-403). Non-component schemas remain
158
- inline.
159
-
160
- ### โ™ป๏ธ Reusable parameters and responses (v0.2.x)
161
-
162
- Declared **reusable parameters** (`#/components/parameters/โ€ฆ`, Swagger 2.0:
163
- `#/parameters/โ€ฆ`) and **reusable responses** (`#/components/responses/โ€ฆ`,
164
- Swagger 2.0: `#/responses/โ€ฆ`) also become their own component modules when
165
- component files are emitted โ€” under `components/parameters/<Name>/index.ts`
166
- and `components/responses/<Name>/index.ts`, exporting `<Name>Parameter` and
167
- `<Name>Response`. A parameter module holds the parameter's *schema* (name,
168
- location, `required`, and descriptions stay operation-level data); a response
169
- module holds its primary media-type schema. A declaration whose only key is a
170
- single-segment namespace `$ref` at a use site resolves to the module export โ€”
171
- sibling-merged `$ref`s at use sites keep their inline composition as before.
172
-
173
- ```ts
174
- // components/parameters/TraceId/index.ts (reusable parameter)
175
- /** Generated by zopia โ€” do not edit by hand. */
176
- import { z } from 'zod';
177
-
178
- export const TraceIdParameter = z.string();
179
-
180
- export default TraceIdParameter;
181
- ```
182
-
183
- Each kind gets a barrel (`components/parameters/index.ts`,
184
- `components/responses/index.ts`) **only when that kind has declarations**, so
185
- specs without reusables gain no files. Cross-kind export-name collisions are
186
- impossible (suffixes `Schema`/`Parameter`/`Response`); within a kind, name and
187
- export collisions fail with `ZOPIA_SPEC_INVALID` exactly like schema modules.
188
- A reusable response without a schema (no media type in 3.x, no `schema` in
189
- 2.0) has nothing to centralize โ€” it emits **no module** and renders
190
- `z.void()` at use sites; its metadata still round-trips verbatim through the
191
- manifest (`componentsOverlay` / Swagger globals).
192
-
193
- ## ๐Ÿ”„ Reverse conversion (engine โ‘ฃ)
194
-
195
- | # | Rule |
196
- | --- | --- |
197
- | R-821 | ๐Ÿงฑ Engine โ‘ฃ rebuilds `components.schemas` from the manifest in **every** mode: `file` set (components mode) โ†’ the component file is imported and converted with engine โ‘  (developer edits win); `file: null` (default mode) โ†’ the manifest `schema` is re-emitted verbatim (R-655/R-751) |
198
- | R-822 | ๐Ÿ”— Endpoint use-sites of imported components become `$ref: "#/components/schemas/<Name>"` again from runtime Zod identity; changing the component used in generated code changes the emitted `$ref`. RFC 6901-escaped names are decoded for imports and re-escaped in output, and direct aliases retain their `$ref` sibling keywords as Zod metadata. |
199
- | R-823 | ๐ŸŒ€ `z.lazy` cycles serialize back to self `$ref`s โ€” recursion round-trips |
200
- | R-824 | ๐Ÿ“ธ Component annotations and direct-`$ref` siblings are emitted as Zod metadata when a component file exists, so code remains authoritative; only `file: null` components use the manifest schema snapshot |
201
- | R-825 | โ™ป๏ธ Reusable parameter/response modules reverse symmetrically (v0.2.x): the manifest component entry carries `kind: "parameter"` (or `"response"`) and the declaration (`#/components/parameters/<Name>`, Swagger 2.0 `#/parameters/<Name>`, responses likewise) is refreshed from the current module schema before assembly โ€” developer edits win (R-821 semantics per kind). Use sites are restored **verbatim** from the manifest placement records, so every `$ref: "#/components/parameters/<Name>"` (and bare body/formData/response `$ref`s) reappears exactly where it was declared to be used; an import a developer adds at a *new* position simply inlines there |
202
-
203
- ## ๐Ÿšซ Phase 1 scope (documented limits)
204
-
205
- Reusable non-schema objects (`components.parameters`, `components.responses`,
206
- `components.examples`, and Swagger globals) are resolved at endpoint use sites
207
- for generated km-api code. The manifest preserves their declarations and ref
208
- placements, so engine โ‘ฃ restores reusable identity. Phase 2 is only needed to
209
- emit those objects as standalone generated files.
210
-
211
- For file-path inputs, same-folder external refs are bundled inline before
212
- this matrix applies (D-17), so an external spec and its inline equivalent land
213
- in the same rows.
214
-
215
- | ๐Ÿงฉ Thing | Current behaviour | When |
216
- | --- | --- | --- |
217
- | external refs in the spec folder (file inputs) | bundled inline before processing (D-17) | v0.2.x |
218
- | external refs outside the spec folder, or from object/text input | `ZOPIA_REF_EXTERNAL` | โ€” |
219
-
220
- ## ๐Ÿ”— Next
221
-
222
- - โš™๏ธ Options reference โ†’ [Configuration](09-configuration.md)
223
- - ๐Ÿ”„ How refs are resolved โ†’ [Architecture โ†’ The reference graph](04-architecture.md#-the-reference-graph)
@@ -1,267 +0,0 @@
1
- # ๐Ÿงช Testing
2
-
3
- > ๐ŸŽฏ **T-13** โ€” *tests in all scenarios, with Vitest, run with Bun.*
4
- > Every rule in this project (R-โ€ฆ), every target (T-โ€ฆ), and every mapping row
5
- > in [Conversions](06-conversions.md) is **pinned by at least one test**.
6
-
7
- ## ๐Ÿ› ๏ธ Toolchain
8
-
9
- | ๐Ÿงฉ Piece | ๐Ÿ“ Choice | ๐Ÿ“ Why |
10
- | --- | --- | --- |
11
- | Runner | **Vitest 4.1.11** (exact pin) | requested standard (D-02); snapshots, V8 coverage, type-aware assertions |
12
- | Runtime | **Bun** | runs the package, the tests, *and* the generated code (D-08) |
13
- | Types | `tsc --noEmit` (`strict`) | the type-level test gate |
14
- | Fixtures | plain JSON files under `tests/fixtures/` | specs are the unit of integration |
15
-
16
- ```bash
17
- bun run typecheck # โœ… strict TS
18
- bun run test # ๐Ÿงช vitest run (CI mode)
19
- bun run test:watch # ๐Ÿ‘€ vitest watch
20
- bun run coverage # ๐Ÿ“ˆ vitest --coverage
21
- bun run golden:update # ๐Ÿ“ธ regenerate golden trees deliberately (R-112)
22
- bun run package:check # ๐Ÿ“ฆ exact npm archive + isolated consumer smoke
23
- bun run release:check # ๐Ÿšข frozen install + every release check
24
- ```
25
-
26
- The Bun gate is the release-level wrapper around these individual commands. It
27
- also validates the pinned Bun version and `bun.lock`, executes the package binary,
28
- proves reverse conversion can import freshly generated TypeScript under Bun,
29
- and packs the exact npm artifact for an isolated offline install, package-root
30
- import, and generate/reverse CLI smoke test. `prepublishOnly` delegates to this
31
- same gate so local and publish-time validation cannot drift.
32
-
33
- ## ๐Ÿ“ Test pyramid
34
-
35
- | ๐Ÿ”๏ธ Layer | ๐Ÿ“ Where | ๐ŸŽฏ What it proves |
36
- | --- | --- | --- |
37
- | **Focused** | `tests/*.test.ts` | schema keywords, OpenAPI helpers, layouts, planning, manifests, warnings, and errors on in-memory values |
38
- | **Integration** | top-level `tests/*generate*.test.ts`, `tests/*public*.test.ts`, and `tests/*to-openapi*.test.ts` | full engine runs: spec in โ†’ tree out (both modes/options); trusted generated tree in โ†’ spec out |
39
- | **Round-trip** | `tests/roundtrip/**/*.test.ts` | property: `openapi(docs(spec)) โ‰ˆ spec` and `zodSchema(zod(jsonSchema(zodSchema))) โ‰ˆ schema` (see below) |
40
- | **Golden files** | `tests/fixtures/expected/**` | byte-exact generated trees (determinism, P-1), regenerated deliberately and reviewed with their fixture inputs |
41
- | **Contract** | `tests/contract/**/*.test.ts` | public API/JSDoc, scenario identifiers, quality/forbidden behavior, changelog history, golden output, npm artifact, release metadata, and documentation status |
42
-
43
- > ๐Ÿ“Œ **Rule R-111** โ€” *no test touches the network*; *no test writes outside
44
- > a per-test temp directory*. Every test-created directory goes through the
45
- > shared `useTemporaryDirectories()` helper (`fs.mkdtemp` under `os.tmpdir()`),
46
- > which removes all owned trees in `afterEach`; a hygiene contract rejects direct
47
- > temp-directory factories in test files. The same contract rejects network,
48
- > wall-clock, random, and host-locale behavior in tests.
49
-
50
- ## ๐Ÿงพ The scenario matrix
51
-
52
- The suite **must** cover every cell. A cell is a *spec axis ร— an output axis*.
53
- Each `S-โ€ฆ` identifier below appears in the executable test that covers it, and a
54
- contract test compares the complete documented identifier set with the complete
55
- Vitest source tree so a newly documented scenario cannot remain unimplemented:
56
-
57
- ### ๐Ÿ“„ Spec-input scenarios
58
-
59
- | # | Scenario | ๐Ÿ†” Rules pinned |
60
- | --- | --- | --- |
61
- | S-01 | Swagger 2.0 โ€” basic (definitions, body params, consumes/produces, securityDefinitions, host/basePath) | โ‘ข v2 table |
62
- | S-02 | Swagger 2.0 โ€” `formData` (urlencoded *and* multipart) | R-503 |
63
- | S-03 | Swagger 2.0 โ€” primitive params (`type`/`format`/`enum` inline) | v2 param normalization |
64
- | S-04 | OpenAPI 3.0 โ€” cookie params, requestBody, `nullable: true`, single `example` | โ‘ข v3 table |
65
- | S-05 | OpenAPI 3.1 โ€” `type: [t, "null"]`, `const`, numeric `exclusiveMinimum`, `prefixItems`, `examples` array | R-503 |
66
- | S-06 | Both dialects โ€” `deprecated`, tags with descriptions, multiple servers | R-641 |
67
- | S-07 | Error inputs โ€” invalid JSON, unknown version, missing `paths`, unknown/external/malformed/circular `$ref` | error model (R-404) |
68
- | S-08 | typed-error boundary matrix โ€” engines โ‘ โ€“โ‘ฃ, low-level helpers, warning validation, generated-module imports, filesystem writers, and CLI arguments all fail with a catalogued `ZopiaError`, actionable `hint`, discoverable `at`, and preserved `cause` | R-141โ€ฆR-143/R-404 |
69
-
70
- ### ๐Ÿ”— Ref-graph scenarios
71
-
72
- | # | Scenario | ๐Ÿ†” Rules pinned |
73
- | --- | --- | --- |
74
- | S-11 | simple ref (operation โ†’ component) | R-402 |
75
- | S-12 | nested refs (component โ†’ component โ†’ component) | R-811 |
76
- | S-13 | cycle (component โ†’ itself) โ†’ `z.lazy()` | R-402/R-812 |
77
- | S-14 | same component used by many operations (self-contained inlining in default mode, identity-preserving imports in ref mode) | R-403/R-822 |
78
- | S-15 | missing ref / external ref โ†’ typed error | R-404 |
79
-
80
- ### ๐Ÿ“‚ Layout scenarios
81
-
82
- | # | Scenario | ๐Ÿ†” Rules pinned |
83
- | --- | --- | --- |
84
- | S-21 | `directory` โ€” the canonical Admin API tree (golden file) | R-711โ€ฆR-714 |
85
- | S-22 | `flat` โ€” same spec, flat names (golden file) | R-721โ€ฆR-723 |
86
- | S-23 | flat name collision โ†’ `-2` suffix | R-722 |
87
- | S-24 | path with a literal segment equal to a method name (`/users/get`) | R-714 |
88
- | S-25 | deep paths (5+ segments) & params at every level | R-711 |
89
- | S-26 | `TRACE` operation โ†’ emitted as a `trace/` method dir with `method: 'TRACE'` (km-api โ‰ฅ 0.4.1) | R-712/R-642 |
90
-
91
- ### โš™๏ธ Option scenarios
92
-
93
- | # | Scenario | ๐Ÿ†” Rules pinned |
94
- | --- | --- | --- |
95
- | S-31 | defaults (`directory`, no components) โ€” self-contained files (imports only `zod`/`km-api`) | R-502, defaults |
96
- | S-32 | `insertComponents: true` โ€” `components/**` + barrel + inlined endpoints | R-801 |
97
- | S-33 | `insertComponents + useComponentAsReference` โ€” endpoint schemas import emitted components recursively; cycles stay lazy | R-802, R-821 |
98
- | S-34 | `useComponentAsReference` alone โ†’ `ZOPIA_CONFIG_INVALID` | R-911 |
99
- | S-35 | manifest written & valid in all of the above (schema test) | D-06 |
100
- | S-36 | dedicated manifest writer โ€” canonical hash/bytes, complete OpenAPI and Swagger frame metadata, portable paths, literal-aware `$ref` collection, invalid-shape rejection, atomic replacement/cleanup, temporary-symlink refusal, and reader round-trip compatibility | D-06, P-1, R-751โ€ฆR-754 |
101
- | S-37 | manifest staleness โ€” source/config drift, invalid manifests, missing owned files, canonical key-reordering equivalence, manifest disablement, obsolete-owned-file pruning, custom-file retention, and symlink-ancestor rejection | R-741โ€ฆR-743/R-406 |
102
-
103
- ### โš›๏ธ Zod / JSON Schema coverage (engines โ‘  & โ‘ก)
104
-
105
- | # | Scenario |
106
- | --- | --- |
107
- | S-41 | every format row of R-627 (email, uuid, url/uri alias, hostname, ipv4/6, date-time, date, time, duration, `byte` โ†’ `z.base64()`) + unmapped formats (password, binary, int32/64, float, double, โ€ฆ) โ†’ base type + warning + overlay |
108
- | S-42 | every numeric/string/array constraint of R-628 (min/max, int, regex, multipleOf, exclusive bounds both forms) |
109
- | S-43 | enum (string/non-string), const, nullable (both spellings), tuples (both spellings) |
110
- | S-44 | objects: required/optional, `additionalProperties` (false/schema/true), defaults, catchall |
111
- | S-45 | oneOf/anyOf/allOf, discriminator โ†’ `discriminatedUnion` (+ fallback case) |
112
- | S-46 | D-12 unsupported keywords โ†’ warning + approximation + `// @zopia:warn` comment (uniqueItems, not, if/then/else, patternProperties, propertyNames, min/maxProperties, contains) โ€” and the manifest overlay restores the original keywords verbatim (R-635, asserted in the round-trip) |
113
- | S-47 | โ‘  targets โ€” output diffs between `openapi-3.1` / `openapi-3.0` / `draft-2020-12` / `draft-07` for the same input |
114
- | S-48 | โ‘  unrepresentable (transforms, functions, NaN, `z.set`) โ†’ `{}` + warning (R-614) |
115
- | S-49 | โ‘ก cross-check: generated code's runtime schema behaves like `z.fromJSONSchema()`'s (experimental) one on the fixture set |
116
- | S-50 | โ‘ก/โ‘  determinism โ€” same input โ‡’ identical output, twice in a row |
117
- | S-51 | โ‘ /โ‘ฃ value normalizations โ€” sentinel integer bounds stripped (R-618), const-literal unions โ†’ `enum` (R-654) โ€” asserted before the round-trip comparison |
118
- | S-52 | `io: 'input'` request conversion (R-615) โ€” defaulted request fields stay out of `required`, transformed request fields convert to their *input* type; response schemas use `io: 'output'` |
119
- | S-53 | shared warning normalization โ€” stable-code validation, one-line sanitization, deduplication, deterministic order, JSON Pointer rebasing, and canonical comment/log formatting; nested โ‘ก siblings retain distinct exact source locations |
120
-
121
- ### ๐Ÿ” Reverse-conversion scenarios (engine โ‘ฃ)
122
-
123
- | # | Scenario | ๐Ÿ†” Rules pinned |
124
- | --- | --- | --- |
125
- | S-61 | reverse of S-21 (directory) โ†’ equals original spec after canonicalization | R-651โ€ฆR-658 |
126
- | S-62 | reverse of S-22 (flat) โ†’ same result as S-61 (mode-independence) | D-06 |
127
- | S-63 | reverse with `insertComponents + refs` on โ†’ `components.schemas` + `$ref`s restored | R-821โ€ฆR-823 |
128
- | S-64 | `version: '3.0'` vs `'3.1'` output diff | D-09 |
129
- | S-84 | `version: '2.0'` dialect downgrade โ€” host/basePath/schemes decomposition, `x-nullable`, body/formData parameters, global `parameters`/`responses` tables, security-scheme mapping, deterministic downgrade warnings, native-Swagger identity | D-20 |
130
- | S-86 | native `z.record` conversion for exact `propertyNames`+`additionalProperties` objects โ€” warning/overlay-free code, runtime key enforcement, verbatim engine โ‘ขโ†’โ‘ฃ round-trip; non-native propertyNames forms stay refined + frozen | D-22 |
131
- | S-87 | 3.1 webhook endpoint generation โ€” deterministic `webhooks/` files, manifest `webhooks[]`/`webhookOrder`/`webhooksOverlay` records, exact-order engine โ‘ฃ round-trip with runtime refresh, derived operationIds, local path-item `$ref` items restored verbatim unchanged (`webhookItemRef`) and expanded on edit, empty `x-` webhook items preserved through the order list, operation-less map preservation, malformed webhook items raise typed `ZOPIA_SPEC_INVALID`, 3.0/2.0 omission warnings with in-place expansion of path-item `$ref`s into omitted containers, cross-scope operationId rejection at โ‘ข plus the defense of record at โ‘ฃ | D-23 |
132
- | S-91 | spec diff โ€” key-order-insensitive identity across JSON/YAML/object inputs; endpoint add/remove operations labeled by method+path+operationId in path-primary order; shared-operation headers with scalar/parameter/request-body/response/`x-` details (add/remove/change per field); dialect, info, webhooks, components (dialect-aligned `at` pointers), document fields, and root extensions compared; typed failures for unreadable inputs; CLI glyph/stdout-silence/exit-0 contract plus grammar rejection and `--help` coverage; round 6 coverage โ€” component registries (`parameters`/`responses`/`securitySchemes`/`securityDefinitions`/`requestBodies`/`headers`/`links`/`callbacks`/`examples`/`pathItems`) with dialect-aligned pointers, path-item/webhook-item metadata via `$ref` resolution, `x-` entries inside `paths`/`webhooks`, typed `$ref`-chain failures | Phase 3 diff tool |
133
- | S-92 | split-generation presets โ€” `planPresetBuckets` pure planner (primary-tag routing with `ZOPIA_WARN_PRESET_PRIMARY_TAG` on multi-tagged operations, effective-first-server routing, untagged/default buckets, collision-safe slugs, `x-` entries copied per bucket, `$ref` whole-item items verbatim vs partial-route inline expansion, fallthrough to the normal single tree); `openApiToApiDocs` preset split (one reversible tree + manifest per bucket, `trees[]` result, warning locations prefixed per bucket); invalid presets fail `ZOPIA_CONFIG_INVALID`; CLI `--preset` value/alias-rejection, summary line, `--help` coverage; round 7 fixes โ€” op-less path/webhook items retained in every bucket (planner + public reverse round-trip), an explicit empty `servers: []` is decisive and routes to the default-server bucket, Swagger 2.0 multi-server/multi-tag defensive fallthrough | Phase 3 presets |
134
- | S-93 | spec โ†” tree navigation โ€” manifest-driven index answers both directions exactly (endpoints/webhooks/components/companion `custom.ts` files/component barrels/the manifest itself; flat+directory layouts; preset bucket roots each carry an index); unknown pointer/file/component-(emission-disabled) + missing/invalid manifests fail with typed `ZOPIA_CONFIG_INVALID`/`ZOPIA_DOCS_MISSING_MANIFEST`/`ZOPIA_MANIFEST_INVALID`; single-pass JSON pointerโ†’line scanning (exact lines incl. escaped segments + minified documents, absent pointers stay absent, malformed JSON โ†’ `ZOPIA_SPEC_INVALID_JSON`); `specPointerAtLine` cursor rule deterministic; CLI `zopia navigate` stable lines both directions, flag grammar (mutual exclusion, value requirements, repeat rejection), `--help`/unknown-command hint coverage | Phase 3 VS Code navigation |
135
- | S-90 | incremental regeneration (D-24) โ€” byte-identical regen leaves endpoint/manifest mtimes untouched; changed sources refresh them; one scaffolded `custom.ts` per endpoint+webhook in both layouts with the `export * as custom` line; hand edits and symlinks at the path survive regeneration; default off, toggle off drops the export line but keeps the file; staleness message covers the `custom` toggle; non-boolean options rejected at both layers; CLI `--custom` + `--help` coverage; a directory shadowing the companion path is a typed `ZOPIA_FS_OUTSIDE_OUTDIR`, and `custom.ts`-shaped path segments (planner-renamed) still receive a real scaffold file in both layouts |
136
- | S-89 | `zopia validate` โ€” clean spec/docs trees report `ok`, broken `$ref`s error at the offending pointer, cross-namespace duplicate operationIds error, unreachable 3.1 components (including orphan chains) and Swagger 2.0 definitions warn, webhook-only references stay reachable, same-folder external refs bundle before linting, generated trees run a reverse dry-run, missing/tampered manifests report typed errors, km-api outside the peer range or unresolvable warns without failing, CLI grammar/help/stdout-stderr/exit-status contracts | Phase 3 validate |
137
- | S-88 | `zopia generate --watch` โ€” immediate initial run, coalesced spec-change regeneration with in-place stale-tree refresh (one `ZOPIA_WARN_STALE_TREE`), error-recovery across broken edits (previous tree untouched, watching continues), atomic-save survival (write-temp + rename, parent-directory watch), forward warnings surfaced each run, duplicate-flag rejection, `--help` coverage | watch mode |
138
- | S-65 | missing manifest / renamed file / broken export โ†’ typed errors | R-651/R-652 |
139
- | S-66 | metadata restoration โ€” titles, examples, servers, tag descriptions, security schemes, multi-content types come back verbatim | R-656/R-657 + honest-limits table |
140
- | S-67 | idempotence โ€” `reverse(generate(spec))` then `generate(โ€ฆ)` โ‡’ identical tree, including source-order-sensitive method/path collisions and empty Path Items (T-11) | R-409 |
141
- | S-68 | non-standard status (`419`) + `default` response โ†’ emitted as numeric/`default` response keys, round-trips exactly (km-api โ‰ฅ 0.4.1) | R-642 |
142
- | S-69 | exotic media type (`application/vnd.custom+json`) โ†’ emitted verbatim as the content type, used as the `content` key on reverse (km-api โ‰ฅ 0.4.1) | R-642 |
143
- | S-70 | parameter extras (`allowEmptyValue`, `style`, `explode`) + response `headers` โ†’ overlay/`responseOverlay`, restored verbatim on reverse | R-635/R-754 |
144
- | S-71 | multiple security schemes + per-operation requirements with scopes (oauth2) + an explicit `security: []` operation โ†’ `defaultSecurity` / `apis[].security` manifest fields, round-trips exactly (km-api stores only the `auth` boolean) | R-653/R-656 |
145
- | S-72 | reverse warnings โ€” runtime Zod losses, fallback info/security, and 3.1โ†’3.0 omissions return/callback with exact output pointers; security fallback coverage includes multiple operations, definition-name collisions, manifest-authoritative explicit/global requirements, and OpenAPI/Swagger representations | R-408/R-654/R-656โ€ฆR-658 |
146
- | S-73 | CLI warning channels โ€” generate/reverse diagnostics go to stderr while reverse stdout remains parseable JSON | R-408/R-933 |
147
- | S-74 | CLI contract โ€” every flag maps to its API option; options may surround positionals; missing/extra arguments, unknown/cross-command/duplicate/valueless flags fail before engine work; help includes the trusted-tree warning; exit statuses distinguish typed and unexpected failures | R-931โ€ฆR-934 |
148
- | S-75 | JSDoc AST audit โ€” every directly or named-only exported declaration and exposed public/nested-shape member has a useful summary; all callable forms require specific parameters/returns, optional configuration defaults are stated, TypeScript examples semantically typecheck against the source API, and relative `@see` links resolve | T-12/R-131โ€ฆR-135/R-1003 |
149
- | S-76 | Package/release contract โ€” version and public metadata stay synchronized; Vitest/coverage versions and Bun scripts stay pinned; the npm archive is allowlisted and executable; `prepublishOnly` runs the complete release gate; the exact tarball installs offline and passes package-root import plus generate/reverse CLI smoke tests | R-191โ€ฆR-193 |
150
- | S-77 | YAML input โ€” `.yaml`/`.yml` paths, inline YAML text, extension-less YAML fallback, and JSON-inside-YAML flow text enter engine โ‘ข; byte-identical trees vs JSON twins, reverse round-trips, CLI parity, stable YAML-side error codes | D-16/R-404 |
151
- | S-78 | YAML parser (D-16) โ€” core-schema scalars, nested block/flow collections, quoted escapes, literal/folded block scalars with chomping/indent indicators, anchors/aliases/`<<` merge keys, directives/markers, deterministic failure matrix, recursion guard | D-16/R-1006 |
152
- | S-79 | external `$ref` bundling (D-17) โ€” same-folder YAML/JSON chains resolve inline with clone-on-splice, sibling-key merges, self-file refs, literal/example shielding, and read-once caching; API/CLI generated trees are byte-identical to inline twins, reverse emits the bundled single file, and manifest staleness reacts to sibling-file edits | D-17/P-1 |
153
- | S-80 | external `$ref` failure matrix (D-17) โ€” URL/`../`/absolute/subdirectory/drive/unknown-extension targets keep `ZOPIA_REF_EXTERNAL`; unreadable, unparsable (JSON/YAML), missing-pointer, bad-fragment, circular, >512-deep, and sibling-on-scalar targets fail with typed codes located at the referencing pointer | D-17/R-404 |
154
- | S-81 | reusable parameters & responses generation (D-18) โ€” declarations become `components/parameters/<Name>/index.ts` / `components/responses/<Name>/index.ts` modules with `<Name>Parameter` / `<Name>Response` exports and kind barrels; bare-`$ref` use sites import through the barrel while sibling-merged `$ref`s inline; cross-schema/ref-chain derivations, body/formData slots, manifest `kind` entries, schema-less response exclusion, and invalid-container/collision errors | D-18/P-1 |
155
- | S-82 | reusable parameters & responses round-trip (D-18) โ€” both fixtures (`reusables-3.1.json`, `reusables-2.0.json`) reproduce byte-exactly after canonicalization; edited modules refresh their declarations on reverse (Swagger 2.0 constraints and 3.x content forms) while every use-site `$ref` restores verbatim from manifest placements and declaration chains stay chains | D-18/R-716 |
156
- | S-83 | `zopia.config.ts` project defaults (D-19) โ€” working-directory discovery + explicit `--config` paths, default/named `config` exports, structural validation with key-located `ZOPIA_CONFIG_INVALID`, precedence CLI > config > defaults (`--no-manifest` always wins), optional `<output-dir>` from `generate.outDir`, and reverse `version`/`out` defaults | D-19/R-940 |
157
-
158
- ## ๐Ÿ”„ Round-trip property tests
159
-
160
- ```ts
161
- // ๐Ÿงช tests/roundtrip/property.test.ts (implemented)
162
- for (const fixture of fixtures) {
163
- it(`round-trips ${fixture}`, async () => {
164
- const outDir = await temporaryDirectory(); // shared R-111 cleanup helper
165
- await openApiToApiDocs(await loadFixture(fixture), { outDir });
166
- // Omitting `version` preserves the source dialect, including Swagger 2.0
167
- // and an exact OpenAPI patch version such as 3.0.3.
168
- const back = await manifestFileToOpenApi(path.join(outDir, '.zopia-manifest.json'));
169
- expect(canonicalize(back)).toEqual(canonicalize(await loadFixture(fixture)));
170
- const regenerated = await temporaryDirectory();
171
- await openApiToApiDocs(back, { outDir: regenerated });
172
- expect(await treeSnapshot(regenerated)).toEqual(await treeSnapshot(outDir));
173
- });
174
- }
175
- ```
176
-
177
- `canonicalize()` = R-401 key ordering + deep-equal on JSON (whitespace
178
- independent). Value normalizations are **not** part of canonicalization โ€”
179
- engine โ‘ฃ's serializer applies them (R-654) *before* comparison, so a mismatch
180
- is a real engine bug. Every dialect fixture (S-01โ€ฆS-06) round-trips against
181
- **its own original**; the canonical Admin API additionally asserts an
182
- **empty `overlay` on every API** โ€” a spec-clean spec must round-trip without a
183
- single frozen subtree or keyword restoration.
184
-
185
- The implemented matrix also runs flat mode, emitted-but-inlined components,
186
- emitted component references, nested and cyclic refs, and frozen overlays. For
187
- every fixture/layout/component case it asserts that reverse output reproduces
188
- the source and regenerates a byte-identical tree; separate properties cover
189
- same-input regeneration and collision-sensitive path plans. It checks source-preserving
190
- Swagger โ†’ OpenAPI selection separately, covers explicit empty schema containers,
191
- empty Path Items, boolean/tuple/local-definition schemas, schema-less media and
192
- absent optional flags, and verifies that supported Zod โ†’ JSON Schema โ†’ Zod
193
- pipelines (including `z.never()`) converge on the same canonical schema. Every temporary tree is removed after its test (R-111).
194
-
195
- ## ๐Ÿงฐ Fixtures
196
-
197
- ```text
198
- tests/fixtures/
199
- โ”œโ”€โ”€ specs/
200
- โ”‚ โ”œโ”€โ”€ admin-api-3.0.json # โญ the canonical Admin API (docs/07)
201
- โ”‚ โ”œโ”€โ”€ admin-api-3.0.yaml # ๐Ÿ“ YAML twin โ€” parses/generates byte-identically (S-77)
202
- โ”‚ โ”œโ”€โ”€ admin-api-2.0.json # same API as Swagger 2.0
203
- โ”‚ โ”œโ”€โ”€ admin-api-2.0.yaml # ๐Ÿ“ YAML twin (S-77)
204
- โ”‚ โ”œโ”€โ”€ petstore-mini-3.1.json # 3.1 keywords (const, prefixItems, โ€ฆ)
205
- โ”‚ โ”œโ”€โ”€ cycle-comment.json # ๐ŸŒ€ self-referential component
206
- โ”‚ โ”œโ”€โ”€ nested-refs.json # ๐Ÿงฉ component โ†’ component โ†’ component
207
- โ”‚ โ”œโ”€โ”€ formdata-2.0.json # ๐Ÿงพ formData multipart + urlencoded
208
- โ”‚ โ”œโ”€โ”€ cookies-3.0.json # ๐Ÿช cookie parameters
209
- โ”‚ โ”œโ”€โ”€ unsupported-keywords.json # ๐Ÿšซ D-12 matrix in one spec
210
- โ”‚ โ”œโ”€โ”€ path-item-ref-3.1.json # ๐Ÿ”— local path-item reference identity
211
- โ”‚ โ”œโ”€โ”€ km-api-contract-3.1.json # ๐Ÿ“ trace/custom/default/extension type surface
212
- โ”‚ โ”œโ”€โ”€ external-refs/ # ๐Ÿ”— spec folder with sibling YAML/JSON shards (D-17/S-79)
213
- โ”‚ โ”‚ โ”œโ”€โ”€ admin-3.0.yaml # root โ€” cross-file refs, sibling merges, self-file refs
214
- โ”‚ โ”‚ โ”œโ”€โ”€ shared-schemas.yaml # schemas with local + cross-file refs of their own
215
- โ”‚ โ”‚ โ”œโ”€โ”€ shared-responses.yaml # reusable response target
216
- โ”‚ โ”‚ โ””โ”€โ”€ common.json # JSON leaf with its own local refs
217
- โ”‚ โ””โ”€โ”€ external-refs-inline/ # ๐Ÿ”— fully-inline twin โ€” byte-identical generated tree (S-79)
218
- โ”‚ โ””โ”€โ”€ admin-3.0-inline.json
219
- โ””โ”€โ”€ expected/
220
- โ”œโ”€โ”€ admin-api-3.0.directory/ # ๐Ÿ“ธ golden tree (defaults)
221
- โ”œโ”€โ”€ admin-api-3.0.flat/ # ๐Ÿ“ธ golden tree (flat)
222
- โ”œโ”€โ”€ admin-api-3.0.components/ # ๐Ÿ“ธ golden tree (components + refs)
223
- โ”œโ”€โ”€ km-api-0.4.1.contract/ # ๐Ÿ“ generated open-value type contract
224
- โ””โ”€โ”€ tsconfig.json # ๐Ÿ”’ dedicated strict no-emit gate
225
- ```
226
-
227
- > ๐Ÿ“Œ **Rule R-112** โ€” golden trees are checked in and reviewed like code.
228
- > Changing one requires a deliberate `bun run golden:update` run and a PR
229
- > showing the diff โ€” determinism regressions are visible in review.
230
- >
231
- > The implemented updater removes and recreates only the governed output trees
232
- > from their checked-in JSON fixtures. The contract suite generates each variant
233
- > into a cleaned `os.tmpdir()` directory, compares the complete relative-path โ†’
234
- > UTF-8-byte map (so extra files fail too), and verifies that `expected/` itself
235
- > contains no ungoverned tree.
236
-
237
- ## ๐Ÿ“ˆ Coverage gates
238
-
239
- | ๐Ÿ“ Gate | ๐ŸŽฏ Threshold |
240
- | --- | --- |
241
- | Lines / functions | โ‰ฅ **90%** overall |
242
- | Branches | โ‰ฅ **85%** overall |
243
- | `src/conversions/**` | โ‰ฅ **95%** lines โ€” these files implement engines โ‘ โ€“โ‘ฃ and their mapping tables |
244
- | Any single `src/**/*.ts` file | never below **80%** lines |
245
-
246
- `bun run coverage` runs Vitest's V8 provider over **all** `src/**/*.ts` files,
247
- including files that no test imported. Vitest enforces the overall thresholds;
248
- `scripts/check-coverage.ts` then inventories source files against the JSON
249
- summary, enforces the aggregate conversion-engine and per-file line gates, and
250
- exits non-zero on any omission or shortfall. Warnings paths (D-12) are tested โ€”
251
- a warning that never fires in tests is a red flag, not a shrug.
252
-
253
- ## ๐Ÿ“ Writing tests (standard)
254
-
255
- | # | Rule |
256
- | --- | --- |
257
- | R-121 | **AAA** โ€” Arrange / Act / Assert sections, one behaviour per `it()` |
258
- | R-122 | **Name = spec** โ€” test names cite the rule they pin: `it('R-627: format email โ†’ z.email()', โ€ฆ)` |
259
- | R-123 | **Errors assert on `code`** (R-404), never on message text |
260
- | R-124 | **New rule โ‡’ new test** โ€” adding an R-โ€ฆ row to any doc requires the matching test in the same PR |
261
- | R-125 | **No skipped tests in main** โ€” `it.skip` is allowed only with a linked issue and a removal date |
262
- | R-126 | **Golden trees typecheck** โ€” the contract suite runs dedicated strict, no-emit `tsc` over every golden `api_docs` tree against installed published `km-api@0.4.1` (D-15), without skipping declaration checks. The contract fixture pins `trace`, custom and `default` statuses, and an arbitrary extension media type. `makeApiConfig` remains the actual call-site gate rather than a source-text substitute (D-14/R-642) |
263
-
264
- ## ๐Ÿ”— Next
265
-
266
- - ๐Ÿ“ The rules being tested โ†’ [Standards](12-standards.md)
267
- - ๐Ÿ”„ The engines' exact contracts โ†’ [Conversions](06-conversions.md)