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
package/src/diff.ts ADDED
@@ -0,0 +1,353 @@
1
+ import { ZopiaError } from './errors';
2
+ import { collectOpenApiOperations, collectOpenApiWebhookOperations, type OpenApiOperation } from './conversions/openapi-to-api-docs';
3
+ import { normalizeOpenApiDocument, type OpenApiDocument, type OpenApiVersion } from './conversions/openapi';
4
+ import { resolveOpenApiLocalRef } from './conversions/openapi-ref';
5
+ import { readOpenApiSourceInput } from './conversions/openapi-to-api-docs-public';
6
+
7
+ /** Category of one reported change. */
8
+ export type ZopiaDiffKind = 'added' | 'removed' | 'changed';
9
+
10
+ /** Area of the documents a change belongs to. */
11
+ export type ZopiaDiffArea = 'dialect' | 'info' | 'endpoint' | 'webhook' | 'component' | 'document';
12
+
13
+ /** One structured, human-readable change between two spec inputs. */
14
+ export interface ZopiaDiffEntry {
15
+ /** Whether this change adds, removes, or alters something present in the `after` input. */
16
+ kind: ZopiaDiffKind;
17
+ /** Document area the change belongs to, used for grouped, deterministic ordering. */
18
+ area: ZopiaDiffArea;
19
+ /** JSON Pointer of the changed node — into the `after` input for additions/changes, into the `before` input for removals. */
20
+ at: string;
21
+ /** Human-readable description without the kind glyph or indentation. */
22
+ message: string;
23
+ /** Nesting depth: `0` top-level lines, `1` details under a changed endpoint/webhook header. */
24
+ depth: 0 | 1;
25
+ }
26
+
27
+ /** Grouped change counts of one diff run. */
28
+ export interface ZopiaDiffCounts {
29
+ /** Entries with kind `added`. */
30
+ added: number;
31
+ /** Entries with kind `removed`. */
32
+ removed: number;
33
+ /** Entries with kind `changed`. */
34
+ changed: number;
35
+ }
36
+
37
+ /** Result of comparing two Swagger/OpenAPI documents. */
38
+ export interface ZopiaDiffResult {
39
+ /** `true` when both inputs are semantically identical (key order ignored). */
40
+ identical: boolean;
41
+ /** Every detected change, deterministically ordered: dialect, info, endpoints, webhooks, components, document. */
42
+ changes: ZopiaDiffEntry[];
43
+ /** Per-kind entry counts. */
44
+ counts: ZopiaDiffCounts;
45
+ }
46
+
47
+ const escapePointer = (value: string): string => value.replace(/~/g, '~0').replace(/\//g, '~1');
48
+
49
+ /** Canonical key-order-insensitive serialization for value comparison (arrays stay order-sensitive). */
50
+ function canonical(value: unknown, at: string, stack: Set<object> = new Set()): string {
51
+ if (value === undefined) return '∅';
52
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? '∅';
53
+ if (stack.has(value as object)) throw new ZopiaError('ZOPIA_SPEC_INVALID', 'circular in-memory OpenAPI value', { at, hint: 'use JSON references instead of JavaScript object cycles' });
54
+ stack.add(value as object);
55
+ try {
56
+ if (Array.isArray(value)) return `[${value.map((item) => canonical(item, at, stack)).join(',')}]`;
57
+ const object = value as Record<string, unknown>;
58
+ return `{${Object.keys(object).sort().filter((key) => object[key] !== undefined).map((key) => `${JSON.stringify(key)}:${canonical(object[key], at, stack)}`).join(',')}}`;
59
+ } finally {
60
+ stack.delete(value as object);
61
+ }
62
+ }
63
+
64
+ /** Short inline rendering for scalar values; other values diffuse to a bare `changed` line. */
65
+ function scalar(value: unknown): string | undefined {
66
+ if (typeof value === 'string' && value.length <= 40) return JSON.stringify(value);
67
+ if (typeof value === 'number' && Number.isFinite(value) || typeof value === 'boolean' || value === null) return JSON.stringify(value);
68
+ return undefined;
69
+ }
70
+
71
+ /** `"a" -> "b"` for renderable scalar pairs, otherwise a bare `changed`. */
72
+ function transition(left: unknown, right: unknown): string {
73
+ const from = scalar(left); const to = scalar(right);
74
+ return from !== undefined && to !== undefined ? `${from} -> ${to}` : 'changed';
75
+ }
76
+
77
+ /** Collect parameters of one operation, keyed by the `name`/`in` identity pair. */
78
+ function parameterMap(operation: OpenApiOperation): Map<string, { name: string; where: string; value: unknown }> {
79
+ const map = new Map<string, { name: string; where: string; value: unknown }>();
80
+ for (const parameter of operation.parameters) {
81
+ if (!parameter || typeof parameter !== 'object' || Array.isArray(parameter)) continue;
82
+ const name = typeof parameter.name === 'string' ? parameter.name : undefined;
83
+ const where = typeof parameter.in === 'string' ? parameter.in : undefined;
84
+ if (name === undefined || where === undefined) continue;
85
+ map.set(`${JSON.stringify(where)}/${JSON.stringify(name)}`, { name, where, value: parameter });
86
+ }
87
+ return map;
88
+ }
89
+
90
+ /** Sorted union key collection for deterministic difference emission. */
91
+ function unionKeys(left: Record<string, unknown> | undefined, right: Record<string, unknown> | undefined): string[] {
92
+ const keys = new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})]);
93
+ return [...keys].sort();
94
+ }
95
+
96
+ /** Response status keys in plan order: numeric statuses ascending, then `default` and extensions. */
97
+ const responseStatusOrder = (keys: string[]): string[] => {
98
+ const numeric = keys.filter((key) => /^\d+$/.test(key)).sort((a, b) => Number(a) - Number(b));
99
+ const other = keys.filter((key) => !/^\d+$/.test(key)).sort();
100
+ return [...numeric, ...other];
101
+ };
102
+
103
+ interface OperationPair { key: string; older?: OpenApiOperation; newer?: OpenApiOperation; }
104
+
105
+ /**
106
+ * Field-change phrasing for path-item and webhook-item metadata lines:
107
+ * scalar pairs transit as `"a" -> "b"`, add/remove carry their scalar when short.
108
+ */
109
+ function itemPhrase(kind: ZopiaDiffKind, before: unknown, after: unknown): string {
110
+ if (kind === 'added') { const value = scalar(after); return value !== undefined ? `${value} added` : 'added'; }
111
+ if (kind === 'removed') { const value = scalar(before); return value !== undefined ? `${value} removed` : 'removed'; }
112
+ return transition(before, after);
113
+ }
114
+
115
+ /** Resolve one path/webhook item through local `$ref` chains — sibling keys win, same as generation. */
116
+ function resolveItem(document: OpenApiDocument, item: unknown, name: string, kind: 'path' | 'webhook'): Record<string, any> | undefined {
117
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
118
+ let resolved: any = item;
119
+ const seen = new Set<string>();
120
+ while ('$ref' in resolved) {
121
+ if (typeof resolved.$ref !== 'string' || !resolved.$ref) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid ${kind}-item $ref: ${name}`);
122
+ if (seen.has(resolved.$ref)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Circular ${kind}-item $ref: ${resolved.$ref}`);
123
+ seen.add(resolved.$ref);
124
+ const target = resolveOpenApiLocalRef(document, resolved.$ref);
125
+ if (!target || typeof target !== 'object' || Array.isArray(target)) throw new ZopiaError('ZOPIA_SPEC_PATH_REF', `Invalid ${kind}-item $ref: ${resolved.$ref}`);
126
+ resolved = { ...target, ...Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== '$ref')) };
127
+ }
128
+ return resolved;
129
+ }
130
+
131
+ /** Item-level metadata fields (operation bodies are compared by the operation sections, parameters are already merged there). */
132
+ function diffItemMetadata(collect: (e: ZopiaDiffEntry) => void, area: ZopiaDiffArea, section: 'paths' | 'webhooks', label: 'path item' | 'webhook', fields: readonly string[], older: OpenApiDocument, newer: OpenApiDocument): void {
133
+ const olderMap = (older[section] ?? {}) as Record<string, unknown>;
134
+ const newerMap = (newer[section] ?? {}) as Record<string, unknown>;
135
+ for (const name of unionKeys(olderMap, newerMap)) {
136
+ const itemAt = `#/${section}/${escapePointer(name)}`;
137
+ if (name.startsWith('x-')) {
138
+ const extensionLabel = label === 'webhook' ? 'webhook extension' : 'path extension';
139
+ const before = olderMap[name]; const after = newerMap[name];
140
+ if (before === undefined) collect({ kind: 'added', area, at: itemAt, message: `${extensionLabel} ${name} added`, depth: 0 });
141
+ else if (after === undefined) collect({ kind: 'removed', area, at: itemAt, message: `${extensionLabel} ${name} removed`, depth: 0 });
142
+ else if (canonical(before, itemAt) !== canonical(after, itemAt)) collect({ kind: 'changed', area, at: itemAt, message: `${extensionLabel} ${name} changed`, depth: 0 });
143
+ continue;
144
+ }
145
+ if (!(name in olderMap) || !(name in newerMap)) continue; // add/remove is reported per operation by the operation sections
146
+ const kind = section === 'webhooks' ? 'webhook' : 'path';
147
+ const before = resolveItem(older, olderMap[name], name, kind);
148
+ const after = resolveItem(newer, newerMap[name], name, kind);
149
+ if (!before || !after) continue;
150
+ const extensions = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((key) => key.startsWith('x-')).sort();
151
+ for (const field of [...fields, ...extensions]) {
152
+ const fieldAt = `${itemAt}/${escapePointer(field)}`;
153
+ if (before[field] === undefined && after[field] === undefined) continue;
154
+ if (before[field] === undefined) collect({ kind: 'added', area, at: fieldAt, message: `${label} ${name}: ${field} ${itemPhrase('added', undefined, after[field])}`, depth: 0 });
155
+ else if (after[field] === undefined) collect({ kind: 'removed', area, at: fieldAt, message: `${label} ${name}: ${field} ${itemPhrase('removed', before[field], undefined)}`, depth: 0 });
156
+ else if (canonical(before[field], fieldAt) !== canonical(after[field], fieldAt)) collect({ kind: 'changed', area, at: fieldAt, message: `${label} ${name}: ${field} ${itemPhrase('changed', before[field], after[field])}`, depth: 0 });
157
+ }
158
+ }
159
+ }
160
+
161
+ /** Named component registries compared per entry; aligned across dialects (Swagger 2.0 top-level maps ↔ OpenAPI 3.x `components.*`). */
162
+ const diffRegistries: ReadonlyArray<{ label: string; key2?: string; key3: string }> = [
163
+ { label: 'parameter', key2: 'parameters', key3: 'parameters' },
164
+ { label: 'response', key2: 'responses', key3: 'responses' },
165
+ { label: 'security scheme', key2: 'securityDefinitions', key3: 'securitySchemes' },
166
+ { label: 'request body', key3: 'requestBodies' },
167
+ { label: 'header', key3: 'headers' },
168
+ { label: 'link', key3: 'links' },
169
+ { label: 'callback', key3: 'callbacks' },
170
+ { label: 'example', key3: 'examples' },
171
+ { label: 'path item', key3: 'pathItems' },
172
+ ];
173
+
174
+ /** Dialect-aware registry map plus its pointer prefix (`undefined` when the dialect has no such container). */
175
+ function registryOf(document: OpenApiDocument, version: OpenApiVersion, key3: string, key2?: string): { map: Record<string, unknown>; at: string } | undefined {
176
+ if (version === '2.0') {
177
+ if (!key2) return undefined;
178
+ return { map: (document[key2] ?? {}) as Record<string, unknown>, at: `#/${escapePointer(key2)}` };
179
+ }
180
+ return { map: ((document.components?.[key3] ?? {}) as Record<string, unknown>), at: `#/components/${escapePointer(key3)}` };
181
+ }
182
+
183
+ /** Emit per-registry per-name component differences, in registry declaration order for determinism. */
184
+ function diffRegistriesSection(collect: (e: ZopiaDiffEntry) => void, olderVersion: OpenApiVersion, newerVersion: OpenApiVersion, older: OpenApiDocument, newer: OpenApiDocument): void {
185
+ for (const registry of diffRegistries) {
186
+ const olderRegistry = registryOf(older, olderVersion, registry.key3, registry.key2);
187
+ const newerRegistry = registryOf(newer, newerVersion, registry.key3, registry.key2);
188
+ const olderMap = olderRegistry?.map ?? {}; const newerMap = newerRegistry?.map ?? {};
189
+ for (const name of unionKeys(olderMap, newerMap)) {
190
+ const at = `${(name in newerMap ? newerRegistry?.at : undefined) ?? olderRegistry?.at ?? newerRegistry?.at}/${escapePointer(name)}`;
191
+ if (!(name in olderMap)) collect({ kind: 'added', area: 'component', at, message: `${registry.label} ${name}`, depth: 0 });
192
+ else if (!(name in newerMap)) collect({ kind: 'removed', area: 'component', at, message: `${registry.label} ${name}`, depth: 0 });
193
+ else if (canonical(olderMap[name], at) !== canonical(newerMap[name], at)) collect({ kind: 'changed', area: 'component', at, message: `${registry.label} ${name} changed`, depth: 0 });
194
+ }
195
+ }
196
+ }
197
+
198
+ /** Human label of one operation: `GET /pets (listPets)`. */
199
+ function operationLabel(operation: OpenApiOperation): string {
200
+ return `${operation.method.toUpperCase()} ${operation.path}${operation.operationId ? ` (${operation.operationId})` : ''}`;
201
+ }
202
+
203
+ /** All per-section differences of one operation kept by both inputs. */
204
+ function diffSharedOperation(entry: (e: Omit<ZopiaDiffEntry, 'depth'>) => void, detail: (e: Omit<ZopiaDiffEntry, 'depth'>) => void, area: ZopiaDiffArea, at: string, older: OpenApiOperation, newer: OpenApiOperation, label: string): void {
205
+ const details: Array<{ kind: ZopiaDiffKind; at: string; message: string }> = [];
206
+ for (const field of ['summary', 'description', 'operationId', 'deprecated'] as const) {
207
+ const before = older.operation[field]; const after = newer.operation[field];
208
+ if (before === undefined && after === undefined || canonical(before, `${at}/${field}`) === canonical(after, `${at}/${field}`)) continue;
209
+ if (before === undefined) details.push({ kind: 'added', at: `${at}/${field}`, message: `${field}: ${scalar(after) ?? 'added'}` });
210
+ else if (after === undefined) details.push({ kind: 'removed', at: `${at}/${field}`, message: `${field}: ${scalar(before) ?? 'removed'}` });
211
+ else details.push({ kind: 'changed', at: `${at}/${field}`, message: `${field}: ${transition(before, after)}` });
212
+ }
213
+ if (canonical(older.operation.tags, `${at}/tags`) !== canonical(newer.operation.tags, `${at}/tags`)) {
214
+ details.push({ kind: 'changed', at: `${at}/tags`, message: `tags: ${transition(older.operation.tags, newer.operation.tags)}` });
215
+ }
216
+ const olderParameters = parameterMap(older); const newerParameters = parameterMap(newer);
217
+ for (const key of [...new Set([...olderParameters.keys(), ...newerParameters.keys()])].sort()) {
218
+ const before = olderParameters.get(key); const after = newerParameters.get(key);
219
+ const parameterAt = `${at}/parameters|${key}`;
220
+ if (before && !after) details.push({ kind: 'removed', at: parameterAt, message: `parameter ${before.name} (${before.where}) removed` });
221
+ else if (!before && after) details.push({ kind: 'added', at: parameterAt, message: `parameter ${after.name} (${after.where}) added` });
222
+ else if (before && after && canonical(before.value, parameterAt) !== canonical(after.value, parameterAt)) details.push({ kind: 'changed', at: parameterAt, message: `parameter ${before.name} (${before.where}) changed` });
223
+ }
224
+ const olderBody = older.operation.requestBody; const newerBody = newer.operation.requestBody;
225
+ if (olderBody !== undefined || newerBody !== undefined) {
226
+ if (olderBody === undefined) details.push({ kind: 'added', at: `${at}/requestBody`, message: 'request body added' });
227
+ else if (newerBody === undefined) details.push({ kind: 'removed', at: `${at}/requestBody`, message: 'request body removed' });
228
+ else if (canonical(olderBody, `${at}/requestBody`) !== canonical(newerBody, `${at}/requestBody`)) details.push({ kind: 'changed', at: `${at}/requestBody`, message: 'request body changed' });
229
+ }
230
+ const olderResponses = (older.operation.responses ?? {}) as Record<string, unknown>; const newerResponses = (newer.operation.responses ?? {}) as Record<string, unknown>;
231
+ for (const status of responseStatusOrder(unionKeys(olderResponses, newerResponses))) {
232
+ const statusAt = `${at}/responses/${escapePointer(status)}`;
233
+ if (!(status in olderResponses)) details.push({ kind: 'added', at: statusAt, message: `response ${status} added` });
234
+ else if (!(status in newerResponses)) details.push({ kind: 'removed', at: statusAt, message: `response ${status} removed` });
235
+ else if (canonical(olderResponses[status], statusAt) !== canonical(newerResponses[status], statusAt)) details.push({ kind: 'changed', at: statusAt, message: `response ${status} changed` });
236
+ }
237
+ if (canonical(older.operation.security, `${at}/security`) !== canonical(newer.operation.security, `${at}/security`)) details.push({ kind: 'changed', at: `${at}/security`, message: 'security changed' });
238
+ const extensions = [...new Set([...Object.keys(older.operation), ...Object.keys(newer.operation)])].filter((key) => key.startsWith('x-')).sort();
239
+ for (const key of extensions) {
240
+ const extensionAt = `${at}/${escapePointer(key)}`;
241
+ if (older.operation[key] === undefined && newer.operation[key] === undefined) continue;
242
+ if (older.operation[key] === undefined) details.push({ kind: 'added', at: extensionAt, message: `${key}: ${scalar(newer.operation[key]) ?? 'added'}` });
243
+ else if (newer.operation[key] === undefined) details.push({ kind: 'removed', at: extensionAt, message: `${key}: ${scalar(older.operation[key]) ?? 'removed'}` });
244
+ else if (canonical(older.operation[key], extensionAt) !== canonical(newer.operation[key], extensionAt)) details.push({ kind: 'changed', at: extensionAt, message: `${key}: ${transition(older.operation[key], newer.operation[key])}` });
245
+ }
246
+ if (details.length === 0 && canonical(older.operation, at) !== canonical(newer.operation, at)) details.push({ kind: 'changed', at, message: 'operation content changed' });
247
+ if (details.length === 0) return;
248
+ entry({ kind: 'changed', area, at, message: label });
249
+ for (const item of details) detail({ kind: item.kind, area, at: item.at, message: item.message });
250
+ }
251
+
252
+ /** Match the operations of both inputs by display identity and emit the endpoint/webhook sections. */
253
+ function diffOperationSections(collect: (e: { kind: ZopiaDiffKind; area: ZopiaDiffArea; at: string; message: string; depth: 0 | 1 }) => void, area: ZopiaDiffArea, section: 'paths' | 'webhooks', olderOperations: readonly OpenApiOperation[], newerOperations: readonly OpenApiOperation[]): void {
254
+ const olderByKey = new Map<string, OpenApiOperation>(olderOperations.map((operation) => [`${operation.method.toUpperCase()} ${operation.path}`, operation]));
255
+ const newerByKey = new Map<string, OpenApiOperation>(newerOperations.map((operation) => [`${operation.method.toUpperCase()} ${operation.path}`, operation]));
256
+ const operationOrder = (left: string, right: string): number => {
257
+ const [leftMethod, ...leftPath] = left.split(' '); const [rightMethod, ...rightPath] = right.split(' ');
258
+ const leftPathText = leftPath.join(' '); const rightPathText = rightPath.join(' ');
259
+ return leftPathText < rightPathText ? -1 : leftPathText > rightPathText ? 1 : leftMethod < rightMethod ? -1 : leftMethod > rightMethod ? 1 : 0;
260
+ };
261
+ for (const key of [...new Set([...olderByKey.keys(), ...newerByKey.keys()])].sort(operationOrder)) {
262
+ const older = olderByKey.get(key); const newer = newerByKey.get(key);
263
+ const method = key.split(' ')[0].toLowerCase(); const path = key.slice(method.length + 1);
264
+ const at = `#/${section}/${escapePointer(path)}/${method}`;
265
+ const singular = area === 'webhook' ? 'webhook' : 'endpoint';
266
+ if (older && !newer) collect({ kind: 'removed', area, at, message: `${singular} ${operationLabel(older)}`, depth: 0 });
267
+ else if (!older && newer) collect({ kind: 'added', area, at, message: `${singular} ${operationLabel(newer)}`, depth: 0 });
268
+ else if (older && newer) diffSharedOperation(
269
+ (e) => collect({ ...e, depth: 0 }),
270
+ (e) => collect({ ...e, depth: 1 }),
271
+ area, at, older, newer, `${singular} ${operationLabel(newer)}`,
272
+ );
273
+ }
274
+ }
275
+
276
+ /** Schema component maps, dialect-aligned (Swagger 2.0 {#/definitions}, OpenAPI 3.x {#/components/schemas}). */
277
+ function schemaContainer(document: OpenApiDocument, version: OpenApiVersion): { map: Record<string, unknown>; at: string } {
278
+ if (version === '2.0') return { map: (document.definitions ?? {}) as Record<string, unknown>, at: '#/definitions' };
279
+ return { map: (document.components?.schemas ?? {}) as Record<string, unknown>, at: '#/components/schemas' };
280
+ }
281
+
282
+ /** Document-level fields compared as whole values, in dialect order. */
283
+ const documentFields = ['servers', 'host', 'basePath', 'schemes', 'consumes', 'produces', 'security', 'tags', 'externalDocs', 'jsonSchemaDialect'] as const;
284
+
285
+ /**
286
+ * Compare two normalized-or-raw documents byte-deeply: endpoints, webhooks, schema components, `info`, and document fields.
287
+ *
288
+ * @param before Older Swagger 2.0 or OpenAPI 3.0/3.1 document object.
289
+ * @param after Newer Swagger 2.0 or OpenAPI 3.0/3.1 document object.
290
+ * @returns Deterministically ordered structured changes; empty when semantically identical (key order ignored).
291
+ * @throws {@link ZopiaError} `ZOPIA_SPEC_*`/`ZOPIA_SPEC_PATH_REF` when either document envelope, path item, or webhook item is invalid.
292
+ */
293
+ export function diffOpenApiDocuments(before: OpenApiDocument, after: OpenApiDocument): ZopiaDiffResult {
294
+ const older = normalizeOpenApiDocument(before);
295
+ const newer = normalizeOpenApiDocument(after);
296
+ const changes: ZopiaDiffEntry[] = [];
297
+ const collect = (e: ZopiaDiffEntry): void => { changes.push(e); };
298
+
299
+ const olderVersion = older.version === '2.0' ? `swagger ${older.document.swagger}` : `openapi ${older.document.openapi}`;
300
+ const newerVersion = newer.version === '2.0' ? `swagger ${newer.document.swagger}` : `openapi ${newer.document.openapi}`;
301
+ if (olderVersion !== newerVersion) collect({ kind: 'changed', area: 'dialect', at: '#', message: `dialect: ${olderVersion} -> ${newerVersion}`, depth: 0 });
302
+
303
+ const olderInfo = (older.document.info ?? {}) as Record<string, unknown>; const newerInfo = (newer.document.info ?? {}) as Record<string, unknown>;
304
+ for (const field of unionKeys(olderInfo, newerInfo)) {
305
+ const at = `#/info/${escapePointer(field)}`;
306
+ if (!(field in olderInfo)) collect({ kind: 'added', area: 'info', at, message: `info.${field}: ${scalar(newerInfo[field]) ?? 'added'}`, depth: 0 });
307
+ else if (!(field in newerInfo)) collect({ kind: 'removed', area: 'info', at, message: `info.${field}: ${scalar(olderInfo[field]) ?? 'removed'}`, depth: 0 });
308
+ else if (canonical(olderInfo[field], at) !== canonical(newerInfo[field], at)) collect({ kind: 'changed', area: 'info', at, message: `info.${field}: ${transition(olderInfo[field], newerInfo[field])}`, depth: 0 });
309
+ }
310
+
311
+ diffItemMetadata(collect, 'endpoint', 'paths', 'path item', ['summary', 'description', 'servers'], older.document, newer.document);
312
+ diffOperationSections(collect, 'endpoint', 'paths', collectOpenApiOperations(older.document), collectOpenApiOperations(newer.document));
313
+ diffItemMetadata(collect, 'webhook', 'webhooks', 'webhook', ['summary', 'description'], older.document, newer.document);
314
+ diffOperationSections(collect, 'webhook', 'webhooks', collectOpenApiWebhookOperations(older.document), collectOpenApiWebhookOperations(newer.document));
315
+
316
+ const olderSchemas = schemaContainer(older.document, older.version); const newerSchemas = schemaContainer(newer.document, newer.version);
317
+ for (const name of unionKeys(olderSchemas.map, newerSchemas.map)) {
318
+ const at = `${newerSchemas.map[name] !== undefined || olderSchemas.map[name] === undefined ? newerSchemas.at : olderSchemas.at}/${escapePointer(name)}`;
319
+ if (!(name in olderSchemas.map)) collect({ kind: 'added', area: 'component', at, message: `component ${name}`, depth: 0 });
320
+ else if (!(name in newerSchemas.map)) collect({ kind: 'removed', area: 'component', at, message: `component ${name}`, depth: 0 });
321
+ else if (canonical(olderSchemas.map[name], at) !== canonical(newerSchemas.map[name], at)) collect({ kind: 'changed', area: 'component', at, message: `component ${name}: schema changed`, depth: 0 });
322
+ }
323
+ diffRegistriesSection(collect, older.version, newer.version, older.document, newer.document);
324
+
325
+ const rootKeys = new Set([...Object.keys(older.document), ...Object.keys(newer.document)]);
326
+ const extensions = [...rootKeys].filter((key) => key.startsWith('x-')).sort();
327
+ for (const field of [...documentFields, ...extensions]) {
328
+ const before = older.document[field]; const after = newer.document[field];
329
+ if (before === undefined && after === undefined) continue;
330
+ const at = `#/${escapePointer(field)}`;
331
+ if (before === undefined) collect({ kind: 'added', area: 'document', at, message: `${field}: ${scalar(after) ?? 'added'}`, depth: 0 });
332
+ else if (after === undefined) collect({ kind: 'removed', area: 'document', at, message: `${field}: ${scalar(before) ?? 'removed'}`, depth: 0 });
333
+ else if (canonical(before, at) !== canonical(after, at)) collect({ kind: 'changed', area: 'document', at, message: `${field}: ${transition(before, after)}`, depth: 0 });
334
+ }
335
+
336
+ const counts = { added: 0, removed: 0, changed: 0 };
337
+ for (const change of changes) counts[change.kind] += 1;
338
+ return { identical: changes.length === 0, changes, counts };
339
+ }
340
+
341
+ /**
342
+ * Compare two Swagger/OpenAPI spec inputs — file paths (JSON/YAML), inline text, or in-memory objects — each loaded with the same rules as {@link openApiToApiDocs}.
343
+ *
344
+ * @param before Older spec input (file path, inline JSON/YAML text, or document object).
345
+ * @param after Newer spec input (file path, inline JSON/YAML text, or document object).
346
+ * @returns Deterministically ordered structured changes; empty when semantically identical (key order ignored).
347
+ * @throws {@link ZopiaError} `ZOPIA_SPEC_INVALID*` when an input cannot be read or parsed, and `ZOPIA_SPEC_*` for invalid documents.
348
+ */
349
+ export async function diffOpenApiSpecs(before: string | OpenApiDocument, after: string | OpenApiDocument): Promise<ZopiaDiffResult> {
350
+ const older = await readOpenApiSourceInput(before);
351
+ const newer = await readOpenApiSourceInput(after);
352
+ return diffOpenApiDocuments(older.document, newer.document);
353
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,114 @@
1
+ /** Stable error-code catalogue exposed by every zopia public API. */
2
+ export const ZOPIA_ERROR_CODES = Object.freeze([
3
+ 'ZOPIA_CONFIG_INVALID',
4
+ 'ZOPIA_DOCS_IMPORT_FAILED',
5
+ 'ZOPIA_DOCS_MANIFEST_MISMATCH',
6
+ 'ZOPIA_DOCS_MISSING_MANIFEST',
7
+ 'ZOPIA_FS_OUTSIDE_OUTDIR',
8
+ 'ZOPIA_FS_WRITE_FAILED',
9
+ 'ZOPIA_MANIFEST_INVALID',
10
+ 'ZOPIA_REF_EXTERNAL',
11
+ 'ZOPIA_REF_NOT_FOUND',
12
+ 'ZOPIA_SCHEMA_INVALID',
13
+ 'ZOPIA_SPEC_INVALID',
14
+ 'ZOPIA_SPEC_INVALID_JSON',
15
+ 'ZOPIA_SPEC_INVALID_YAML',
16
+ 'ZOPIA_SPEC_MISSING_PATHS',
17
+ 'ZOPIA_SPEC_PATH_REF',
18
+ 'ZOPIA_SPEC_UNSUPPORTED_VERSION',
19
+ 'ZOPIA_WARNING_INVALID',
20
+ ] as const);
21
+
22
+ /** Stable machine-readable error code exposed by zopia's public APIs. */
23
+ export type ZopiaErrorCode = typeof ZOPIA_ERROR_CODES[number];
24
+
25
+ /** Optional source location, recovery hint, and causal error metadata. */
26
+ export interface ZopiaErrorOptions {
27
+ /**
28
+ * JSON Pointer, option name, or portable file location associated with the error.
29
+ * @default undefined
30
+ */
31
+ at?: string;
32
+ /**
33
+ * Concise action the caller can take to resolve the error.
34
+ * @default Code-specific recovery guidance.
35
+ */
36
+ hint?: string;
37
+ /**
38
+ * Original exception when zopia translates a lower-level failure.
39
+ * @default undefined
40
+ */
41
+ cause?: unknown;
42
+ }
43
+
44
+ const DEFAULT_ERROR_HINTS: Record<ZopiaErrorCode, string> = {
45
+ ZOPIA_CONFIG_INVALID: 'correct the invalid option or argument',
46
+ ZOPIA_SPEC_INVALID_JSON: 'provide readable, valid JSON',
47
+ ZOPIA_SPEC_INVALID_YAML: 'provide readable, valid YAML',
48
+ ZOPIA_SPEC_INVALID: 'fix the invalid Swagger/OpenAPI document',
49
+ ZOPIA_SPEC_UNSUPPORTED_VERSION: 'use Swagger 2.0, OpenAPI 3.0, or OpenAPI 3.1',
50
+ ZOPIA_SPEC_MISSING_PATHS: 'add a paths object to the API document',
51
+ ZOPIA_SPEC_PATH_REF: 'use a valid local path-item reference',
52
+ ZOPIA_SCHEMA_INVALID: 'provide a valid Zod or JSON Schema value',
53
+ ZOPIA_REF_NOT_FOUND: 'check that the local JSON Pointer target exists',
54
+ ZOPIA_REF_EXTERNAL: 'replace the external reference with a local reference',
55
+ ZOPIA_MANIFEST_INVALID: 'regenerate the manifest or fix its invalid metadata',
56
+ ZOPIA_DOCS_MISSING_MANIFEST: 'generate api docs first or pass the manifest path',
57
+ ZOPIA_DOCS_MANIFEST_MISMATCH: 'regenerate the api-docs tree or restore its generated files',
58
+ ZOPIA_DOCS_IMPORT_FAILED: 'fix or regenerate the affected generated module',
59
+ ZOPIA_FS_OUTSIDE_OUTDIR: 'keep generated paths inside the output directory',
60
+ ZOPIA_FS_WRITE_FAILED: 'check the output path, permissions, and available disk space',
61
+ ZOPIA_WARNING_INVALID: 'provide a valid warning code, location, and message',
62
+ };
63
+
64
+ /** A typed public error with a stable code and optional source location and actionable recovery hint. */
65
+ export class ZopiaError extends Error {
66
+ /** Stable, machine-readable error code. */
67
+ readonly code: ZopiaErrorCode;
68
+
69
+ /** JSON Pointer or file location associated with the error. */
70
+ readonly at?: string;
71
+
72
+ /** Actionable recovery suggestion. */
73
+ readonly hint: string;
74
+
75
+ /**
76
+ * Create an error from a stable code, human-readable message, and optional diagnostics.
77
+ *
78
+ * @param code Stable machine-readable failure code.
79
+ * @param message Human-readable failure description.
80
+ * @param options Optional source location, recovery hint, and cause.
81
+ */
82
+ constructor(code: ZopiaErrorCode, message: string, options: ZopiaErrorOptions = {}) {
83
+ super(`${code}: ${message}`, { cause: options.cause });
84
+ this.name = 'ZopiaError';
85
+ this.code = code;
86
+ this.at = options.at;
87
+ this.hint = options.hint ?? DEFAULT_ERROR_HINTS[code];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Test whether an unknown thrown value is a zopia typed error.
93
+ *
94
+ * @param error Unknown caught or rejected value.
95
+ * @returns Whether `error` is a {@link ZopiaError} instance.
96
+ */
97
+ export function isZopiaError(error: unknown): error is ZopiaError {
98
+ return error instanceof ZopiaError;
99
+ }
100
+
101
+ /**
102
+ * Preserve an existing typed error or wrap an unknown failure with public diagnostics.
103
+ *
104
+ * @param error Unknown caught or rejected value.
105
+ * @param code Stable code to use when wrapping an untyped failure.
106
+ * @param message Context prepended to an untyped failure's message.
107
+ * @param options Optional source location and recovery hint for a wrapped failure.
108
+ * @returns The existing typed error or a newly wrapped {@link ZopiaError}.
109
+ */
110
+ export function asZopiaError(error: unknown, code: ZopiaErrorCode, message: string, options: Omit<ZopiaErrorOptions, 'cause'> = {}): ZopiaError {
111
+ if (error instanceof ZopiaError) return error;
112
+ const detail = error instanceof Error && error.message ? `: ${error.message}` : error === undefined ? '' : `: ${String(error)}`;
113
+ return new ZopiaError(code, `${message}${detail}`, { ...options, cause: error });
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,80 @@
1
+ export {
2
+ ZopiaError,
3
+ ZOPIA_ERROR_CODES,
4
+ asZopiaError,
5
+ isZopiaError,
6
+ type ZopiaErrorCode,
7
+ type ZopiaErrorOptions,
8
+ } from './errors';
9
+ export { ZOPIA_WARNING_CODES, type ZopiaWarning, type ZopiaWarningCode } from './warnings';
10
+ export {
11
+ zodToJsonSchema,
12
+ type ZodJsonSchemaTarget,
13
+ type ZodToJsonSchemaOptions,
14
+ } from './conversions/zod-to-json-schema';
15
+ export {
16
+ jsonSchemaToZod,
17
+ type JsonSchema,
18
+ type JsonSchemaOverlay,
19
+ type JsonSchemaToZodOptions,
20
+ type JsonSchemaToZodResult,
21
+ } from './conversions/json-schema-to-zod';
22
+ export {
23
+ normalizeOpenApiDocument,
24
+ type NormalizedOpenApiDocument,
25
+ type OpenApiDocument,
26
+ type OpenApiVersion,
27
+ } from './conversions/openapi';
28
+ export {
29
+ openApiToApiDocs,
30
+ readOpenApiSourceInput,
31
+ validateOpenApiReferences,
32
+ type ReadOpenApiSourceInputResult,
33
+ type ZopiaGenerateOptions,
34
+ type ZopiaGeneratedFile,
35
+ type ZopiaGenerateResult,
36
+ } from './conversions/openapi-to-api-docs-public';
37
+ export {
38
+ collectOpenApiOperations,
39
+ collectOpenApiWebhookOperations,
40
+ deriveOperationId,
41
+ OPENAPI_METHODS,
42
+ type OpenApiMethod,
43
+ type OpenApiOperation,
44
+ } from './conversions/openapi-to-api-docs';
45
+ export { endpointFilePath, type ApiDocsMode } from './conversions/api-docs-layout';
46
+ export { assertUniqueOperationIdsAcrossScopes, planApiDocsFiles, planWebhookDocsFiles, webhookRuntimePath, type ApiDocsFilePlan } from './conversions/api-docs-plan';
47
+ export { generateApiDocsFiles, type GeneratedApiDocsFile, type GenerateApiDocsOptions } from './conversions/api-docs-generate';
48
+ export { planPresetBuckets, ZOPIA_GENERATE_PRESETS, type ZopiaGeneratePreset, type ZopiaPresetBucket, type ZopiaPresetTree } from './conversions/api-docs-presets';
49
+ export { loadNavigationIndex, navigationIndexFromManifest, specPointerAtLine, specPointerToLine, specPointersToLines, type ZopiaNavigationIndex, type ZopiaNavigationKind, type ZopiaNavigationLocation } from './api-docs-navigation';
50
+ export { apiDocsFacadeAccess } from './conversions/api-docs-facade';
51
+ export { buildOpenApiOperationIR, type OpenApiOperationIR } from './conversions/openapi-ir';
52
+ export { extractOperationContracts, type OperationContracts } from './conversions/openapi-contracts';
53
+ export { resolveOpenApiLocalRef } from './conversions/openapi-ref';
54
+ export { apiDocsToOpenApi, manifestToOpenApi, manifestFileToOpenApi, type ZopiaReverseOptions, type ZopiaReverseResult } from './conversions/manifest-to-openapi';
55
+ export { type ZopiaManifest } from './conversions/manifest-writer';
56
+ export {
57
+ defineConfig,
58
+ loadZopiaConfig,
59
+ type ZopiaConfigLoadOptions,
60
+ type ZopiaProjectConfig,
61
+ type ZopiaProjectGenerateConfig,
62
+ type ZopiaProjectReverseConfig,
63
+ } from './config';
64
+ export {
65
+ validateZopia,
66
+ ZOPIA_VALIDATION_CODES,
67
+ type ZopiaValidateOptions,
68
+ type ZopiaValidationCode,
69
+ type ZopiaValidationIssue,
70
+ type ZopiaValidationResult,
71
+ } from './validation';
72
+ export {
73
+ diffOpenApiDocuments,
74
+ diffOpenApiSpecs,
75
+ type ZopiaDiffArea,
76
+ type ZopiaDiffCounts,
77
+ type ZopiaDiffEntry,
78
+ type ZopiaDiffKind,
79
+ type ZopiaDiffResult,
80
+ } from './diff';