zopia 0.4.0 โ†’ 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.1] - 2026-09-30
11
+
12
+ ### ๐Ÿ› Fixed
13
+ - ๐Ÿ› **Duplicate `.int()` in generated Zod schemas for `int32` and `int64`
14
+ formats (#4).** Integer properties carrying an integer format emitted
15
+ `z.number().int().int()...` โ€” the `integer` type already appends `.int()`
16
+ and the format branch appended it again. Now every combination
17
+ (`integer`/`number` ร— `int32`/`int64`/`uint32`/`uint64`) emits exactly one
18
+ `.int()`, with bounds (`int32` โ†’ `-2147483648...2147483647`,
19
+ `uint32` โ†’ `0...4294967295`) and `.nonnegative()` for the unsigned formats
20
+ unchanged. Runtime validation, warning codes, and reverse-conversion
21
+ overlays are untouched; covered by new unit and end-to-end regression
22
+ tests that assert the emitted code (not just parse behavior).
23
+
24
+ ## [0.5.0] - 2026-09-29
25
+
26
+ ### โœจ Added
27
+ - ๐Ÿง  **Exact IntelliSense for runtime tree consumption (S-95).** Every tree
28
+ generated with a manifest now also carries a types-only
29
+ **`.zopia-tree.d.ts`** declaration beside the manifest, and
30
+ `createApiDocs` / `flattenApiDocs` accept it as a type argument โ€” turning the
31
+ permissive runtime typing into **exact** typing:
32
+ ```ts
33
+ import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
34
+ import type { ApiDocsTree, ApiDocsFlat } from './api_docs/.zopia-tree';
35
+
36
+ const apiDocs = await createApiDocs<ApiDocsTree>('./api_docs');
37
+ apiDocs.users['{userId}'].get.pathShape; // literal "/users/{userId}", autocompleted
38
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
39
+ endpoints.getUser; // exact key, same leaf object
40
+ ```
41
+ Segment and method keys autocomplete exactly (unknown keys are **compile
42
+ errors**, not `any`), every leaf is typed as the generated module's own
43
+ `makeApiConfig()` export (literal `method`/`pathShape`, exact Zod request /
44
+ response shapes), and the flat record's keys derive through the same shared
45
+ naming rules as the generator's export identifiers (camelize,
46
+ reserved-word guard, `2`/`3`โ€ฆ collision suffixes) โ€” parity with the runtime
47
+ keys is pinned by tests. The declaration is emitted in both layouts and in
48
+ every preset bucket root, follows the manifest lifecycle (pruned when
49
+ manifests are disabled, refreshed on regeneration, and a deleted declaration
50
+ reports `ZOPIA_WARN_STALE_TREE`), and is types-only: it imports nothing
51
+ beyond the tree itself (R-502 holds). `zopia generate` results report the
52
+ file with a new `kind: 'types'`. Conflicting paths that cannot share one
53
+ nested tree (below a method leaf, trailing-slash twins) render the
54
+ permissive intersection shape โ€” matching the runtime's typed
55
+ `ZOPIA_SPEC_INVALID` failure. The shared deterministic tree ordering
56
+ (path segments, then canonical method order) moved to
57
+ `src/conversions/api-docs-layout.ts` so the runtime resolver and the
58
+ declaration emitter enumerate identically.
59
+
10
60
  ## [0.4.0] - 2026-09-29
11
61
 
12
62
  ### ๐Ÿ”„ Changed
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.4.0 released**
16
+ โœ… **Status โ€” Phase 3 complete ยท v0.5.1 released**
17
17
 
18
18
  </div>
19
19
 
@@ -100,16 +100,21 @@ all resolve through their manifests):
100
100
 
101
101
  ```ts
102
102
  import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
103
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
103
104
 
104
105
  // Nested: URL path segments โ†’ lowercase method โ†’ the makeApiConfig object
105
- const apiDocs = await createApiDocs('api_docs');
106
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
106
107
  const getUser = apiDocs.users['{userId}'].get; // default export of that index.ts
107
108
 
108
109
  // Flat freebie: keyed by operationId, same leaf objects, deterministic order
109
- const endpoints = flattenApiDocs(apiDocs);
110
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
110
111
  endpoints.getUser === getUser; // โ†’ true
111
112
  ```
112
113
 
114
+ Every generated tree also ships a types-only `.zopia-tree.d.ts` โ€” pass its
115
+ types as shown and IntelliSense becomes **exact**: keys autocomplete, and
116
+ misspelled segments, methods, or endpoint names are compile errors.
117
+
113
118
  Generation output is untouched โ€” the resolver only reads manifests and imports
114
119
  modules. See [docs/07-api-docs.md โ†’ Runtime tree consumption](docs/07-api-docs.md#-runtime-tree-consumption--createapidocs).
115
120
 
@@ -115,15 +115,31 @@ file-level API, and the manifest remains authoritative (D-06).
115
115
 
116
116
  ```ts
117
117
  import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
118
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
118
119
 
119
- const apiDocs = await createApiDocs('api_docs'); // โ† the entire consumer DX
120
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs'); // โ† the entire consumer DX
120
121
  const endpoint = apiDocs.applicant['{applicantId}'].exame['{examId}'].get;
121
- // โ†’ the endpoint module's makeApiConfig object (its default export)
122
+ // โ†’ the endpoint module's makeApiConfig object (its default export), EXACTLY typed
122
123
 
123
- const endpoints = flattenApiDocs(apiDocs); // the flat freebie
124
- endpoints.getExam; // same leaf object
124
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // the flat freebie
125
+ endpoints.getExam; // same leaf object
125
126
  ```
126
127
 
128
+ **Exact IntelliSense (S-95).** Called without a type argument, the helpers
129
+ return permissively typed results (every node is a branch โˆช config). Passing
130
+ the generated **`.zopia-tree.d.ts`** types โ€” written beside every retained
131
+ manifest, in both layouts and every preset bucket root โ€” makes the result
132
+ **exact**: segment and method keys autocomplete, unknown keys are compile
133
+ errors (not `any`), every leaf carries the generated module's own
134
+ `makeApiConfig()` type (literal `method`/`pathShape`, exact Zod request and
135
+ response shapes), and the flat record's keys are the derived endpoint names
136
+ (same shared rules as the generator's export identifiers, including collision
137
+ suffixes). The declaration is types-only โ€” it imports nothing beyond the tree
138
+ itself (R-502 holds) โ€” and follows the manifest lifecycle: refreshed on
139
+ regeneration, pruned when manifests are disabled, and a deleted declaration
140
+ reports `ZOPIA_WARN_STALE_TREE`. `zopia generate` results list it with
141
+ `kind: 'types'`.
142
+
127
143
  **Manifest discovery & merge (B-conditions).** The resolver reads the root
128
144
  `.zopia-manifest.json` **plus** every one-level-deep preset bucket manifest
129
145
  (`multi-tag` / `multi-server` splits write one manifest per bucket directory),
@@ -138,8 +154,9 @@ segments first, then the canonical method order
138
154
  (including literal `{param}` segments), the leaf key is the lowercase method,
139
155
  and the leaf value is the endpoint module's **default export**, loaded with
140
156
  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
157
+ exist depends on the source spec, the default `ApiDocsTree` return type is
158
+ permissive (branch โˆช config) โ€” pass the generated `.zopia-tree.d.ts` type for
159
+ exact keys (see below). A tree `/` path nests its methods at the root
143
160
  (`apiDocs.get`). Paths that cannot coexist in one nested tree โ€” a path
144
161
  continuing below another path's method leaf, or two paths differing only by a
145
162
  trailing slash โ€” fail with a typed `ZOPIA_SPEC_INVALID` instead of silently
package/docs/10-usage.md CHANGED
@@ -191,16 +191,22 @@ resolve through their manifests:
191
191
 
192
192
  ```ts
193
193
  import { createApiDocs, flattenApiDocs } from 'zopia/runtime';
194
+ import type { ApiDocsFlat, ApiDocsTree } from './api_docs/.zopia-tree';
194
195
 
195
196
  // Nested: exact URL path segments, lowercase method leaf, default-export config
196
- const apiDocs = await createApiDocs('api_docs');
197
+ const apiDocs = await createApiDocs<ApiDocsTree>('api_docs');
197
198
  const getUser = apiDocs.users['{userId}'].get; // the makeApiConfig object
198
199
 
199
200
  // Flat: keyed by operationId (derived with the generator's own naming rules)
200
- const endpoints = flattenApiDocs(apiDocs);
201
+ const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
201
202
  endpoints.getUser === getUser; // โ†’ true
202
203
  ```
203
204
 
205
+ The type import is optional โ€” without it the results stay permissively typed โ€”
206
+ but with it every key is exact: `apiDocs.users.` autocompletes `{userId}`,
207
+ `endpoints.` autocompletes the endpoint names, and misspelled keys are compile
208
+ errors.
209
+
204
210
  Failures are typed `ZopiaError`s with `at`/`hint` โ€” see
205
211
  [API docs โ†’ Runtime tree consumption](07-api-docs.md#-runtime-tree-consumption--createapidocs).
206
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zopia",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Type-safe OpenAPI, JSON Schema, and Zod conversion toolkit.",
5
5
  "keywords": [
6
6
  "api-docs",
@@ -7,6 +7,7 @@ import { deriveReusableParameterSchema, deriveReusableResponseSchema, extractOpe
7
7
  import { jsonSchemaToZod } from './json-schema-to-zod';
8
8
  import { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './api-docs-plan';
9
9
  import { isPortableApiDocsSegment, type ApiDocsMode } from './api-docs-layout';
10
+ import { renderApiDocsTreeTypes, ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
10
11
  import type { OpenApiDocument } from './openapi';
11
12
  import { createZopiaManifest, hashOpenApiDocument, writeZopiaManifest, ZOPIA_MANIFEST_FILE } from './manifest-writer';
12
13
  import { inspectZopiaManifestStaleness, removeObsoleteManifestFiles } from './manifest-staleness';
@@ -452,7 +453,11 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
452
453
  reservedFiles.push(`${directory}/index.ts`, ...names.map((name) => `${directory}/${name}/index.ts`));
453
454
  }
454
455
  }
455
- if (retainManifest) reservedFiles.push(ZOPIA_MANIFEST_FILE);
456
+ if (retainManifest) {
457
+ reservedFiles.push(ZOPIA_MANIFEST_FILE);
458
+ // The exact-tree declaration is emitted beside the manifest and shares its lifecycle.
459
+ reservedFiles.push(ZOPIA_TREE_TYPES_FILE);
460
+ }
456
461
  const plans = avoidReservedFileCollisions(planApiDocsFiles(source, mode), reservedFiles);
457
462
  const webhookPlans = avoidReservedFileCollisions(planWebhookDocsFiles(source, mode), [...reservedFiles, ...plans.map((plan) => plan.file)]);
458
463
  // OpenAPI requires operationId to be unique document-wide; $ref aliases can make the
@@ -555,6 +560,14 @@ async function generateApiDocsFilesInternal(input: OpenApiDocument | string, opt
555
560
  if (manifest) {
556
561
  const manifestPath = await writeZopiaManifest(root, manifest);
557
562
  generated.push({ file: ZOPIA_MANIFEST_FILE, absolutePath: manifestPath, operationId: 'manifest' });
563
+ // Exact IntelliSense: the declaration mirrors the runtime tree/flat shapes (S-95).
564
+ const treeTypesPath = await writeGeneratedFile(root, ZOPIA_TREE_TYPES_FILE, renderApiDocsTreeTypes(plans.map((plan) => ({
565
+ file: plan.file,
566
+ path: plan.path,
567
+ method: plan.method,
568
+ operationId: plan.operationId,
569
+ }))), previouslyOwned);
570
+ generated.push({ file: ZOPIA_TREE_TYPES_FILE, absolutePath: treeTypesPath, operationId: 'tree-types' });
558
571
  }
559
572
  await removeObsoleteManifestFiles(root, previous.ownedFiles, generated.map(({ file }) => file));
560
573
  return generated;
@@ -4,6 +4,47 @@ import { OPENAPI_METHODS, type OpenApiMethod } from './openapi-to-api-docs';
4
4
  /** Filesystem layout used for generated endpoint modules. */
5
5
  export type ApiDocsMode = 'directory' | 'flat';
6
6
 
7
+ /**
8
+ * Split one OpenAPI path template into its literal segment keys.
9
+ *
10
+ * Empty segments are dropped, so the root path `/` has no segments and its
11
+ * methods nest directly at the tree root. This is the single segmentation rule
12
+ * shared by the runtime tree resolver and the generated `.zopia-tree.d.ts`.
13
+ *
14
+ * @param path OpenAPI path template beginning with `/`.
15
+ * @returns Path segments in order, including literal `{param}` segments.
16
+ */
17
+ export function apiDocsPathSegments(path: string): string[] {
18
+ return path.split('/').filter(Boolean);
19
+ }
20
+
21
+ /**
22
+ * Compare two endpoint entries by the canonical deterministic tree order:
23
+ * URL path segments lexically, shorter segment chains first, then the
24
+ * canonical method order (`get, post, put, delete, head, options, patch, trace`).
25
+ *
26
+ * The runtime tree resolver inserts keys in this order and the generated
27
+ * `.zopia-tree.d.ts` declares them in this order, so both surfaces enumerate
28
+ * identically (R-732/S-94).
29
+ *
30
+ * @param left Endpoint entry with an OpenAPI `path` and lowercase `method`.
31
+ * @param right Endpoint entry with an OpenAPI `path` and lowercase `method`.
32
+ * @returns Negative when `left` sorts first, positive when `right` does, zero when equal.
33
+ */
34
+ export function compareApiDocsEntries(left: { path: string; method: string }, right: { path: string; method: string }): number {
35
+ const leftSegments = apiDocsPathSegments(left.path);
36
+ const rightSegments = apiDocsPathSegments(right.path);
37
+ const depth = Math.min(leftSegments.length, rightSegments.length);
38
+ for (let index = 0; index < depth; index += 1) {
39
+ const order = leftSegments[index] < rightSegments[index] ? -1 : leftSegments[index] > rightSegments[index] ? 1 : 0;
40
+ if (order !== 0) return order;
41
+ }
42
+ const lengthOrder = leftSegments.length - rightSegments.length;
43
+ if (lengthOrder !== 0) return lengthOrder;
44
+ const methods = OPENAPI_METHODS as readonly string[];
45
+ return methods.indexOf(left.method) - methods.indexOf(right.method);
46
+ }
47
+
7
48
  /**
8
49
  * Return whether one generated-tree path segment is portable across supported filesystems.
9
50
  *
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ๐Ÿง  Generated exact tree types (S-95).
3
+ *
4
+ * Renders the `.zopia-tree.d.ts` declaration emitted beside every retained
5
+ * manifest: a types-only artifact describing the **exact** nested shape the
6
+ * runtime resolver (`createApiDocs`) builds and the **exact** flat record
7
+ * `flattenApiDocs` returns, so consumers get precise IntelliSense โ€” literal
8
+ * path-segment keys, method leaves typed as the generated module's own
9
+ * `makeApiConfig()` export, and typo-proof access (unknown keys are compile
10
+ * errors). Nothing runtime is emitted; the file is pure declaration.
11
+ *
12
+ * Ordering and naming are shared with the runtime: entries sort through
13
+ * `compareApiDocsEntries` and flat keys derive through `endpointExportName` /
14
+ * `uniqueEndpointName` โ€” the same rules as the generator's `export const`
15
+ * names โ€” so the declared types match the runtime objects by construction.
16
+ */
17
+
18
+ import { apiDocsPathSegments, compareApiDocsEntries } from './api-docs-layout';
19
+ import { endpointExportName, uniqueEndpointName } from './api-docs-names';
20
+
21
+ /** File name of the generated exact-tree declaration, written beside the manifest. */
22
+ export const ZOPIA_TREE_TYPES_FILE = '.zopia-tree.d.ts';
23
+
24
+ /** One endpoint record the declaration is rendered from. */
25
+ export interface ApiDocsTreeTypePlan {
26
+ /** Portable endpoint-module path relative to the tree root (POSIX, with `.ts`). */
27
+ file: string;
28
+ /** OpenAPI path template โ€” the authority for the nested key chain. */
29
+ path: string;
30
+ /** Lowercase HTTP method โ€” the leaf key. */
31
+ method: string;
32
+ /** Declared or deterministically derived operation identifier. */
33
+ operationId: string;
34
+ }
35
+
36
+ /** Internal tree node while assembling the declaration: a branch, a method leaf, or both. */
37
+ interface TypeTreeNode {
38
+ /** Child branches/leaves by key; insertion follows the deterministic entry order. */
39
+ children?: Map<string, TypeTreeNode>;
40
+ /** Module type reference when this node is (also) a method leaf. */
41
+ leaf?: { module: string; exportName: string };
42
+ }
43
+
44
+ /** Import-free module type reference: `typeof import('<module>').<exportName>`. */
45
+ const typeReference = (module: string, exportName: string): string => `typeof import('${module}').${exportName}`;
46
+
47
+ /** Module specifier of one endpoint plan: the portable file path without the `.ts` extension. */
48
+ const moduleSpecifier = (file: string): string => `./${file.replace(/\.ts$/, '')}`;
49
+
50
+ /**
51
+ * Render the `.zopia-tree.d.ts` declaration for one tree root.
52
+ *
53
+ * @param apis Endpoint records (path operations only โ€” webhooks are not URL-path endpoints).
54
+ * @returns The complete declaration file text: an `ApiDocsTree` type keyed by exact URL path segments with lowercase-method leaves, and an `ApiDocsFlat` type keyed by the derived endpoint names.
55
+ */
56
+ export function renderApiDocsTreeTypes(apis: readonly ApiDocsTreeTypePlan[]): string {
57
+ const sorted = [...apis].sort(compareApiDocsEntries);
58
+ const root: TypeTreeNode = {};
59
+ for (const api of sorted) {
60
+ let node: TypeTreeNode = root;
61
+ for (const segment of apiDocsPathSegments(api.path)) {
62
+ let child = node.children?.get(segment);
63
+ if (!child) {
64
+ child = {};
65
+ (node.children ??= new Map<string, TypeTreeNode>()).set(segment, child);
66
+ }
67
+ node = child;
68
+ }
69
+ (node.children ??= new Map<string, TypeTreeNode>()).set(api.method, { leaf: { module: moduleSpecifier(api.file), exportName: endpointExportName(api.operationId) } });
70
+ }
71
+
72
+ const renderNode = (node: TypeTreeNode, depth: number): string => {
73
+ if (!node.children || node.children.size === 0) return 'Record<string, never>';
74
+ const indent = ' '.repeat(depth);
75
+ const lines: string[] = [];
76
+ for (const [key, child] of node.children) {
77
+ const childType = child.leaf
78
+ ? child.children && child.children.size > 0
79
+ // A path continuing below another path's method leaf: the key is both the
80
+ // endpoint config and a deeper branch โ€” the permissive intersection shape.
81
+ ? `(${typeReference(child.leaf.module, child.leaf.exportName)} & ${renderNode(child, 1)})`
82
+ : typeReference(child.leaf.module, child.leaf.exportName)
83
+ : renderNode(child, depth + 1);
84
+ lines.push(`${indent} readonly ${JSON.stringify(key)}: ${childType};`);
85
+ }
86
+ return `{\n${lines.join('\n')}\n${indent}}`;
87
+ };
88
+
89
+ const flatLines: string[] = [];
90
+ const used = new Set<string>();
91
+ for (const api of sorted) {
92
+ const key = uniqueEndpointName(endpointExportName(api.operationId), used);
93
+ used.add(key);
94
+ flatLines.push(` readonly ${JSON.stringify(key)}: ${typeReference(moduleSpecifier(api.file), endpointExportName(api.operationId))};`);
95
+ }
96
+
97
+ return [
98
+ '/**',
99
+ ' * Generated by zopia โ€” do not edit by hand.',
100
+ ' *',
101
+ ' * Exact types of this api-docs tree as loaded at runtime by the',
102
+ ' * `createApiDocs()` / `flattenApiDocs()` helpers of the zopia `zopia/runtime`',
103
+ ' * subpath (types-only: this file imports nothing beyond the tree itself):',
104
+ ' *',
105
+ " * const apiDocs = await createApiDocs<ApiDocsTree>('./<this-tree>'); // nested: exact segments โ†’ method",
106
+ ' * const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs); // flat: exact endpoint names',
107
+ ' */',
108
+ `export type ApiDocsTree = ${renderNode(root, 0)};`,
109
+ '',
110
+ `export type ApiDocsFlat = ${flatLines.length ? `{\n${flatLines.join('\n')}\n}` : 'Record<string, never>'};`,
111
+ '',
112
+ ].join('\n').replace(/[ \t]+$/gm, '');
113
+ }
@@ -684,11 +684,17 @@ function jsonSchemaToZodInternal(input: JsonSchema | string, options: JsonSchema
684
684
  }
685
685
  }
686
686
  if ((node.format === 'int32' || node.format === 'int64') && (node.type === 'integer' || node.type === 'number')) {
687
- const integer = (result.schema as any).int(); const code = `${result.code}.int()`;
688
- result = node.format === 'int32' ? { schema: integer.min(-2147483648).max(2147483647), code: `${code}.min(-2147483648).max(2147483647)` } : { schema: integer, code };
687
+ // `integer` schemas already carry .int() from the type switch above โ€” appending it again
688
+ // duplicated the call in generated code (issue #4); only `number` schemas need it added here.
689
+ const alreadyInteger = node.type === 'integer';
690
+ const integer = alreadyInteger ? result.schema : (result.schema as any).int();
691
+ const code = alreadyInteger ? result.code : `${result.code}.int()`;
692
+ result = node.format === 'int32' ? { schema: (integer as any).min(-2147483648).max(2147483647), code: `${code}.min(-2147483648).max(2147483647)` } : { schema: integer, code };
689
693
  }
690
694
  if ((node.format === 'uint32' || node.format === 'uint64') && (node.type === 'integer' || node.type === 'number')) {
691
- const integer = (result.schema as any).int().nonnegative(); const code = `${result.code}.int().nonnegative()`;
695
+ const alreadyInteger = node.type === 'integer';
696
+ const integer = alreadyInteger ? (result.schema as any).nonnegative() : (result.schema as any).int().nonnegative();
697
+ const code = alreadyInteger ? `${result.code}.nonnegative()` : `${result.code}.int().nonnegative()`;
692
698
  result = node.format === 'uint32' ? { schema: integer.max(4294967295), code: `${code}.max(4294967295)` } : { schema: integer, code };
693
699
  }
694
700
  if (node.format && node.type === 'string') {
@@ -1,6 +1,7 @@
1
1
  import { lstat, readFile, realpath, rmdir, rm, stat } from 'node:fs/promises';
2
2
  import { asZopiaError } from '../errors';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
4
5
  import type { ApiDocsMode } from './api-docs-layout';
5
6
  import {
6
7
  validateZopiaManifest,
@@ -55,7 +56,8 @@ function compareText(left: string, right: string): number {
55
56
  const isManifestComponent = (value: unknown): value is { kind?: string; file?: unknown } => typeof value === 'object' && value !== null;
56
57
 
57
58
  function collectOwnedFiles(manifest: GeneratedZopiaManifest): string[] {
58
- const files = new Set<string>([ZOPIA_MANIFEST_FILE]);
59
+ // The exact-tree declaration is written and pruned together with the manifest.
60
+ const files = new Set<string>([ZOPIA_MANIFEST_FILE, ZOPIA_TREE_TYPES_FILE]);
59
61
  for (const api of manifest.apis) files.add(api.file);
60
62
  for (const webhook of manifest.webhooks ?? []) files.add(webhook.file);
61
63
  for (const component of manifest.components) if (component.file !== null) files.add(component.file);
@@ -110,7 +112,7 @@ export async function inspectZopiaManifestStaleness(outputDir: string, identity:
110
112
  return {
111
113
  status: 'stale',
112
114
  reasons: ['invalid-manifest', ...(!identity.manifest ? ['manifest-disabled' as const] : [])],
113
- ownedFiles: [ZOPIA_MANIFEST_FILE],
115
+ ownedFiles: [ZOPIA_MANIFEST_FILE, ZOPIA_TREE_TYPES_FILE],
114
116
  };
115
117
  }
116
118
 
@@ -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.4.0' as const;
19
+ export const ZOPIA_VERSION = '0.5.1' 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';
@@ -12,6 +12,7 @@ import { normalizeOpenApiDocument, type OpenApiDocument } from './openapi';
12
12
  import { parseYaml } from './yaml';
13
13
  import { jsonSchemaToZod, type JsonSchema } from './json-schema-to-zod';
14
14
  import { hashOpenApiDocument, ZOPIA_MANIFEST_FILE } from './manifest-writer';
15
+ import { ZOPIA_TREE_TYPES_FILE } from './api-docs-tree-types';
15
16
  import { formatManifestStaleness, inspectZopiaManifestStaleness } from './manifest-staleness';
16
17
  import { join } from 'node:path';
17
18
  import { planPresetBuckets, ZOPIA_GENERATE_PRESETS, type ZopiaGeneratePreset, type ZopiaPresetTree } from './api-docs-presets';
@@ -39,7 +40,7 @@ export interface ZopiaGeneratedFile {
39
40
  /** Portable path relative to `outDir`. */
40
41
  path: string;
41
42
  /** Generated artifact category. */
42
- kind: 'endpoint' | 'component' | 'manifest';
43
+ kind: 'endpoint' | 'component' | 'manifest' | 'types';
43
44
  }
44
45
 
45
46
  /** Result returned by the Engine โ‘ข public API. */
@@ -459,7 +460,7 @@ export async function openApiToApiDocs(input: string | Record<string, unknown>,
459
460
  : []);
460
461
  const files: ZopiaGeneratedFile[] = generated.map(({ file }): ZopiaGeneratedFile => ({
461
462
  path: file,
462
- kind: file === ZOPIA_MANIFEST_FILE ? 'manifest' : componentFiles.has(file) ? 'component' : 'endpoint',
463
+ kind: file === ZOPIA_MANIFEST_FILE ? 'manifest' : file === ZOPIA_TREE_TYPES_FILE ? 'types' : componentFiles.has(file) ? 'component' : 'endpoint',
463
464
  })).sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
464
465
  const warningCollector = new ZopiaWarningCollector(); warningCollector.addAll(warnings);
465
466
  return { files, warnings: warningCollector.toArray(), ...(config.manifest ? { manifestPath: ZOPIA_MANIFEST_FILE } : {}) };
@@ -17,6 +17,7 @@ import { isAbsolute, join, resolve } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { asZopiaError, ZopiaError } from '../errors';
19
19
  import { endpointExportName, uniqueEndpointName } from '../conversions/api-docs-names';
20
+ import { apiDocsPathSegments, compareApiDocsEntries } from '../conversions/api-docs-layout';
20
21
  import { deriveOperationId, OPENAPI_METHODS, type OpenApiMethod } from '../conversions/openapi-to-api-docs';
21
22
  import { ZOPIA_MANIFEST_FILE } from '../conversions/manifest-writer';
22
23
 
@@ -65,11 +66,7 @@ interface DiscoveredManifest {
65
66
  const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value);
66
67
  const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
67
68
  const isMissingFileError = (error: unknown): boolean => isRecord(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
68
-
69
- /** Split one OpenAPI path template into its literal segment keys (empty segments dropped, so `/` has none). */
70
- function pathSegments(path: string): string[] {
71
- return path.split('/').filter(Boolean);
72
- }
69
+ const isSupportedApiDocsMethod = (method: string): boolean => (OPENAPI_METHODS as readonly string[]).includes(method);
73
70
 
74
71
  /** Guard one manifest file reference: relative, POSIX-separated, and unable to escape the tree root. */
75
72
  function isSafeTreeFile(file: string): boolean {
@@ -87,7 +84,7 @@ function manifestApiEntries(manifest: unknown, manifestPath: string): Array<{ fi
87
84
  if (!isRecord(api)
88
85
  || typeof api.file !== 'string' || !isSafeTreeFile(api.file)
89
86
  || typeof api.path !== 'string' || !api.path.startsWith('/')
90
- || typeof api.method !== 'string' || !(OPENAPI_METHODS as readonly string[]).includes(api.method)) {
87
+ || typeof api.method !== 'string' || !isSupportedApiDocsMethod(api.method)) {
91
88
  throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `invalid zopia manifest API entry ${index}: ${manifestPath}`, { at: `${manifestPath}#apis/${index}`, hint: 'regenerate the tree to rebuild a valid manifest' });
92
89
  }
93
90
  entries.push({ file: api.file, path: api.path, method: api.method });
@@ -152,19 +149,7 @@ function mergeApiEntries(manifests: readonly DiscoveredManifest[]): MergedApiEnt
152
149
  merged.push({ root, file: api.file, path: api.path, method: api.method });
153
150
  }
154
151
  }
155
- return merged.sort((left, right) => {
156
- const leftSegments = pathSegments(left.path);
157
- const rightSegments = pathSegments(right.path);
158
- const depth = Math.min(leftSegments.length, rightSegments.length);
159
- for (let index = 0; index < depth; index += 1) {
160
- const order = compareText(leftSegments[index], rightSegments[index]);
161
- if (order !== 0) return order;
162
- }
163
- const lengthOrder = leftSegments.length - rightSegments.length;
164
- if (lengthOrder !== 0) return lengthOrder;
165
- const methods = OPENAPI_METHODS as readonly string[];
166
- return methods.indexOf(left.method) - methods.indexOf(right.method);
167
- });
152
+ return merged.sort((left, right) => compareApiDocsEntries(left, right));
168
153
  }
169
154
 
170
155
  /** Import one generated endpoint module through its file URL โ€” `pathToFileURL` keeps absolute (Windows-style) paths safe. */
@@ -231,6 +216,17 @@ function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Reco
231
216
  * tree always enumerates its keys in the same order. The tree is only read and
232
217
  * imported โ€” nothing on disk is written.
233
218
  *
219
+ * **Exact IntelliSense (S-95):** every tree generated with a manifest also
220
+ * carries a `.zopia-tree.d.ts` declaration. Pass its `ApiDocsTree` type as the
221
+ * type argument to type the result exactly โ€” literal path-segment keys,
222
+ * method-leaf configs typed as the generated module's own `makeApiConfig()`
223
+ * export, and unknown keys become compile errors:
224
+ *
225
+ * ```ts
226
+ * import type { ApiDocsTree } from './api_docs/.zopia-tree';
227
+ * const apiDocs = await createApiDocs<ApiDocsTree>('./api_docs');
228
+ * ```
229
+ *
234
230
  * @param docsDir Generated api-docs output directory (tree root or preset bucket root; absolute or relative).
235
231
  * @returns Nested tree keyed by exact URL path segments with lowercase-method leaves holding the endpoint configs.
236
232
  * @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `docsDir` is not a usable path.
@@ -242,13 +238,14 @@ function isEndpointConfig(value: unknown): value is ApiDocsEndpointConfig & Reco
242
238
  * ```ts
243
239
  * import { createApiDocs } from './src/runtime';
244
240
  *
245
- * const apiDocs = await createApiDocs('api_docs');
246
- * const endpoint = apiDocs.users['{userId}'].get;
247
- * console.log(endpoint.method, endpoint.pathShape);
241
+ * // Exact typing: pass the generated `.zopia-tree.d.ts` tree type.
242
+ * type MyTree = { readonly users: { readonly get: { readonly pathShape: string } } };
243
+ * const apiDocs = await createApiDocs<MyTree>('api_docs');
244
+ * console.log(apiDocs.users.get.pathShape);
248
245
  * ```
249
246
  * @see [docs/07-api-docs.md โ†’ Runtime tree consumption](../../docs/07-api-docs.md)
250
247
  */
251
- export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
248
+ export async function createApiDocs<TTree extends object = ApiDocsTree>(docsDir: string): Promise<TTree> {
252
249
  if (typeof docsDir !== 'string' || docsDir.trim() === '' || docsDir.includes('\0')) {
253
250
  throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'docsDir must be a non-empty directory path', { at: 'docsDir', hint: 'provide the generated api-docs output directory (tree root or preset bucket root)' });
254
251
  }
@@ -265,9 +262,9 @@ export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
265
262
  if (!isRecord(config)) {
266
263
  throw new ZopiaError('ZOPIA_DOCS_IMPORT_FAILED', `generated endpoint module has no default export: ${entry.file}`, { at: entry.file, hint: "regenerate the tree, or restore the module's default endpoint-config export" });
267
264
  }
268
- insertEndpoint(tree, pathSegments(entry.path), entry.method, config, entry.file);
265
+ insertEndpoint(tree, apiDocsPathSegments(entry.path), entry.method, config, entry.file);
269
266
  }
270
- return tree as ApiDocsTree;
267
+ return tree as unknown as TTree;
271
268
  }
272
269
 
273
270
  /**
@@ -283,20 +280,31 @@ export async function createApiDocs(docsDir: string): Promise<ApiDocsTree> {
283
280
  * a null prototype, so even an `operationId` spelled `__proto__` stays a plain
284
281
  * own key.
285
282
  *
286
- * @param apiDocs Nested tree returned by {@link createApiDocs}.
283
+ * **Exact IntelliSense (S-95):** pass the generated `.zopia-tree.d.ts`
284
+ * `ApiDocsFlat` type as the type argument to key the record exactly โ€”
285
+ * `endpoints.getUser` autocompletes and unknown names become compile errors:
286
+ *
287
+ * ```ts
288
+ * import type { ApiDocsFlat } from './api_docs/.zopia-tree';
289
+ * const endpoints = flattenApiDocs<ApiDocsFlat>(apiDocs);
290
+ * ```
291
+ *
292
+ * @param apiDocs Nested tree returned by {@link createApiDocs} (exact generated tree types are accepted).
287
293
  * @returns Flat record of every endpoint config keyed by its derived endpoint name, in tree leaf order.
288
294
  * @throws {ZopiaError} `ZOPIA_CONFIG_INVALID` when `apiDocs` is not the nested tree object (a non-object input or a non-tree value inside it).
289
295
  * @throws {ZopiaError} `ZOPIA_SPEC_INVALID` when a leaf without a usable `operationId` cannot be named (its `pathShape` is not an OpenAPI `/`-rooted template or its `method` is not one of the eight standard methods).
290
296
  * @example
291
297
  * ```ts
292
- * import { createApiDocs, flattenApiDocs } from './src/runtime';
298
+ * import { createApiDocs, flattenApiDocs, type ApiDocsEndpointConfig } from './src/runtime';
293
299
  *
294
- * const endpoints = flattenApiDocs(await createApiDocs('api_docs'));
300
+ * // Exact typing: pass the generated `.zopia-tree.d.ts` flat type.
301
+ * type MyFlat = { readonly getUser: ApiDocsEndpointConfig };
302
+ * const endpoints = flattenApiDocs<MyFlat>(await createApiDocs('api_docs'));
295
303
  * console.log(endpoints.getUser.pathShape);
296
304
  * ```
297
305
  * @see [docs/07-api-docs.md โ†’ Runtime tree consumption](../../docs/07-api-docs.md)
298
306
  */
299
- export function flattenApiDocs(apiDocs: ApiDocsTree): Record<string, ApiDocsEndpointConfig> {
307
+ export function flattenApiDocs<TFlat extends Record<string, ApiDocsEndpointConfig> = Record<string, ApiDocsEndpointConfig>>(apiDocs: object): TFlat {
300
308
  if (!isRecord(apiDocs)) {
301
309
  throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'flattenApiDocs expects the tree object returned by createApiDocs', { at: 'apiDocs', hint: 'pass the createApiDocs result (a nested object of endpoint configs)' });
302
310
  }
@@ -317,5 +325,5 @@ export function flattenApiDocs(apiDocs: ApiDocsTree): Record<string, ApiDocsEndp
317
325
  }
318
326
  };
319
327
  walk(apiDocs);
320
- return flat;
328
+ return flat as unknown as TFlat;
321
329
  }