zopia 0.3.0 โ†’ 0.4.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.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-29
11
+
12
+ ### ๐Ÿ”„ Changed
13
+ - ๐Ÿ“ฆ **Slimmer npm package โ€” practical docs only.** The published archive now
14
+ ships just the user-facing guides (`docs/07-api-docs.md`,
15
+ `docs/09-configuration.md`, `docs/10-usage.md`) next to `README.md` /
16
+ `CHANGELOG.md`; the development documentation (overview, targets, roadmap,
17
+ architecture, concepts, conversions, components, testing, standards, the
18
+ docs map, and the publish-workflow example) stays in the GitHub repository
19
+ and is linked from the README โ€” npm users installing the package get
20
+ usage/installation docs, not project management artifacts. The tarball
21
+ shrinks **239 KB โ†’ 182 KB packed (915 KB โ†’ 750 KB unpacked, 50 โ†’ 39
22
+ files)**; `npm` force-includes `README*` from any directory, so the docs map
23
+ is explicitly negated (`!docs/README.md`) in `files`. `package:check` and
24
+ the release contract now enforce the slim archive: a missing practical doc
25
+ fails, and any development doc leaking into the pack fails. The release
26
+ standard (R-192) and the docs map describe the split.
27
+
10
28
  ### โœจ Added
29
+ - ๐ŸŒณ **Runtime tree consumption โ€” `zopia/runtime` (S-94).** New opt-in subpath
30
+ export (`import { createApiDocs, flattenApiDocs } from 'zopia/runtime'`) that
31
+ turns a generated api-docs directory into the objects an application
32
+ consumes, with **zero changes to generation output**. `createApiDocs(dir)` โ€”
33
+ the entire consumer DX โ€” discovers the root `.zopia-manifest.json` plus every
34
+ one-level-deep preset bucket manifest (`multi-tag`/`multi-server`), merges
35
+ them (deduplicating by `${path}#${method}`, root manifest first), sorts
36
+ deterministically (path segments, then the canonical method order), and
37
+ returns one nested object keyed by the exact URL path segments with the
38
+ lowercase method as leaf key holding the endpoint module's `export default`
39
+ (loaded via `pathToFileURL`, Windows-safe). `flattenApiDocs(tree)` is the
40
+ flat freebie: a `Record<string, config>` keyed by `operationId`, deriving
41
+ missing names and collision suffixes through the **same shared rules the
42
+ generator uses for its export identifiers** (extracted to
43
+ `src/conversions/api-docs-names.ts`: camelize, reserved-word guard,
44
+ leading-numeric guard, `2`/`3`โ€ฆ suffixing โ€” `await` โ†’ `awaitEndpoint`).
45
+ Missing/garbled manifests, unsafe manifest paths, import failures, and
46
+ modules without default exports fail typed (`ZOPIA_DOCS_MISSING_MANIFEST`,
47
+ `ZOPIA_MANIFEST_INVALID`, `ZOPIA_DOCS_IMPORT_FAILED`); paths that cannot
48
+ share one nested tree (below a method leaf, trailing-slash twins) fail typed
49
+ `ZOPIA_SPEC_INVALID`. Documented in
50
+ [docs/07-api-docs.md โ†’ Runtime tree consumption](docs/07-api-docs.md).
11
51
  - ๐Ÿ“ฆ **npm publish pipeline** โ€” `.github/workflows/publish.yml` publishes on GitHub Release creation (or manually) with the pinned Bun toolchain, verifies the release tag matches `package.json`'s version, and runs `npm publish --provenance --access public`. Its only credential is the repository-secret `NODE_AUTH_TOKEN` (npm `NPM_TOKEN`) โ€” never handled in chat or commits; npm trusted-publishing (OIDC) is supported by the declared `id-token` permission.
12
52
 
13
53
  ### ๐Ÿ› Fixed
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![Runtime](https://img.shields.io/badge/Runtime-Bun%201.x-black.svg)](https://bun.sh/)
14
14
  [![Tests](https://img.shields.io/badge/Tests-vitest-10b981.svg)](https://vitest.dev/)
15
15
 
16
- โœ… **Status โ€” Phase 3 complete ยท v0.3.0 released**
16
+ โœ… **Status โ€” Phase 3 complete ยท v0.4.0 released**
17
17
 
18
18
  </div>
19
19
 
@@ -91,25 +91,57 @@ await openApiToApiDocs('swagger.json', {
91
91
  const { openapi } = await apiDocsToOpenApi('api_docs', { version: '3.1' });
92
92
  ```
93
93
 
94
+ ### ๐ŸŒณ Consuming the tree at runtime
95
+
96
+ When wiring endpoints dynamically beats importing generated files one by one,
97
+ import the opt-in runtime subpath and point it at any directory zopia ever
98
+ generated into (`directory`, `flat`, and `multi-tag` / `multi-server` splits
99
+ all resolve through their manifests):
100
+
101
+ ```ts
102
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
103
+
104
+ // Nested: URL path segments โ†’ lowercase method โ†’ the makeApiConfig object
105
+ const apiDocs = await createApiDocs('api_docs');
106
+ const getUser = apiDocs.users['{userId}'].get; // default export of that index.ts
107
+
108
+ // Flat freebie: keyed by operationId, same leaf objects, deterministic order
109
+ const endpoints = flattenApiDocs(apiDocs);
110
+ endpoints.getUser === getUser; // โ†’ true
111
+ ```
112
+
113
+ Generation output is untouched โ€” the resolver only reads manifests and imports
114
+ modules. See [docs/07-api-docs.md โ†’ Runtime tree consumption](docs/07-api-docs.md#-runtime-tree-consumption--createapidocs).
115
+
94
116
  ## ๐Ÿ“š Documentation
95
117
 
96
- Everything about the project โ€” targets, architecture, conversion rules,
97
- output format, configuration, testing, standards โ€” lives in [`docs/`](docs/).
118
+ The **practical guides ship inside the npm package** โ€” usage, configuration,
119
+ and the generated output format. The development documentation (project
120
+ targets, roadmap, architecture, testing strategy, engineering standards)
121
+ stays in the
122
+ [GitHub repository](https://github.com/komeilm76/zopia/tree/main/docs).
123
+
124
+ **๐Ÿ“ฆ Packed with the npm package** (relative links, in `docs/`):
98
125
 
99
126
  | ๐Ÿ“„ Document | Contents |
100
127
  | --- | --- |
101
- | ๐Ÿงญ [Overview](docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
102
- | ๐ŸŽฏ [Targets](docs/02-targets.md) | The explicit, testable targets of this project |
103
- | ๐Ÿ—บ๏ธ [Roadmap](docs/03-roadmap.md) | Phases, milestones, definition of done |
104
- | ๐Ÿ—๏ธ [Architecture](docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
105
- | ๐Ÿงฉ [Concepts](docs/05-concepts.md) | Glossary โ€” Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
106
- | ๐Ÿ”„ [Conversions](docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
107
- | ๐Ÿ“„ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest |
108
- | ๐Ÿงฑ [Components](docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
109
- | โš™๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
110
128
  | ๐Ÿš€ [Usage](docs/10-usage.md) | Installation, programmatic API, CLI, end-to-end example |
111
- | ๐Ÿงช [Testing](docs/11-testing.md) | Vitest strategy, scenario matrix, fixtures, coverage gates |
112
- | ๐Ÿ“ [Standards](docs/12-standards.md) | Code, JSDoc, commits, changelog, releases, key decisions |
129
+ | โš™๏ธ [Configuration](docs/09-configuration.md) | Full option reference, defaults, validation rules |
130
+ | ๐Ÿ“„ [API docs format](docs/07-api-docs.md) | `directory` & `flat` layouts, `index.ts` contract, manifest, runtime consumption |
131
+
132
+ **๐Ÿ™ GitHub-only** (development docs, not packed):
133
+
134
+ | ๐Ÿ“„ Document | Contents |
135
+ | --- | --- |
136
+ | ๐Ÿงญ [Overview](https://github.com/komeilm76/zopia/blob/main/docs/01-overview.md) | What zopia is, the problem it solves, principles, non-goals |
137
+ | ๐ŸŽฏ [Targets](https://github.com/komeilm76/zopia/blob/main/docs/02-targets.md) | The explicit, testable targets of this project |
138
+ | ๐Ÿ—บ๏ธ [Roadmap](https://github.com/komeilm76/zopia/blob/main/docs/03-roadmap.md) | Phases, milestones, definition of done |
139
+ | ๐Ÿ—๏ธ [Architecture](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md) | Modules, pipeline, internal model, error model, safety |
140
+ | ๐Ÿงฉ [Concepts](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md) | Glossary โ€” Swagger 2.0, OpenAPI 3.x, JSON Schema, `$ref`, Zod v4, km-api |
141
+ | ๐Ÿ”„ [Conversions](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md) | The four engines: algorithms, mapping tables, edge cases |
142
+ | ๐Ÿงฑ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md) | `insertComponents` / `useComponentAsReference`, `$ref` graphs |
143
+ | ๐Ÿงช [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md) | Vitest strategy, scenario matrix, fixtures, coverage gates |
144
+ | ๐Ÿ“ [Standards](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) | Code, JSDoc, commits, changelog, releases, key decisions |
113
145
 
114
146
  ## ๐Ÿ—๏ธ Project structure
115
147
 
@@ -139,7 +171,7 @@ zopia/
139
171
 
140
172
  ## ๐Ÿงช Development
141
173
 
142
- zopia is a **Bun-first** project (see [docs/12-standards.md](docs/12-standards.md)):
174
+ zopia is a **Bun-first** project (see [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md)):
143
175
 
144
176
  ```bash
145
177
  bun install --frozen-lockfile # ๐Ÿ“ฆ reproducible install from bun.lock
@@ -157,7 +189,7 @@ bun run release:check # ๐Ÿšข complete pinned-Bun prepublish gate
157
189
 
158
190
  ## ๐Ÿค Contributing
159
191
 
160
- Read [docs/12-standards.md](docs/12-standards.md) first โ€” it defines code style,
192
+ Read [docs/12-standards.md](https://github.com/komeilm76/zopia/blob/main/docs/12-standards.md) first โ€” it defines code style,
161
193
  JSDoc rules, the commit convention, and the changelog rule. Every feature ships
162
194
  **with** its documentation in the same commit.
163
195
 
@@ -106,6 +106,81 @@ templates, duplicate parameters, unsupported methods, and unsafe roots. Engine
106
106
  does not emit a facade module in v0.1.0: direct imports are the generated
107
107
  file-level API, and the manifest remains authoritative (D-06).
108
108
 
109
+ ## ๐ŸŒณ Runtime tree consumption โ€” `createApiDocs`
110
+
111
+ > ๐ŸŽฏ **S-94** โ€” *an opt-in runtime API, shipped in the `zopia` package itself,
112
+ > converts a generated tree directory into the nested / flat objects an
113
+ > application consumes. No generation output changes: the feature only reads
114
+ > and imports.*
115
+
116
+ ```ts
117
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
118
+
119
+ const apiDocs = await createApiDocs('api_docs'); // โ† the entire consumer DX
120
+ const endpoint = apiDocs.applicant['{applicantId}'].exame['{examId}'].get;
121
+ // โ†’ the endpoint module's makeApiConfig object (its default export)
122
+
123
+ const endpoints = flattenApiDocs(apiDocs); // the flat freebie
124
+ endpoints.getExam; // same leaf object
125
+ ```
126
+
127
+ **Manifest discovery & merge (B-conditions).** The resolver reads the root
128
+ `.zopia-manifest.json` **plus** every one-level-deep preset bucket manifest
129
+ (`multi-tag` / `multi-server` splits write one manifest per bucket directory),
130
+ merging all of them and deduplicating by `${path}#${method}` โ€” the root
131
+ manifest wins duplicates, buckets follow in sorted directory order. Any
132
+ directory that was ever a zopia output root is accepted, including a single
133
+ preset bucket root. Entries are then sorted deterministically: URL path
134
+ segments first, then the canonical method order
135
+ (`get, post, put, delete, head, options, patch, trace`).
136
+
137
+ **Nesting.** Branch keys are the URL path segments **exactly and in order**
138
+ (including literal `{param}` segments), the leaf key is the lowercase method,
139
+ and the leaf value is the endpoint module's **default export**, loaded with
140
+ dynamic `import()` through `pathToFileURL` (Windows-safe). Because which keys
141
+ exist depends on the source spec, the `ApiDocsTree` type types every node
142
+ permissively (branch โˆช config). A tree `/` path nests its methods at the root
143
+ (`apiDocs.get`). Paths that cannot coexist in one nested tree โ€” a path
144
+ continuing below another path's method leaf, or two paths differing only by a
145
+ trailing slash โ€” fail with a typed `ZOPIA_SPEC_INVALID` instead of silently
146
+ dropping an endpoint. Key insertion follows the deterministic sort, so the
147
+ same tree always enumerates keys in the same order (P-1).
148
+
149
+ **Errors** reuse the manifest conventions: a missing manifest โ†’
150
+ `ZOPIA_DOCS_MISSING_MANIFEST`; garbled JSON or unusable `apis[]` records โ†’
151
+ `ZOPIA_MANIFEST_INVALID`; a module that cannot be imported or has no default
152
+ export โ†’ `ZOPIA_DOCS_IMPORT_FAILED` naming the file. Reading is pure โ€” nothing
153
+ on disk is written.
154
+
155
+ **The flatten freebie.** `flattenApiDocs(apiDocs)` deep-walks the tree in its
156
+ deterministic leaf order and returns a flat `Record<string, config>` keyed by
157
+ each config's `operationId`. When a leaf has no usable `operationId`, or two
158
+ keys collide, the name is derived by the **same rules the generator uses for
159
+ its `export const` identifiers** (R-732: camelize, reserved-word guard,
160
+ leading-numeric guard, `2`/`3`โ€ฆ uniqueness suffix โ€” `await` โ†’ `awaitEndpoint`,
161
+ `get-a` + `getA` โ†’ `getA` + `getA2`), so the flat keys always match the
162
+ modules' named exports. The shared rules live in
163
+ `src/conversions/api-docs-names.ts` and both call-sites (generation and
164
+ runtime) use them.
165
+
166
+ **A small km-api hand-off** โ€” every leaf *is* a `makeApiConfig()` object, so
167
+ the km-api surface is available directly:
168
+
169
+ ```ts
170
+ import { createApiDocs } from 'zopia/runtime';
171
+
172
+ const apiDocs = await createApiDocs('api_docs');
173
+ const getUser = apiDocs.users['{userId}'].get;
174
+
175
+ getUser.method; // "GET"
176
+ getUser.pathShape; // "/users/{userId}"
177
+ getUser.request.params.parse({ userId: '22ccbc6a-โ€ฆ' }); // โœ… Zod-validated path params
178
+ getUser.response[200].parse({ id: 'u1', email: 'a@b.c' }); // โœ… Zod-validated response
179
+ ```
180
+
181
+ > ๐Ÿ’ก Generated webhook modules are not URL-path endpoints and stay outside the
182
+ > nested tree โ€” import them directly or reverse-convert the tree (engine โ‘ฃ).
183
+
109
184
  ## ๐Ÿงฌ Operation contract extraction
110
185
 
111
186
  Before rendering an endpoint, zopia normalizes each operation into an
@@ -177,7 +252,7 @@ With the default options (`insertComponents: false`), every `index.ts` imports
177
252
  **only** `zod` and `km-api` (R-502): each component use is inlined into its
178
253
  request/parameter/response expression (R-403). Cross-file imports appear
179
254
  **only** when `useComponentAsReference` is `true` โ€” see
180
- [Components](08-components.md).
255
+ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md).
181
256
 
182
257
  ## ๐Ÿ“ฆ The manifest โ€” `.zopia-manifest.json`
183
258
 
@@ -333,5 +408,5 @@ Security requirements remain manifest-owned because km-api stores only `auth: 'Y
333
408
 
334
409
  ## ๐Ÿ”— Next
335
410
 
336
- - ๐Ÿงฑ What changes when components are emitted โ†’ [Components](08-components.md)
411
+ - ๐Ÿงฑ What changes when components are emitted โ†’ [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
337
412
  - โš™๏ธ Every option that shapes this output โ†’ [Configuration](09-configuration.md)
@@ -107,7 +107,7 @@ interface ZopiaReverseOptions {
107
107
 
108
108
  ## ๐Ÿ“„ `ZodToJsonSchemaOptions` โ€” engine โ‘ 
109
109
 
110
- See [Conversions โ†’ Engine โ‘ ](06-conversions.md)
110
+ See [Conversions โ†’ Engine โ‘ ](https://github.com/komeilm76/zopia/blob/main/docs/06-conversions.md)
111
111
  (`target`, `$schema`, `io`).
112
112
 
113
113
  ## ๐Ÿ“„ `JsonSchemaToZodOptions` โ€” engine โ‘ก
@@ -164,4 +164,4 @@ zopia diff <old-spec> <new-spec> (no options yet; `--config path` accep
164
164
  ## ๐Ÿ”— Next
165
165
 
166
166
  - ๐Ÿš€ How options are used end-to-end โ†’ [Usage](10-usage.md)
167
- - ๐Ÿ“‚ What each option changes in the tree โ†’ [API docs format](07-api-docs.md) ยท [Components](08-components.md)
167
+ - ๐Ÿ“‚ What each option changes in the tree โ†’ [API docs format](07-api-docs.md) ยท [Components](https://github.com/komeilm76/zopia/blob/main/docs/08-components.md)
package/docs/10-usage.md CHANGED
@@ -178,8 +178,31 @@ stderr, hint included) ยท `2` internal error (should never happen โ€” report it)
178
178
  | R-101 | ๐Ÿ“‚ **Import, don't re-type** โ€” your app imports the generated `index.ts` files; their Zod schemas *are* the validation | โ€” |
179
179
  | R-102 | ๐Ÿ”„ **Spec or generation options changed?** re-run `zopia generate` โ€” output is idempotent (P-1); manifest staleness warns on source/config/incomplete-tree drift and safely prunes only obsolete manifest-owned files (`ZOPIA_WARN_STALE_TREE`) | [07 โ†’ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
180
180
  | R-103 | โœ๏ธ **Hand edits** โ€” Phase 1 overwrites them on regeneration (see the file banner); the merge-safe custom layer arrives in Phase 3 | [07 โ†’ Regeneration](07-api-docs.md#-regeneration--manual-edits-phase-1-policy) |
181
- | R-104 | ๐Ÿงช **km-api helpers** โ€” `makeFullPath`, `makeParams`, `convertResponseType`, โ€ฆ are available on every generated config for free | [Concepts โ†’ km-api](05-concepts.md#-km-api) |
181
+ | R-104 | ๐Ÿงช **km-api helpers** โ€” `makeFullPath`, `makeParams`, `convertResponseType`, โ€ฆ are available on every generated config for free | [Concepts โ†’ km-api](https://github.com/komeilm76/zopia/blob/main/docs/05-concepts.md#-km-api) |
182
182
  | R-105 | ๐Ÿšซ **No zopia import in app code** โ€” generated files depend only on `zod` + `km-api` (R-502) | โ€” |
183
+ | R-106 | ๐ŸŒณ **Runtime tree loading** โ€” when wiring endpoints dynamically beats importing files one by one, `createApiDocs()` from the opt-in `zopia/runtime` subpath turns the whole directory into one nested object (plus the `flattenApiDocs` flat record); generation output is untouched | [07 โ†’ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs) |
184
+
185
+ ### ๐ŸŒณ Consuming the tree at runtime
186
+
187
+ Import the dedicated subpath (the package root stays free of
188
+ filesystem-importing APIs) and point it at any directory zopia ever generated
189
+ into โ€” `directory`, `flat`, and split `multi-tag` / `multi-server` trees all
190
+ resolve through their manifests:
191
+
192
+ ```ts
193
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
194
+
195
+ // Nested: exact URL path segments, lowercase method leaf, default-export config
196
+ const apiDocs = await createApiDocs('api_docs');
197
+ const getUser = apiDocs.users['{userId}'].get; // the makeApiConfig object
198
+
199
+ // Flat: keyed by operationId (derived with the generator's own naming rules)
200
+ const endpoints = flattenApiDocs(apiDocs);
201
+ endpoints.getUser === getUser; // โ†’ true
202
+ ```
203
+
204
+ Failures are typed `ZopiaError`s with `at`/`hint` โ€” see
205
+ [API docs โ†’ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs).
183
206
 
184
207
  ## ๐Ÿงฏ Error handling
185
208
 
@@ -200,9 +223,9 @@ try {
200
223
  ```
201
224
 
202
225
  The full error-code table lives in
203
- [Architecture โ†’ Error model](04-architecture.md#-error-model).
226
+ [Architecture โ†’ Error model](https://github.com/komeilm76/zopia/blob/main/docs/04-architecture.md#-error-model).
204
227
 
205
228
  ## ๐Ÿ”— Next
206
229
 
207
230
  - โš™๏ธ Every option โ†’ [Configuration](09-configuration.md)
208
- - ๐Ÿงช How all of this is tested โ†’ [Testing](11-testing.md)
231
+ - ๐Ÿงช How all of this is tested โ†’ [Testing](https://github.com/komeilm76/zopia/blob/main/docs/11-testing.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zopia",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
5
5
  "keywords": [
6
6
  "api-docs",
@@ -27,7 +27,10 @@
27
27
  "files": [
28
28
  "bin",
29
29
  "src",
30
- "docs",
30
+ "docs/07-api-docs.md",
31
+ "docs/09-configuration.md",
32
+ "docs/10-usage.md",
33
+ "!docs/README.md",
31
34
  "CHANGELOG.md",
32
35
  "LICENSE",
33
36
  "README.md"
@@ -72,6 +75,11 @@
72
75
  "types": "./src/index.ts",
73
76
  "import": "./src/index.ts",
74
77
  "default": "./src/index.ts"
78
+ },
79
+ "./runtime": {
80
+ "types": "./src/runtime.ts",
81
+ "import": "./src/runtime.ts",
82
+ "default": "./src/runtime.ts"
75
83
  }
76
84
  }
77
85
  }
@@ -1,6 +1,7 @@
1
1
  import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { asZopiaError, ZopiaError } from '../errors';
4
+ import { endpointExportName } from './api-docs-names';
4
5
  import { buildOpenApiOperationIR } from './openapi-ir';
5
6
  import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOperationContracts, reusableDeclarations } from './openapi-contracts';
6
7
  import { jsonSchemaToZod } from './json-schema-to-zod';
@@ -61,7 +62,7 @@ function collectComponentRefs(value: unknown, names = new Set<string>(), mapEntr
61
62
  return names;
62
63
  }
63
64
  function schemaCode(schema: unknown, name: string): string {
64
- const safeName = exportName(name);
65
+ const safeName = endpointExportName(name);
65
66
  const converted = jsonSchemaToZod(schema === undefined ? true : schema as any, { rootName: safeName });
66
67
  const source = converted.code.trimEnd();
67
68
  const direct = source.match(new RegExp(`^const ${safeName.replace(/[$]/g, '\\$&')} = ([\\s\\S]*);$`));
@@ -222,23 +223,16 @@ function resolveObject(value: unknown, source: OpenApiDocument): any {
222
223
  }
223
224
  return current;
224
225
  }
225
- function exportName(operationId: string): string {
226
- const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
227
- let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
228
- if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
229
- if (['arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'].includes(name)) name = `${name}Endpoint`;
230
- return name;
231
- }
232
226
  function componentExportName(componentName: string): string {
233
- const name = exportName(componentName);
227
+ const name = endpointExportName(componentName);
234
228
  return name.endsWith('Schema') ? name : `${name}Schema`;
235
229
  }
236
230
  function componentParameterExportName(componentName: string): string {
237
- const name = exportName(componentName);
231
+ const name = endpointExportName(componentName);
238
232
  return name.endsWith('Parameter') ? name : `${name}Parameter`;
239
233
  }
240
234
  function componentResponseExportName(componentName: string): string {
241
- const name = exportName(componentName);
235
+ const name = endpointExportName(componentName);
242
236
  return name.endsWith('Response') ? name : `${name}Response`;
243
237
  }
244
238
  function componentReaches(source: OpenApiDocument, from: string, target: string, seen = new Set<string>()): boolean {
@@ -352,7 +346,7 @@ function renderEndpoint(operation: any, source: OpenApiDocument, mode: ApiDocsMo
352
346
  const examplesValue = { ...(Object.keys(requestExamples).length ? { request: requestExamples } : {}), ...(Object.keys(responseExamples).length ? { response: responseExamples } : {}) };
353
347
  const examples = Object.keys(examplesValue).length ? `examples: JSON.parse(${JSON.stringify(stableDataJson(examplesValue))}),` : '';
354
348
  const opId = operation.operationId;
355
- const exportId = exportName(opId);
349
+ const exportId = endpointExportName(opId);
356
350
  const tags = ir.tags;
357
351
  const auth = ir.security !== undefined && ir.security.length > 0 && ir.security.every((requirement) => Object.keys(requirement as Record<string, unknown>).length > 0) ? 'YES' : 'NO';
358
352
  const sourceName = JSON.stringify(`${source.info.title} v${source.info.version}`).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ๐Ÿ“› Shared endpoint identifier rules (R-732).
3
+ *
4
+ * The single source of truth for deriving safe export identifiers from
5
+ * operation IDs โ€” and for the numeric uniqueness suffix applied when two
6
+ * derived identifiers collide. Generation (Engine โ‘ข's `export const` names
7
+ * and the collectors' deterministic operation IDs) and the runtime tree
8
+ * resolver (`flattenApiDocs` record keys) call these helpers so both surfaces
9
+ * keep producing identical names by construction.
10
+ */
11
+
12
+ /** ECMAScript keywords and literals never emitted as generated identifiers. */
13
+ const RESERVED_IDENTIFIER_NAMES: ReadonlySet<string> = new Set([
14
+ 'arguments', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'eval', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield',
15
+ ]);
16
+
17
+ /**
18
+ * Derive the endpoint export identifier from one operation ID.
19
+ *
20
+ * Applies the fixed naming contract of R-732: split on non-identifier
21
+ * characters, camel-case the parts (camelize), fall back to `endpoint` when
22
+ * nothing remains, prefix `endpoint` when the result starts with a digit or
23
+ * other non-identifier character, and append `Endpoint` when the result is a
24
+ * reserved ECMAScript keyword or literal (e.g. `await` โ†’ `awaitEndpoint`).
25
+ *
26
+ * @param operationId Explicit or deterministically derived operation identifier.
27
+ * @returns Safe camel-case TypeScript identifier for the module export.
28
+ */
29
+ export function endpointExportName(operationId: string): string {
30
+ const parts = operationId.split(/[^A-Za-z0-9_$]+/).filter(Boolean);
31
+ let name = parts.map((part, index) => index === 0 ? part : part[0].toUpperCase() + part.slice(1)).join('') || 'endpoint';
32
+ if (!/^[A-Za-z_$]/.test(name)) name = `endpoint${name}`;
33
+ if (RESERVED_IDENTIFIER_NAMES.has(name)) name = `${name}Endpoint`;
34
+ return name;
35
+ }
36
+
37
+ /**
38
+ * Return the first free variant of a derived identifier, suffixing `2`, `3`, โ€ฆ
39
+ *
40
+ * This is the exact uniqueness rule the operation collectors apply when a
41
+ * derived operation ID collides (`getA` โ†’ `getA2` โ†’ `getA3`): the first
42
+ * candidate is the base itself, and every subsequent candidate appends the
43
+ * next integer directly to the base.
44
+ *
45
+ * @param base Identifier before any uniqueness suffix.
46
+ * @param used Already-claimed identifiers this derivation must not clobber.
47
+ * @returns `base` itself when free, otherwise the first suffixed variant.
48
+ */
49
+ export function uniqueEndpointName(base: string, used: ReadonlySet<string>): string {
50
+ let name = base;
51
+ let suffix = 1;
52
+ while (used.has(name)) name = `${base}${++suffix}`;
53
+ return name;
54
+ }
@@ -16,7 +16,7 @@ export const ZOPIA_MANIFEST_SCHEMA = 'zopia:manifest@1' as const;
16
16
  export const ZOPIA_MANIFEST_FILE = '.zopia-manifest.json' as const;
17
17
 
18
18
  /** Package version recorded by the current manifest writer. */
19
- export const ZOPIA_VERSION = '0.3.0' as const;
19
+ export const ZOPIA_VERSION = '0.4.0' as const;
20
20
 
21
21
  /** Supported source dialect labels stored in a manifest. */
22
22
  export type ZopiaManifestSourceKind = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
@@ -1,5 +1,6 @@
1
1
  import { ZopiaError } from '../errors';
2
2
  import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
3
+ import { uniqueEndpointName } from './api-docs-names';
3
4
  import { resolveOpenApiLocalRef } from './openapi-ref';
4
5
 
5
6
  /** Canonical km-api/OpenAPI operation method order. */
@@ -107,15 +108,12 @@ export function collectOpenApiOperations(input: OpenApiDocument | string): OpenA
107
108
  const previous = owners.get(operationId);
108
109
  if (previous) {
109
110
  if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
110
- let replacement = previous.operationId; let suffix = 1;
111
- while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
111
+ const replacement = uniqueEndpointName(operationId, ids);
112
112
  ids.delete(previous.operationId); owners.delete(previous.operationId);
113
113
  previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
114
114
  }
115
115
  } else {
116
- const base = deriveOperationId(path, method);
117
- operationId = base; let suffix = 1;
118
- while (ids.has(operationId)) operationId = `${base}${++suffix}`;
116
+ operationId = uniqueEndpointName(deriveOperationId(path, method), ids);
119
117
  }
120
118
  const collected = { path, method, operation, operationId, parameters: mergedParameters };
121
119
  ids.add(operationId); owners.set(operationId, collected); operations.push(collected);
@@ -185,15 +183,12 @@ export function collectOpenApiWebhookOperations(document: OpenApiDocument): Open
185
183
  const previous = owners.get(operationId);
186
184
  if (previous) {
187
185
  if (previous.operation.operationId !== undefined) throw new ZopiaError('ZOPIA_SPEC_INVALID', `Duplicate operationId: ${operationId}`);
188
- let replacement = previous.operationId; let suffix = 1;
189
- while (ids.has(replacement)) replacement = `${operationId}${++suffix}`;
186
+ const replacement = uniqueEndpointName(operationId, ids);
190
187
  ids.delete(previous.operationId); owners.delete(previous.operationId);
191
188
  previous.operationId = replacement; ids.add(replacement); owners.set(replacement, previous);
192
189
  }
193
190
  } else {
194
- const base = `${method}${pascalPath(name)}`;
195
- operationId = base; let suffix = 1;
196
- while (ids.has(operationId)) operationId = `${base}${++suffix}`;
191
+ operationId = uniqueEndpointName(`${method}${pascalPath(name)}`, ids);
197
192
  }
198
193
  const collected = { path: name, method, operation, operationId, parameters: mergedParameters };
199
194
  ids.add(operationId); owners.set(operationId, collected); operations.push(collected);