zopia 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +354 -0
  2. package/LICENSE +21 -0
  3. package/README.md +167 -0
  4. package/bin/zopia.js +20 -0
  5. package/docs/01-overview.md +94 -0
  6. package/docs/02-targets.md +55 -0
  7. package/docs/03-roadmap.md +205 -0
  8. package/docs/04-architecture.md +345 -0
  9. package/docs/05-concepts.md +239 -0
  10. package/docs/06-conversions.md +493 -0
  11. package/docs/07-api-docs.md +337 -0
  12. package/docs/08-components.md +223 -0
  13. package/docs/09-configuration.md +167 -0
  14. package/docs/10-usage.md +208 -0
  15. package/docs/11-testing.md +267 -0
  16. package/docs/12-standards.md +242 -0
  17. package/docs/README.md +42 -0
  18. package/docs/publish-workflow.yml.example +48 -0
  19. package/package.json +77 -0
  20. package/src/api-docs-navigation.ts +353 -0
  21. package/src/cli-command.ts +537 -0
  22. package/src/cli.ts +4 -0
  23. package/src/config.ts +190 -0
  24. package/src/conversions/api-docs-facade.ts +42 -0
  25. package/src/conversions/api-docs-generate.ts +567 -0
  26. package/src/conversions/api-docs-layout.ts +39 -0
  27. package/src/conversions/api-docs-plan.ts +130 -0
  28. package/src/conversions/api-docs-presets.ts +246 -0
  29. package/src/conversions/json-schema-to-zod.ts +931 -0
  30. package/src/conversions/manifest-staleness.ts +211 -0
  31. package/src/conversions/manifest-to-openapi.ts +1861 -0
  32. package/src/conversions/manifest-writer.ts +778 -0
  33. package/src/conversions/openapi-contracts.ts +333 -0
  34. package/src/conversions/openapi-external-ref.ts +233 -0
  35. package/src/conversions/openapi-ir.ts +74 -0
  36. package/src/conversions/openapi-ref.ts +38 -0
  37. package/src/conversions/openapi-to-api-docs-public.ts +466 -0
  38. package/src/conversions/openapi-to-api-docs.ts +203 -0
  39. package/src/conversions/openapi.ts +80 -0
  40. package/src/conversions/reverse-security.ts +68 -0
  41. package/src/conversions/yaml.ts +876 -0
  42. package/src/conversions/zod-to-json-schema.ts +536 -0
  43. package/src/diff.ts +353 -0
  44. package/src/errors.ts +114 -0
  45. package/src/index.ts +80 -0
  46. package/src/validation.ts +299 -0
  47. package/src/warnings.ts +164 -0
@@ -0,0 +1,778 @@
1
+ import { deriveReusableParameterSchema, deriveReusableResponseSchema, reusableDeclarations } from './openapi-contracts';
2
+ import { asZopiaError, ZopiaError } from '../errors';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { isAbsolute, join, resolve } from 'node:path';
6
+ import { jsonSchemaToZod, type JsonSchemaOverlay } from './json-schema-to-zod';
7
+ import type { ApiDocsMode } from './api-docs-layout';
8
+ import type { ApiDocsFilePlan } from './api-docs-plan';
9
+ import type { OpenApiDocument } from './openapi';
10
+ import { decodeJsonPointerSegment } from './openapi-ref';
11
+
12
+ /** Versioned schema identifier written into every zopia manifest. */
13
+ export const ZOPIA_MANIFEST_SCHEMA = 'zopia:manifest@1' as const;
14
+
15
+ /** Portable manifest filename used by generated api-docs trees. */
16
+ export const ZOPIA_MANIFEST_FILE = '.zopia-manifest.json' as const;
17
+
18
+ /** Package version recorded by the current manifest writer. */
19
+ export const ZOPIA_VERSION = '0.3.0' as const;
20
+
21
+ /** Supported source dialect labels stored in a manifest. */
22
+ export type ZopiaManifestSourceKind = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
23
+
24
+ /** Source identity and human-readable document metadata. */
25
+ export interface ZopiaManifestSource {
26
+ /** Source dialect. Writers emit a `ZopiaManifestSourceKind`; readers retain forward compatibility. */
27
+ kind: ZopiaManifestSourceKind | (string & {});
28
+ /** Exact source `openapi` value; absent for Swagger and legacy manifests. */
29
+ openapiVersion?: string;
30
+ /** Original API title. Optional only for legacy reader fallback compatibility. */
31
+ title?: string;
32
+ /** Original API version. Optional only for legacy reader fallback compatibility. */
33
+ version?: string;
34
+ /** Original API description, when present. */
35
+ description?: string;
36
+ /** Canonical SHA-256 digest of the complete source document. */
37
+ sha256?: string;
38
+ }
39
+
40
+ /** Generation options needed to interpret the emitted tree. */
41
+ export interface ZopiaManifestGenerationOptions {
42
+ /** Whether component modules were emitted. */
43
+ insertComponents: boolean;
44
+ /** Whether endpoint modules import emitted components. */
45
+ useComponentAsReference: boolean;
46
+ /** Whether endpoint custom companion modules were requested. @default false */
47
+ custom?: boolean;
48
+ }
49
+
50
+ /** Original reference placement relative to one source operation. */
51
+ export interface ZopiaManifestRef {
52
+ /** RFC 6901 pointer to the source `$ref` keyword. */
53
+ at: string;
54
+ /** Original local reference value. */
55
+ ref: string;
56
+ /** Referenced schema component name when the target is a schema component. */
57
+ component?: string;
58
+ }
59
+
60
+ /** Schema or operation restoration retained outside generated Zod code. */
61
+ export type ZopiaManifestOverlay = JsonSchemaOverlay | {
62
+ /** Operation field restored from the source snapshot. */
63
+ key: 'callbacks' | 'servers' | 'externalDocs' | 'links';
64
+ /** Complete original field value. */
65
+ value: unknown;
66
+ };
67
+
68
+ /** Response facts that km-api cannot store directly. */
69
+ export interface ZopiaManifestResponseOverlay {
70
+ /** Response status key. */
71
+ status: string;
72
+ /** Original response headers. */
73
+ headers: unknown;
74
+ }
75
+
76
+ /** One declared source schema component. */
77
+ export interface ZopiaManifestComponent {
78
+ /** Exact source component name. */
79
+ name: string;
80
+ /** Component kind: schema module, reusable parameter module, or reusable response module (D-18). Absent means `schema` (legacy manifests). */
81
+ kind?: 'schema' | 'parameter' | 'response';
82
+ /** Emitted module path, or `null` when components were not emitted. Optional only for legacy reader compatibility. */
83
+ file?: string | null;
84
+ /** Complete original component schema. */
85
+ schema: unknown;
86
+ /** Schema-local reverse restorations. Readers also accept legacy overlay shapes. */
87
+ overlay?: unknown;
88
+ }
89
+
90
+ /** One generated endpoint and its reverse-conversion metadata. */
91
+ export interface ZopiaManifestApi {
92
+ /** Generated endpoint module path. Optional only for legacy reader compatibility. */
93
+ file?: string;
94
+ /** Original OpenAPI path template. */
95
+ path: string;
96
+ /** Lowercase HTTP method. */
97
+ method: string;
98
+ /** Explicit or deterministically derived operation ID. */
99
+ operationId?: string;
100
+ /** Complete original operation object. */
101
+ sourceOperation?: Record<string, unknown>;
102
+ /** Whether the operation was inherited exclusively through a path-item `$ref`. */
103
+ pathItemRef?: boolean;
104
+ /** Source reference placements. Readers also accept legacy representations. */
105
+ refs?: unknown;
106
+ /** Schema and operation restorations. Readers also accept legacy representations. */
107
+ overlay?: unknown;
108
+ /** Response metadata without a km-api representation. Readers also accept legacy representations. */
109
+ responseOverlay?: unknown;
110
+ /** Explicit operation security requirements, including an empty list. */
111
+ security?: unknown[];
112
+ }
113
+
114
+ /** One generated webhook endpoint and its reverse-conversion metadata (OpenAPI 3.1). */
115
+ export interface ZopiaManifestWebhookApi {
116
+ /** Generated endpoint module path. Optional only for legacy reader compatibility. */
117
+ file?: string;
118
+ /** Original OpenAPI webhook name (not a URL path template). */
119
+ name: string;
120
+ /** Lowercase HTTP method. */
121
+ method: string;
122
+ /** Explicit or deterministically derived operation ID. */
123
+ operationId?: string;
124
+ /** Whether the operation was inherited exclusively through a webhook-item `$ref`. */
125
+ webhookItemRef?: boolean;
126
+ /** Complete original operation object. */
127
+ sourceOperation?: Record<string, unknown>;
128
+ /** Source reference placements. Readers also accept legacy representations. */
129
+ refs?: unknown;
130
+ /** Schema and operation restorations. Readers also accept legacy representations. */
131
+ overlay?: unknown;
132
+ /** Response metadata without a km-api representation. Readers also accept legacy representations. */
133
+ responseOverlay?: unknown;
134
+ /** Explicit operation security requirements, including an empty list. */
135
+ security?: unknown[];
136
+ }
137
+
138
+ /** Versioned manifest consumed by reverse conversion. Optional fields preserve older manifests. */
139
+ export interface ZopiaManifest {
140
+ /** Manifest schema identifier. */
141
+ $schema?: string;
142
+ /** Writer package version. */
143
+ zopiaVersion?: string;
144
+ /** Source document identity. */
145
+ source: ZopiaManifestSource;
146
+ /** Generated endpoint layout. */
147
+ mode?: string;
148
+ /** Generation options that affect emitted references. */
149
+ options?: ZopiaManifestGenerationOptions;
150
+ /** Source `paths` key order, including empty Path Items and extensions. */
151
+ pathOrder?: string[];
152
+ /** Whether the source explicitly declared `definitions` or `components.schemas`. */
153
+ schemaComponentsPresent?: boolean;
154
+ /** Non-canonical source `info` fields. */
155
+ infoOverlay?: Record<string, unknown>;
156
+ /** Document-level extensions and unsupported structures. */
157
+ documentOverlay?: Record<string, unknown>;
158
+ /** Path-item metadata and `paths` extensions not represented by endpoint modules. */
159
+ pathsOverlay?: Record<string, unknown>;
160
+ /** Non-schema OpenAPI component sections. */
161
+ componentsOverlay?: Record<string, unknown>;
162
+ /** Original server declarations. */
163
+ servers?: unknown[];
164
+ /** Swagger host. */
165
+ swaggerHost?: string;
166
+ /** Swagger schemes. */
167
+ swaggerSchemes?: string[];
168
+ /** Swagger request media types. */
169
+ swaggerConsumes?: string[];
170
+ /** Swagger response media types. */
171
+ swaggerProduces?: string[];
172
+ /** Internal marker set when the manifest was dialect-downgraded from OpenAPI 3.x to Swagger 2.0 during reverse conversion; never persisted. */
173
+ dialectDowngraded?: true;
174
+ /** Swagger reusable parameters. */
175
+ swaggerParameters?: Record<string, unknown>;
176
+ /** Swagger reusable responses. */
177
+ swaggerResponses?: Record<string, unknown>;
178
+ /** Original tag declarations. */
179
+ tags?: unknown[];
180
+ /** Original security scheme declarations. */
181
+ securitySchemes?: Record<string, unknown>;
182
+ /** Top-level security requirements. */
183
+ defaultSecurity?: unknown[];
184
+ /** Declared schema components. */
185
+ components?: ZopiaManifestComponent[];
186
+ /** Generated endpoint records. */
187
+ apis: ZopiaManifestApi[];
188
+ /** Generated webhook endpoint records (OpenAPI 3.1). Omitted when the source has no webhooks. */
189
+ webhooks?: ZopiaManifestWebhookApi[];
190
+ /** Exact source `webhooks` map key order. Present alongside `webhooks`. */
191
+ webhookOrder?: string[];
192
+ /** Webhook-item metadata retained outside endpoint code (`parameters`, `x-` keys, extension names). */
193
+ webhooksOverlay?: Record<string, unknown>;
194
+ }
195
+
196
+ /** Current writer component shape (legacy manifests may omit writer-owned fields). */
197
+ export interface GeneratedZopiaManifestComponent extends ZopiaManifestComponent {
198
+ /** Emitted component path, or `null` when component emission is disabled. */
199
+ file: string | null;
200
+ /** Canonical component schema restorations. */
201
+ overlay: JsonSchemaOverlay[];
202
+ }
203
+
204
+ /** Current writer endpoint shape (legacy manifests may omit writer-owned fields). */
205
+ export interface GeneratedZopiaManifestApi extends ZopiaManifestApi {
206
+ /** Portable generated endpoint path. */
207
+ file: string;
208
+ /** Explicit or deterministically derived operation identifier. */
209
+ operationId: string;
210
+ /** Complete original operation object. */
211
+ sourceOperation: Record<string, unknown>;
212
+ /** Canonical source reference placements. */
213
+ refs: ZopiaManifestRef[];
214
+ /** Canonical schema and operation restorations. */
215
+ overlay: ZopiaManifestOverlay[];
216
+ /** Canonical response metadata restorations. */
217
+ responseOverlay: ZopiaManifestResponseOverlay[];
218
+ }
219
+
220
+ /** Current writer webhook endpoint shape (legacy manifests may omit writer-owned fields). */
221
+ export interface GeneratedZopiaManifestWebhookApi extends ZopiaManifestWebhookApi {
222
+ /** Portable generated endpoint path. */
223
+ file: string;
224
+ /** Explicit or deterministically derived operation identifier. */
225
+ operationId: string;
226
+ /** Complete original operation object. */
227
+ sourceOperation: Record<string, unknown>;
228
+ /** Canonical source reference placements. */
229
+ refs: ZopiaManifestRef[];
230
+ /** Canonical schema and operation restorations. */
231
+ overlay: ZopiaManifestOverlay[];
232
+ /** Canonical response metadata restorations. */
233
+ responseOverlay: ZopiaManifestResponseOverlay[];
234
+ }
235
+
236
+ /** Complete manifest shape emitted by the current dedicated writer. */
237
+ export interface GeneratedZopiaManifest extends ZopiaManifest {
238
+ /** Current manifest schema identifier. */
239
+ $schema: typeof ZOPIA_MANIFEST_SCHEMA;
240
+ /** Current writer package version. */
241
+ zopiaVersion: typeof ZOPIA_VERSION;
242
+ /** Complete canonical source identity. */
243
+ source: ZopiaManifestSource & {
244
+ /** Supported canonical source dialect. */
245
+ kind: ZopiaManifestSourceKind;
246
+ /** Required original API title. */
247
+ title: string;
248
+ /** Required original API version. */
249
+ version: string;
250
+ /** Canonical SHA-256 digest of the complete source document. */
251
+ sha256: string;
252
+ };
253
+ /** Generated endpoint layout. */
254
+ mode: ApiDocsMode;
255
+ /** Generation options that affect emitted modules. */
256
+ options: ZopiaManifestGenerationOptions;
257
+ /** Exact source `paths` key order. */
258
+ pathOrder: string[];
259
+ /** Whether the source explicitly declared its schema-component container. */
260
+ schemaComponentsPresent: boolean;
261
+ /** Canonical non-core `info` fields. */
262
+ infoOverlay: Record<string, unknown>;
263
+ /** Canonical document-level restorations. */
264
+ documentOverlay: Record<string, unknown>;
265
+ /** Canonical path-item restorations. */
266
+ pathsOverlay: Record<string, unknown>;
267
+ /** Original server declarations, when present. */
268
+ servers?: unknown[];
269
+ /** Original tag declarations, when present. */
270
+ tags?: unknown[];
271
+ /** Original security schemes, when present. */
272
+ securitySchemes?: Record<string, unknown>;
273
+ /** Canonical generated component records. */
274
+ components: GeneratedZopiaManifestComponent[];
275
+ /** Canonical generated endpoint records. */
276
+ apis: GeneratedZopiaManifestApi[];
277
+ /** Generated webhook endpoint records, present when the source declares webhooks. */
278
+ webhooks?: GeneratedZopiaManifestWebhookApi[];
279
+ }
280
+
281
+ /** Options used by the pure manifest builder. */
282
+ export interface CreateZopiaManifestOptions {
283
+ /** Endpoint layout mode. */
284
+ mode: ApiDocsMode;
285
+ /** Whether component files were emitted. */
286
+ insertComponents: boolean;
287
+ /** Whether endpoint modules import component files. */
288
+ useComponentAsReference: boolean;
289
+ /** Whether endpoint custom companion modules were requested. @default false */
290
+ custom?: boolean;
291
+ }
292
+
293
+ const isRecord = (value: unknown): value is Record<string, any> => value !== null && typeof value === 'object' && !Array.isArray(value);
294
+ const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0;
295
+ const pointerToken = (value: string): string => value.replace(/~/g, '~0').replace(/\//g, '~1');
296
+
297
+ function stableJson(value: unknown, stack = new Set<object>()): string {
298
+ if (Array.isArray(value)) {
299
+ if (stack.has(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a circular JSON value');
300
+ const keys = Object.keys(value);
301
+ if (keys.length !== value.length || keys.some((key, index) => key !== String(index))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a non-JSON array');
302
+ stack.add(value);
303
+ const output = `[${value.map((item) => stableJson(item, stack)).join(',')}]`;
304
+ stack.delete(value);
305
+ return output;
306
+ }
307
+ if (value && typeof value === 'object') {
308
+ const prototype = Object.getPrototypeOf(value);
309
+ if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(value).length > 0) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a non-JSON object');
310
+ if (stack.has(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a circular JSON value');
311
+ stack.add(value);
312
+ const object = value as Record<string, unknown>;
313
+ const output = `{${Object.keys(object).sort(compareText).map((key) => `${JSON.stringify(key)}:${stableJson(object[key], stack)}`).join(',')}}`;
314
+ stack.delete(value);
315
+ return output;
316
+ }
317
+ if (typeof value === 'number' && !Number.isFinite(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Cannot serialize a non-JSON number: ${String(value)}`);
318
+ const output = JSON.stringify(value);
319
+ if (output === undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Cannot serialize a non-JSON value: ${String(value)}`);
320
+ return output;
321
+ }
322
+
323
+ function canonicalValue(value: unknown, stack = new Set<object>()): unknown {
324
+ if (Array.isArray(value)) {
325
+ if (stack.has(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a circular JSON value');
326
+ stack.add(value);
327
+ const output = value.map((item) => canonicalValue(item, stack));
328
+ stack.delete(value);
329
+ return output;
330
+ }
331
+ if (value && typeof value === 'object') {
332
+ if (stack.has(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Cannot serialize a circular JSON value');
333
+ stack.add(value);
334
+ const output: Record<string, unknown> = {};
335
+ for (const key of Object.keys(value as Record<string, unknown>).sort(compareText)) Object.defineProperty(output, key, { value: canonicalValue((value as Record<string, unknown>)[key], stack), enumerable: true, configurable: true, writable: true });
336
+ stack.delete(value);
337
+ return output;
338
+ }
339
+ if (JSON.stringify(value) === undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Cannot serialize a non-JSON value: ${String(value)}`);
340
+ return value;
341
+ }
342
+
343
+ function cloneJson<T>(value: T): T {
344
+ return JSON.parse(stableJson(value)) as T;
345
+ }
346
+
347
+ function sortDerivedRecords<T>(values: readonly T[]): T[] {
348
+ return [...values].sort((left, right) => compareText(stableJson(left), stableJson(right)));
349
+ }
350
+
351
+ function componentName(ref: string): string | undefined {
352
+ const prefix = ref.startsWith('#/components/schemas/') ? '#/components/schemas/' : ref.startsWith('#/definitions/') ? '#/definitions/' : undefined;
353
+ if (!prefix) return undefined;
354
+ const encoded = ref.slice(prefix.length);
355
+ if (encoded.includes('/')) return undefined;
356
+ try { return decodeJsonPointerSegment(encoded, ref); }
357
+ catch { return undefined; }
358
+ }
359
+
360
+ const STRUCTURAL_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions', 'responses', 'content', 'headers', 'links', 'encoding', 'callbacks']);
361
+
362
+ function collectRefs(value: unknown, at = '', mapEntries = false): ZopiaManifestRef[] {
363
+ const refs: ZopiaManifestRef[] = [];
364
+ if (Array.isArray(value)) value.forEach((child, index) => refs.push(...collectRefs(child, `${at}/${index}`)));
365
+ else if (isRecord(value)) {
366
+ const literals = new Set(['example', 'examples', 'default', 'enum', 'const']);
367
+ for (const [key, child] of Object.entries(value)) {
368
+ const location = `${at}/${pointerToken(key)}`;
369
+ if (mapEntries) refs.push(...collectRefs(child, location));
370
+ else if (literals.has(key) || key.startsWith('x-')) continue;
371
+ else if (key === '$ref' && typeof child === 'string') {
372
+ const component = componentName(child);
373
+ refs.push({ at: location, ref: child, ...(component === undefined ? {} : { component }) });
374
+ } else refs.push(...collectRefs(child, location, STRUCTURAL_MAP_KEYS.has(key)));
375
+ }
376
+ }
377
+ return refs;
378
+ }
379
+
380
+ function manifestSchemaOverlays(schema: unknown): JsonSchemaOverlay[] {
381
+ return jsonSchemaToZod(schema as any).overlays.filter((overlay) => !(Object.prototype.hasOwnProperty.call(overlay, 'node') && typeof overlay.node === 'boolean'));
382
+ }
383
+
384
+ function prefixedSchemaOverlays(schema: unknown, at: string): JsonSchemaOverlay[] {
385
+ if (schema === undefined || schema === null) return [];
386
+ return manifestSchemaOverlays(schema).map((overlay) => ({ ...overlay, at: `${at}${overlay.at}` }));
387
+ }
388
+
389
+ const SWAGGER_SCHEMA_KEYS = new Set(['type', 'format', 'items', 'collectionFormat', 'default', 'maximum', 'exclusiveMaximum', 'minimum', 'exclusiveMinimum', 'maxLength', 'minLength', 'pattern', 'maxItems', 'minItems', 'uniqueItems', 'enum', 'multipleOf']);
390
+
391
+ function collectOperationSchemaOverlays(value: unknown, swagger: boolean, at = ''): JsonSchemaOverlay[] {
392
+ if (Array.isArray(value)) return value.flatMap((child, index) => collectOperationSchemaOverlays(child, swagger, `${at}/${index}`));
393
+ if (!isRecord(value)) return [];
394
+ const overlays: JsonSchemaOverlay[] = [];
395
+ if (swagger && typeof value.in === 'string' && typeof value.name === 'string' && value.in !== 'body' && [...SWAGGER_SCHEMA_KEYS].some((key) => Object.prototype.hasOwnProperty.call(value, key))) {
396
+ overlays.push(...prefixedSchemaOverlays(Object.fromEntries(Object.entries(value).filter(([key]) => SWAGGER_SCHEMA_KEYS.has(key))), at));
397
+ }
398
+ const literalContainers = new Set(['example', 'examples', 'default', 'enum', 'const', 'x-example']);
399
+ for (const [key, child] of Object.entries(value)) {
400
+ if (literalContainers.has(key) || key.startsWith('x-')) continue;
401
+ const childAt = `${at}/${pointerToken(key)}`;
402
+ if (key === 'schema' && (typeof child === 'boolean' || isRecord(child))) overlays.push(...prefixedSchemaOverlays(child, childAt));
403
+ else overlays.push(...collectOperationSchemaOverlays(child, swagger, childAt));
404
+ }
405
+ return overlays;
406
+ }
407
+
408
+ function isPortableManifestPath(file: string): boolean {
409
+ if (!file || isAbsolute(file) || file.includes('\\') || /^[A-Za-z]:/.test(file)) return false;
410
+ return file.split('/').every((segment) => {
411
+ if (segment === '' || segment === '.' || segment === '..' || /[<>:"|?*\u0000-\u001f]/.test(segment) || /[ .]$/.test(segment)) return false;
412
+ const basename = segment.split('.')[0];
413
+ return !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
414
+ });
415
+ }
416
+
417
+ function validatePointer(value: string, context: string): void {
418
+ if (value !== '' && !value.startsWith('/')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid ${context} pointer: ${value}`);
419
+ if (/~(?![01])/.test(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid ${context} pointer escape: ${value}`);
420
+ }
421
+
422
+ function validateKeys(value: object, allowed: readonly string[], context: string): void {
423
+ const invalid = Object.keys(value).find((key) => !allowed.includes(key));
424
+ if (invalid !== undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${context} key: ${invalid}`);
425
+ }
426
+
427
+ /**
428
+ * Compute the canonical SHA-256 identity used for manifest staleness checks.
429
+ *
430
+ * @param document JSON-compatible Swagger/OpenAPI source document.
431
+ * @returns Lowercase hexadecimal SHA-256 digest of the canonical document.
432
+ * @throws {@link ZopiaError} when the document contains non-JSON or circular values.
433
+ */
434
+ export function hashOpenApiDocument(document: OpenApiDocument): string {
435
+ try {
436
+ return createHash('sha256').update(stableJson(document)).digest('hex');
437
+ } catch (error) {
438
+ const message = error instanceof Error ? error.message : String(error);
439
+ if (message.includes('circular JSON value')) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'cannot hash a circular OpenAPI document', { at: '#', hint: 'use JSON references instead of object cycles', cause: error });
440
+ if (message.includes('non-JSON')) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'cannot hash an unsupported OpenAPI value', { at: '#', hint: 'replace functions, symbols, and non-finite numbers with JSON values', cause: error });
441
+ throw asZopiaError(error, 'ZOPIA_SPEC_INVALID', 'unable to hash OpenAPI document', { at: '#', hint: 'provide a JSON-compatible document' });
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Build a detached, deterministic manifest snapshot without touching the filesystem.
447
+ *
448
+ * @param source Normalized Swagger/OpenAPI source document.
449
+ * @param plans Planned endpoint files represented by the source document.
450
+ * @param options Layout and component-generation settings to record.
451
+ * @param webhookPlans Planned webhook endpoint files represented by the source `webhooks` map.
452
+ * @returns Validated canonical current-writer manifest.
453
+ * @throws {@link ZopiaError} when the source, plans, or options are invalid.
454
+ */
455
+ export function createZopiaManifest(source: OpenApiDocument, plans: readonly ApiDocsFilePlan[], options: CreateZopiaManifestOptions, webhookPlans: readonly ApiDocsFilePlan[] = []): GeneratedZopiaManifest {
456
+ if (!isRecord(source) || !isRecord(source.info) || !isRecord(source.paths)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid manifest source document', { at: '#', hint: 'provide a normalized Swagger/OpenAPI document' });
457
+ if (!isRecord(options) || !['directory', 'flat'].includes(options.mode) || typeof options.insertComponents !== 'boolean' || typeof options.useComponentAsReference !== 'boolean' || (options.custom !== undefined && typeof options.custom !== 'boolean')) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'Invalid manifest generation options', { at: 'options' });
458
+ if (options.useComponentAsReference && !options.insertComponents) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'useComponentAsReference requires insertComponents', { at: 'useComponentAsReference', hint: 'enable `insertComponents` first' });
459
+ const sourceHash = hashOpenApiDocument(source);
460
+ const swagger = source.swagger === '2.0';
461
+ const hasOwn = (key: string): boolean => Object.prototype.hasOwnProperty.call(source, key);
462
+ const schemas = swagger ? source.definitions ?? {} : source.components?.schemas ?? {};
463
+ if (!isRecord(schemas)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'Invalid source schema components', { at: swagger ? '#/definitions' : '#/components/schemas' });
464
+ const sortedPlans = [...plans].sort((left, right) => compareText(left.file, right.file));
465
+ const components: GeneratedZopiaManifestComponent[] = Object.entries(schemas).sort(([left], [right]) => compareText(left, right)).map(([name, schema]) => ({
466
+ name,
467
+ file: options.insertComponents ? `components/${name}/index.ts` : null,
468
+ schema: cloneJson(schema),
469
+ overlay: cloneJson(sortDerivedRecords(manifestSchemaOverlays(schema))),
470
+ }));
471
+ if (options.insertComponents) {
472
+ const declared = reusableDeclarations(source);
473
+ for (const [kind, map, derive] of [['parameter', declared.parameter, deriveReusableParameterSchema], ['response', declared.response, deriveReusableResponseSchema]] as const) {
474
+ components.push(...Object.entries(map).sort(([left], [right]) => compareText(left, right)).flatMap(([name, declaration]) => {
475
+ const schema = derive(source, name, declaration);
476
+ // Schema-less responses render `z.void()` at use sites and carry no reusable schema (D-18).
477
+ if (kind === 'response' && schema === undefined) return [];
478
+ return [{ name, kind, file: `components/${kind}s/${name}/index.ts`, schema: cloneJson(schema), overlay: cloneJson(sortDerivedRecords(manifestSchemaOverlays(schema))) }];
479
+ }));
480
+ }
481
+ }
482
+ const apis: GeneratedZopiaManifestApi[] = sortedPlans.map((plan) => ({
483
+ file: plan.file,
484
+ path: plan.path,
485
+ method: plan.method,
486
+ operationId: plan.operationId,
487
+ sourceOperation: cloneJson(plan.operation),
488
+ ...(isRecord(source.paths?.[plan.path]) && typeof source.paths[plan.path].$ref === 'string' && !Object.prototype.hasOwnProperty.call(source.paths[plan.path], plan.method) ? { pathItemRef: true } : {}),
489
+ refs: sortDerivedRecords(collectRefs(plan.operation)),
490
+ overlay: cloneJson(sortDerivedRecords([
491
+ ...collectOperationSchemaOverlays(plan.operation, swagger),
492
+ ...(['callbacks', 'servers', 'externalDocs', 'links'] as const).filter((key) => Object.prototype.hasOwnProperty.call(plan.operation, key)).map((key) => ({ key, value: plan.operation[key] })),
493
+ ])),
494
+ responseOverlay: cloneJson(sortDerivedRecords(Object.entries(plan.operation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []))),
495
+ ...(Object.prototype.hasOwnProperty.call(plan.operation, 'security') ? { security: cloneJson(plan.operation.security) as unknown[] } : {}),
496
+ }));
497
+ const webhookNames = isRecord(source.webhooks) ? Object.keys(source.webhooks) : [];
498
+ const webhooks: GeneratedZopiaManifestWebhookApi[] = [...webhookPlans].sort((left, right) => compareText(left.file, right.file)).map((plan) => ({
499
+ file: plan.file,
500
+ name: plan.path,
501
+ method: plan.method,
502
+ operationId: plan.operationId,
503
+ sourceOperation: cloneJson(plan.operation),
504
+ ...(isRecord(source.webhooks?.[plan.path]) && typeof source.webhooks[plan.path].$ref === 'string' && !Object.prototype.hasOwnProperty.call(source.webhooks[plan.path], plan.method) ? { webhookItemRef: true } : {}),
505
+ refs: sortDerivedRecords(collectRefs(plan.operation)),
506
+ overlay: cloneJson(sortDerivedRecords([
507
+ ...collectOperationSchemaOverlays(plan.operation, swagger),
508
+ ...(['callbacks', 'servers', 'externalDocs', 'links'] as const).filter((key) => Object.prototype.hasOwnProperty.call(plan.operation, key)).map((key) => ({ key, value: plan.operation[key] })),
509
+ ])),
510
+ responseOverlay: cloneJson(sortDerivedRecords(Object.entries(plan.operation.responses ?? {}).flatMap(([status, response]) => isRecord(response) && response.headers !== undefined ? [{ status, headers: response.headers }] : []))),
511
+ ...(Object.prototype.hasOwnProperty.call(plan.operation, 'security') ? { security: cloneJson(plan.operation.security) as unknown[] } : {}),
512
+ }));
513
+ const manifest: GeneratedZopiaManifest = {
514
+ $schema: ZOPIA_MANIFEST_SCHEMA,
515
+ zopiaVersion: ZOPIA_VERSION,
516
+ mode: options.mode,
517
+ options: { insertComponents: options.insertComponents, useComponentAsReference: options.useComponentAsReference, ...(options.custom === true ? { custom: true } : {}) },
518
+ pathOrder: Object.keys(source.paths),
519
+ schemaComponentsPresent: swagger
520
+ ? Object.prototype.hasOwnProperty.call(source, 'definitions')
521
+ : isRecord(source.components) && Object.prototype.hasOwnProperty.call(source.components, 'schemas'),
522
+ source: {
523
+ kind: swagger ? 'swagger-2.0' : typeof source.openapi === 'string' && /^3\.0/.test(source.openapi) ? 'openapi-3.0' : 'openapi-3.1',
524
+ ...(swagger ? {} : { openapiVersion: source.openapi }),
525
+ title: source.info.title,
526
+ version: source.info.version,
527
+ ...(source.info.description === undefined ? {} : { description: cloneJson(source.info.description) }),
528
+ sha256: sourceHash,
529
+ },
530
+ infoOverlay: cloneJson(Object.fromEntries(Object.entries(source.info).filter(([key]) => !['title', 'version', 'description'].includes(key)))),
531
+ documentOverlay: cloneJson(Object.fromEntries(Object.entries(source).filter(([key]) => key === 'externalDocs' || (key === 'webhooks' && webhookNames.length === 0) || key === 'jsonSchemaDialect' || key.startsWith('x-')))),
532
+ pathsOverlay: cloneJson(Object.fromEntries(Object.entries(source.paths ?? {}).flatMap(([path, item]) => {
533
+ if (path.startsWith('x-')) return [[path, item]];
534
+ if (!isRecord(item)) return [];
535
+ const metadata = Object.fromEntries(Object.entries(item).filter(([key]) => !['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(key)));
536
+ return Object.keys(metadata).length ? [[path, metadata]] : [];
537
+ }))),
538
+ ...(swagger
539
+ ? hasOwn('basePath') ? { servers: [cloneJson(source.basePath)] } : {}
540
+ : hasOwn('servers') ? { servers: cloneJson(source.servers) } : {}),
541
+ ...(swagger ? {
542
+ ...(source.host === undefined ? {} : { swaggerHost: cloneJson(source.host) }),
543
+ ...(source.schemes === undefined ? {} : { swaggerSchemes: cloneJson(source.schemes) }),
544
+ ...(source.consumes === undefined ? {} : { swaggerConsumes: cloneJson(source.consumes) }),
545
+ ...(source.produces === undefined ? {} : { swaggerProduces: cloneJson(source.produces) }),
546
+ ...(source.parameters === undefined ? {} : { swaggerParameters: cloneJson(source.parameters) }),
547
+ ...(source.responses === undefined ? {} : { swaggerResponses: cloneJson(source.responses) }),
548
+ } : {
549
+ ...(hasOwn('components') ? { componentsOverlay: cloneJson(Object.fromEntries(Object.entries(source.components ?? {}).filter(([key]) => key !== 'schemas' && key !== 'securitySchemes'))) } : {}),
550
+ }),
551
+ ...(hasOwn('tags') ? { tags: cloneJson(source.tags) } : {}),
552
+ ...(swagger
553
+ ? hasOwn('securityDefinitions') ? { securitySchemes: cloneJson(source.securityDefinitions) } : {}
554
+ : isRecord(source.components) && Object.prototype.hasOwnProperty.call(source.components, 'securitySchemes') ? { securitySchemes: cloneJson(source.components.securitySchemes) } : {}),
555
+ ...(Object.prototype.hasOwnProperty.call(source, 'security') ? { defaultSecurity: cloneJson(source.security) } : {}),
556
+ components,
557
+ apis,
558
+ ...(webhookNames.length ? { webhookOrder: webhookNames } : {}),
559
+ ...(isRecord(source.webhooks) ? { webhooksOverlay: cloneJson(Object.fromEntries(webhookNames.flatMap((name) => {
560
+ const item = (source.webhooks as Record<string, unknown>)[name];
561
+ if (!isRecord(item)) return [];
562
+ const metadata = Object.fromEntries(Object.entries(item).filter(([key]) => !['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(key)));
563
+ return Object.keys(metadata).length ? [[name, metadata]] : [];
564
+ }))) } : {}),
565
+ ...(webhooks.length ? { webhooks } : {}),
566
+ };
567
+ validateZopiaManifest(manifest);
568
+ return manifest;
569
+ }
570
+
571
+ function validateSecurityRequirements(value: unknown, context: string): void {
572
+ if (!Array.isArray(value) || value.some((alternative) => !isRecord(alternative)
573
+ || Object.entries(alternative).some(([name, scopes]) => !name || !Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string')))) {
574
+ throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${context}`);
575
+ }
576
+ }
577
+
578
+ function validateSchemaOverlay(overlay: unknown, context: string): void {
579
+ if (!isRecord(overlay) || typeof overlay.at !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${context}`);
580
+ validateKeys(overlay, ['at', 'set', 'remove', 'node'], context);
581
+ validatePointer(overlay.at, context);
582
+ if (overlay.set !== undefined && !isRecord(overlay.set)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${context} set`);
583
+ if (overlay.remove !== undefined && (!Array.isArray(overlay.remove) || overlay.remove.some((key: unknown) => typeof key !== 'string'))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${context} remove`);
584
+ if (!Object.prototype.hasOwnProperty.call(overlay, 'node') && overlay.set === undefined && overlay.remove === undefined) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Empty zopia manifest ${context}`);
585
+ }
586
+
587
+ /**
588
+ * Validate the writer-owned manifest contract before serialization or disk output.
589
+ *
590
+ * @param manifest Candidate manifest to validate in place.
591
+ * @returns Nothing; success narrows `manifest` to the current writer shape.
592
+ * @throws {@link ZopiaError} when any manifest field violates the contract.
593
+ */
594
+ export function validateZopiaManifest(manifest: ZopiaManifest): asserts manifest is GeneratedZopiaManifest {
595
+ if (!isRecord(manifest) || manifest.$schema !== ZOPIA_MANIFEST_SCHEMA) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest schema');
596
+ validateKeys(manifest, ['$schema', 'zopiaVersion', 'source', 'mode', 'options', 'pathOrder', 'schemaComponentsPresent', 'infoOverlay', 'documentOverlay', 'pathsOverlay', 'componentsOverlay', 'servers', 'swaggerHost', 'swaggerSchemes', 'swaggerConsumes', 'swaggerProduces', 'swaggerParameters', 'swaggerResponses', 'tags', 'securitySchemes', 'defaultSecurity', 'components', 'apis', 'webhooks', 'webhookOrder', 'webhooksOverlay'], 'root');
597
+ if (manifest.zopiaVersion !== ZOPIA_VERSION) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest writer version');
598
+ if (manifest.mode !== 'directory' && manifest.mode !== 'flat') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest mode');
599
+ if (!isRecord(manifest.options) || typeof manifest.options.insertComponents !== 'boolean' || typeof manifest.options.useComponentAsReference !== 'boolean' || manifest.options.useComponentAsReference && !manifest.options.insertComponents || (manifest.options.custom !== undefined && typeof manifest.options.custom !== 'boolean')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest generation options');
600
+ validateKeys(manifest.options, ['insertComponents', 'useComponentAsReference', 'custom'], 'options');
601
+ if (!Array.isArray(manifest.pathOrder) || manifest.pathOrder.some((path) => typeof path !== 'string' || !path.startsWith('/') && !path.startsWith('x-')) || new Set(manifest.pathOrder).size !== manifest.pathOrder.length) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest path order');
602
+ if (typeof manifest.schemaComponentsPresent !== 'boolean') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest schema-component presence');
603
+ if (!isRecord(manifest.source) || !['swagger-2.0', 'openapi-3.0', 'openapi-3.1'].includes(manifest.source.kind)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest source');
604
+ validateKeys(manifest.source, ['kind', 'openapiVersion', 'title', 'version', 'description', 'sha256'], 'source');
605
+ if (manifest.source.openapiVersion !== undefined && (manifest.source.kind === 'swagger-2.0' || typeof manifest.source.openapiVersion !== 'string' || !/^3\.[01](?:\.\d+)?$/.test(manifest.source.openapiVersion))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest source OpenAPI version');
606
+ if (typeof manifest.source.title !== 'string' || !manifest.source.title.trim() || typeof manifest.source.version !== 'string' || !manifest.source.version.trim()) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest source title or version');
607
+ if (manifest.source.description !== undefined && typeof manifest.source.description !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest source description');
608
+ if (typeof manifest.source.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(manifest.source.sha256)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest source hash');
609
+ for (const [name, value] of [['infoOverlay', manifest.infoOverlay], ['documentOverlay', manifest.documentOverlay], ['pathsOverlay', manifest.pathsOverlay]] as const) {
610
+ if (!isRecord(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${name}`);
611
+ }
612
+ if (manifest.securitySchemes !== undefined && (!isRecord(manifest.securitySchemes) || Object.entries(manifest.securitySchemes).some(([name, scheme]) => !name || !isRecord(scheme)))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest security scheme');
613
+ for (const [name, value] of [['componentsOverlay', manifest.componentsOverlay], ['swaggerParameters', manifest.swaggerParameters], ['swaggerResponses', manifest.swaggerResponses]] as const) {
614
+ if (value !== undefined && !isRecord(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${name}`);
615
+ }
616
+ const invalidInfoKey = Object.keys(manifest.infoOverlay!).find((key) => ['title', 'version', 'description'].includes(key));
617
+ if (invalidInfoKey) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest infoOverlay key: ${invalidInfoKey}`);
618
+ const invalidDocumentKey = Object.keys(manifest.documentOverlay!).find((key) => !['externalDocs', 'webhooks', 'jsonSchemaDialect'].includes(key) && !key.startsWith('x-'));
619
+ if (invalidDocumentKey) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest documentOverlay key: ${invalidDocumentKey}`);
620
+ for (const [path, metadata] of Object.entries(manifest.pathsOverlay!)) {
621
+ if (!manifest.pathOrder.includes(path)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Missing zopia manifest path-order entry: ${path}`);
622
+ if (path.startsWith('x-')) continue;
623
+ if (!path.startsWith('/') || !isRecord(metadata) || Object.keys(metadata).some((key) => ['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(key))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest pathsOverlay entry: ${path}`);
624
+ }
625
+ if (manifest.componentsOverlay && ('schemas' in manifest.componentsOverlay || 'securitySchemes' in manifest.componentsOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest componentsOverlay key');
626
+ for (const [name, value] of [['servers', manifest.servers], ['tags', manifest.tags]] as const) {
627
+ if (value !== undefined && !Array.isArray(value)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${name}`);
628
+ }
629
+ for (const [name, value] of [['swaggerSchemes', manifest.swaggerSchemes], ['swaggerConsumes', manifest.swaggerConsumes], ['swaggerProduces', manifest.swaggerProduces]] as const) {
630
+ if (value !== undefined && (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string'))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ${name}`);
631
+ }
632
+ if (manifest.swaggerHost !== undefined && typeof manifest.swaggerHost !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest swaggerHost');
633
+ if (manifest.defaultSecurity !== undefined) validateSecurityRequirements(manifest.defaultSecurity, 'default security');
634
+ if (!Array.isArray(manifest.apis)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest APIs');
635
+ if (!Array.isArray(manifest.components)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest components');
636
+ if (!manifest.schemaComponentsPresent && manifest.components.some((component) => isRecord(component) && (!component.kind || component.kind === 'schema'))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Schema components require a declared source container');
637
+
638
+ const componentNames = new Set<string>();
639
+ const files = new Set<string>();
640
+ for (const component of manifest.components) {
641
+ const kind = isRecord(component) ? component.kind : undefined;
642
+ if (!isRecord(component) || kind !== undefined && kind !== 'schema' && kind !== 'parameter' && kind !== 'response' || typeof component.name !== 'string' || !component.name) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid or duplicate zopia manifest component: ${String((component as any)?.name)}`);
643
+ const identity = `${kind === 'parameter' || kind === 'response' ? kind : 'schema'}:${component.name}`;
644
+ if (componentNames.has(identity)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid or duplicate zopia manifest component: ${component.name}`);
645
+ validateKeys(component, ['name', 'kind', 'file', 'schema', 'overlay'], 'component');
646
+ componentNames.add(identity);
647
+ if (!Object.prototype.hasOwnProperty.call(component, 'schema')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Missing zopia manifest component schema: ${component.name}`);
648
+ const kindDirectory = kind === 'parameter' ? 'components/parameters' : kind === 'response' ? 'components/responses' : 'components';
649
+ const expectedFile = manifest.options.insertComponents ? `${kindDirectory}/${component.name}/index.ts` : null;
650
+ if ((manifest.options.insertComponents && component.name.includes('/')) || component.file !== expectedFile || (component.file !== null && !isPortableManifestPath(component.file))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest component file: ${String(component.file)}`);
651
+ if (component.file !== null) {
652
+ const fileKey = component.file.toLowerCase();
653
+ if (files.has(fileKey)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate zopia manifest file: ${component.file}`);
654
+ files.add(fileKey);
655
+ }
656
+ if (!Array.isArray(component.overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest component overlay: ${component.name}`);
657
+ for (const overlay of component.overlay) validateSchemaOverlay(overlay, `component overlay: ${component.name}`);
658
+ }
659
+
660
+ const operations = new Set<string>();
661
+ for (const api of manifest.apis) {
662
+ if (!isRecord(api) || typeof api.file !== 'string' || !isPortableManifestPath(api.file) || typeof api.path !== 'string' || !api.path.startsWith('/') || typeof api.method !== 'string' || !['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(api.method)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest API: ${String((api as any)?.path)} ${String((api as any)?.method)}`);
663
+ if (!manifest.pathOrder.includes(api.path)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Missing zopia manifest path-order entry: ${api.path}`);
664
+ validateKeys(api, ['file', 'path', 'method', 'operationId', 'sourceOperation', 'pathItemRef', 'refs', 'overlay', 'responseOverlay', 'security'], 'API');
665
+ if (api.pathItemRef !== undefined && typeof api.pathItemRef !== 'boolean') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest path-item reference flag: ${api.file}`);
666
+ if (typeof api.operationId !== 'string' || !api.operationId) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest operation ID: ${api.file}`);
667
+ const operation = `${api.method}\0${api.path}`;
668
+ if (operations.has(operation)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate zopia manifest API: ${api.method.toUpperCase()} ${api.path}`);
669
+ operations.add(operation);
670
+ const fileKey = api.file.toLowerCase();
671
+ if (files.has(fileKey)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate zopia manifest file: ${api.file}`);
672
+ files.add(fileKey);
673
+ if (!isRecord(api.sourceOperation) || !Array.isArray(api.refs) || !Array.isArray(api.overlay) || !Array.isArray(api.responseOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest API metadata: ${api.file}`);
674
+ if (Object.prototype.hasOwnProperty.call(api, 'security')) validateSecurityRequirements(api.security, `API security: ${api.file}`);
675
+ for (const ref of api.refs) {
676
+ if (!isRecord(ref) || typeof ref.at !== 'string' || typeof ref.ref !== 'string' || ref.component !== undefined && typeof ref.component !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ref: ${api.file}`);
677
+ validateKeys(ref, ['at', 'ref', 'component'], 'ref');
678
+ validatePointer(ref.at, 'ref');
679
+ if (ref.ref !== '#' && !ref.ref.startsWith('#/')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ref target: ${ref.ref}`);
680
+ }
681
+ for (const overlay of api.overlay) {
682
+ if (!isRecord(overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest overlay: ${api.file}`);
683
+ if ('key' in overlay) {
684
+ if (typeof overlay.key !== 'string' || !['callbacks', 'servers', 'externalDocs', 'links'].includes(overlay.key) || !Object.prototype.hasOwnProperty.call(overlay, 'value')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest operation overlay: ${String(overlay.key)}`);
685
+ validateKeys(overlay, ['key', 'value'], 'operation overlay');
686
+ } else validateSchemaOverlay(overlay, `schema overlay: ${api.file}`);
687
+ }
688
+ for (const overlay of api.responseOverlay) {
689
+ if (!isRecord(overlay) || typeof overlay.status !== 'string' || !overlay.status || !Object.prototype.hasOwnProperty.call(overlay, 'headers')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest response overlay: ${api.file}`);
690
+ validateKeys(overlay, ['status', 'headers'], 'response overlay');
691
+ }
692
+ }
693
+
694
+ if (manifest.webhooks !== undefined) {
695
+ if (manifest.source.kind !== 'openapi-3.1') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest webhooks for this source kind');
696
+ if (!Array.isArray(manifest.webhooks) || !Array.isArray(manifest.webhookOrder) || manifest.webhookOrder.some((name) => typeof name !== 'string' || !name) || new Set(manifest.webhookOrder).size !== manifest.webhookOrder.length) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest webhook order');
697
+ if (manifest.webhooksOverlay !== undefined && !isRecord(manifest.webhooksOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', 'Invalid zopia manifest webhooksOverlay');
698
+ for (const [name, item] of Object.entries(manifest.webhooksOverlay ?? {})) {
699
+ if (!name.startsWith('x-') && (!isRecord(item) || Object.keys(item).some((key) => ['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(key)))) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest webhooksOverlay entry: ${name}`);
700
+ }
701
+ const webhookOperations = new Set<string>();
702
+ for (const webhook of manifest.webhooks) {
703
+ if (!isRecord(webhook) || typeof webhook.file !== 'string' || !isPortableManifestPath(webhook.file) || typeof webhook.name !== 'string' || !webhook.name || webhook.name.startsWith('x-') || typeof webhook.method !== 'string' || !['get', 'post', 'put', 'delete', 'head', 'options', 'patch', 'trace'].includes(webhook.method)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest webhook API: ${String((webhook as any)?.name)} ${String((webhook as any)?.method)}`);
704
+ if (!manifest.webhookOrder.includes(webhook.name)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Missing zopia manifest webhook-order entry: ${webhook.name}`);
705
+ validateKeys(webhook, ['file', 'name', 'method', 'operationId', 'sourceOperation', 'webhookItemRef', 'refs', 'overlay', 'responseOverlay', 'security'], 'webhook API');
706
+ if (webhook.webhookItemRef !== undefined && typeof webhook.webhookItemRef !== 'boolean') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest webhook-item reference flag: ${webhook.file}`);
707
+ if (typeof webhook.operationId !== 'string' || !webhook.operationId) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest webhook operation ID: ${webhook.file}`);
708
+ // Paths and webhooks are separate OpenAPI namespaces; a webhook named like a
709
+ // path template must not trip the path collision check (`operations`). Their
710
+ // shared identity constraint is operationId uniqueness, enforced on reverse.
711
+ const operation = `webhook\0${webhook.method}\0${webhook.name}`;
712
+ if (webhookOperations.has(operation)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate zopia manifest webhook API: ${webhook.method.toUpperCase()} ${webhook.name}`);
713
+ webhookOperations.add(operation);
714
+ const fileKey = webhook.file.toLowerCase();
715
+ if (files.has(fileKey)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Duplicate zopia manifest file: ${webhook.file}`);
716
+ files.add(fileKey);
717
+ if (!isRecord(webhook.sourceOperation) || !Array.isArray(webhook.refs) || !Array.isArray(webhook.overlay) || !Array.isArray(webhook.responseOverlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest webhook API metadata: ${webhook.file}`);
718
+ if (Object.prototype.hasOwnProperty.call(webhook, 'security')) validateSecurityRequirements(webhook.security, `webhook API security: ${webhook.file}`);
719
+ for (const ref of webhook.refs) {
720
+ if (!isRecord(ref) || typeof ref.at !== 'string' || typeof ref.ref !== 'string' || ref.component !== undefined && typeof ref.component !== 'string') throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ref: ${webhook.file}`);
721
+ validateKeys(ref, ['at', 'ref', 'component'], 'ref');
722
+ validatePointer(ref.at, 'ref');
723
+ if (ref.ref !== '#' && !ref.ref.startsWith('#/')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest ref target: ${ref.ref}`);
724
+ }
725
+ for (const overlay of webhook.overlay) {
726
+ if (!isRecord(overlay)) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest overlay: ${webhook.file}`);
727
+ if ('key' in overlay) {
728
+ if (typeof overlay.key !== 'string' || !['callbacks', 'servers', 'externalDocs', 'links'].includes(overlay.key) || !Object.prototype.hasOwnProperty.call(overlay, 'value')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest operation overlay: ${String(overlay.key)}`);
729
+ validateKeys(overlay, ['key', 'value'], 'operation overlay');
730
+ } else validateSchemaOverlay(overlay, `schema overlay: ${webhook.file}`);
731
+ }
732
+ for (const overlay of webhook.responseOverlay) {
733
+ if (!isRecord(overlay) || typeof overlay.status !== 'string' || !overlay.status || !Object.prototype.hasOwnProperty.call(overlay, 'headers')) throw new ZopiaError('ZOPIA_MANIFEST_INVALID', `Invalid zopia manifest response overlay: ${webhook.file}`);
734
+ validateKeys(overlay, ['status', 'headers'], 'response overlay');
735
+ }
736
+ }
737
+ }
738
+ stableJson(manifest);
739
+ }
740
+ /**
741
+ * Serialize a validated manifest with canonical object-key order and one trailing newline.
742
+ *
743
+ * @param manifest Candidate manifest to validate and serialize.
744
+ * @returns Pretty-printed canonical JSON ending in one newline.
745
+ * @throws {@link ZopiaError} when the manifest is invalid.
746
+ */
747
+ export function serializeZopiaManifest(manifest: ZopiaManifest): string {
748
+ validateZopiaManifest(manifest);
749
+ return `${JSON.stringify(canonicalValue(manifest), null, 2)}\n`;
750
+ }
751
+
752
+ /**
753
+ * Atomically write a validated manifest and return its absolute path.
754
+ *
755
+ * @param outputDir Destination api-docs directory.
756
+ * @param manifest Candidate manifest to validate and write.
757
+ * @returns Absolute path to the written manifest file.
758
+ * @throws {@link ZopiaError} when validation or filesystem output fails.
759
+ */
760
+ export async function writeZopiaManifest(outputDir: string, manifest: ZopiaManifest): Promise<string> {
761
+ if (typeof outputDir !== 'string' || outputDir.trim() === '' || outputDir.includes('\0')) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'invalid manifest output directory', { at: 'outputDir', hint: 'provide a non-empty output-directory path' });
762
+ const root = resolve(outputDir);
763
+ const file = join(root, ZOPIA_MANIFEST_FILE);
764
+ const temporary = `${file}.tmp`;
765
+ const content = serializeZopiaManifest(manifest);
766
+ if (await readFile(file, 'utf8').catch(() => undefined) === content) return file;
767
+ let temporaryWritten = false;
768
+ try {
769
+ await mkdir(root, { recursive: true });
770
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
771
+ temporaryWritten = true;
772
+ await rename(temporary, file);
773
+ } catch (error) {
774
+ if (temporaryWritten) await rm(temporary, { force: true }).catch(() => undefined);
775
+ throw asZopiaError(error, 'ZOPIA_FS_WRITE_FAILED', 'unable to write zopia manifest', { at: file, hint: 'check output-directory permissions and temporary-file conflicts' });
776
+ }
777
+ return file;
778
+ }