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,536 @@
1
+ import { z } from 'zod';
2
+ import { asZopiaError, ZopiaError } from '../errors';
3
+ import { ZopiaWarningCollector, type ZopiaWarning } from '../warnings';
4
+
5
+ /** Output dialect supported by the Zod-to-JSON-Schema converter. */
6
+ export type ZodJsonSchemaTarget =
7
+ | 'draft-07'
8
+ | 'draft-2020-12'
9
+ | 'openapi-3.0'
10
+ | 'openapi-3.1';
11
+
12
+ /** Options controlling Zod-to-JSON-Schema conversion. */
13
+ export interface ZodToJsonSchemaOptions {
14
+ /** Output dialect. @default 'openapi-3.1' */
15
+ target?: ZodJsonSchemaTarget;
16
+ /** Include the dialect's `$schema` URI. @default true */
17
+ $schema?: boolean;
18
+ /** Convert the schema's accepted input or produced output type. @default 'output' */
19
+ io?: 'input' | 'output';
20
+ /**
21
+ * Receive every structured warning produced by a lossy conversion.
22
+ *
23
+ * @param warning Normalized warning emitted in deterministic order.
24
+ * @returns Nothing.
25
+ * @default undefined
26
+ */
27
+ onWarning?: (warning: ZopiaWarning) => void;
28
+ }
29
+
30
+ type InternalZodJsonSchemaTarget = ZodJsonSchemaTarget | 'draft-4';
31
+ interface InternalZodToJsonSchemaOptions extends Omit<ZodToJsonSchemaOptions, 'target'> {
32
+ target?: InternalZodJsonSchemaTarget;
33
+ }
34
+
35
+ const MAX_SAFE_INTEGER = 9_007_199_254_740_991;
36
+ const KNOWN_FORMATS = new Set([
37
+ 'uuid',
38
+ 'email',
39
+ 'hostname',
40
+ 'ipv4',
41
+ 'ipv6',
42
+ 'date-time',
43
+ 'date',
44
+ 'duration',
45
+ 'uri',
46
+ ]);
47
+
48
+ // `$schema` is a document header. Every schema keyword after it starts with
49
+ // `type`, followed by a fixed validation/annotation dictionary (R-617).
50
+ const KEY_ORDER = [
51
+ '$schema',
52
+ 'type',
53
+ '$id',
54
+ '$anchor',
55
+ '$dynamicAnchor',
56
+ '$ref',
57
+ '$dynamicRef',
58
+ 'format',
59
+ 'contentEncoding',
60
+ 'contentMediaType',
61
+ 'const',
62
+ 'enum',
63
+ 'minimum',
64
+ 'exclusiveMinimum',
65
+ 'maximum',
66
+ 'exclusiveMaximum',
67
+ 'multipleOf',
68
+ 'minLength',
69
+ 'maxLength',
70
+ 'pattern',
71
+ 'prefixItems',
72
+ 'items',
73
+ 'additionalItems',
74
+ 'unevaluatedItems',
75
+ 'contains',
76
+ 'minContains',
77
+ 'maxContains',
78
+ 'minItems',
79
+ 'maxItems',
80
+ 'uniqueItems',
81
+ 'properties',
82
+ 'required',
83
+ 'additionalProperties',
84
+ 'unevaluatedProperties',
85
+ 'propertyNames',
86
+ 'patternProperties',
87
+ 'dependentRequired',
88
+ 'dependentSchemas',
89
+ 'dependencies',
90
+ 'minProperties',
91
+ 'maxProperties',
92
+ 'allOf',
93
+ 'anyOf',
94
+ 'oneOf',
95
+ 'not',
96
+ 'if',
97
+ 'then',
98
+ 'else',
99
+ '$defs',
100
+ 'definitions',
101
+ 'nullable',
102
+ 'readOnly',
103
+ 'writeOnly',
104
+ 'deprecated',
105
+ 'default',
106
+ 'examples',
107
+ 'example',
108
+ 'title',
109
+ 'description',
110
+ '$comment',
111
+ ] as const;
112
+ const KEY_RANK = new Map<string, number>(KEY_ORDER.map((key, index) => [key, index]));
113
+ const SCHEMA_MAP_KEYS = new Set(['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']);
114
+ const SCHEMA_VALUE_KEYS = new Set([
115
+ 'additionalProperties',
116
+ 'unevaluatedProperties',
117
+ 'propertyNames',
118
+ 'contains',
119
+ 'additionalItems',
120
+ 'unevaluatedItems',
121
+ 'not',
122
+ 'if',
123
+ 'then',
124
+ 'else',
125
+ 'contentSchema',
126
+ ]);
127
+ const SCHEMA_ARRAY_KEYS = new Set(['prefixItems', 'allOf', 'anyOf', 'oneOf']);
128
+
129
+ function validateConversionOptions(options: InternalZodToJsonSchemaOptions, internal: boolean): void {
130
+ if (!isRecord(options)) throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'Zod conversion options must be an object', { at: 'options', hint: 'pass an options object or omit it' });
131
+ const unknown = Object.keys(options).find((key) => !['target', '$schema', 'io', 'onWarning'].includes(key));
132
+ if (unknown) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unknown Zod conversion option: ${unknown}`, { at: unknown, hint: 'remove the unsupported option' });
133
+ const targets = internal ? ['draft-4', 'draft-07', 'draft-2020-12', 'openapi-3.0', 'openapi-3.1'] : ['draft-07', 'draft-2020-12', 'openapi-3.0', 'openapi-3.1'];
134
+ if (options.target !== undefined && (typeof options.target !== 'string' || !targets.includes(options.target))) throw new ZopiaError('ZOPIA_CONFIG_INVALID', `unsupported Zod conversion target: ${String(options.target)}`, { at: 'target', hint: `use ${targets.join(', ')}` });
135
+ if (options.$schema !== undefined && typeof options.$schema !== 'boolean') throw new ZopiaError('ZOPIA_CONFIG_INVALID', '$schema must be a boolean', { at: '$schema' });
136
+ if (options.io !== undefined && options.io !== 'input' && options.io !== 'output') throw new ZopiaError('ZOPIA_CONFIG_INVALID', "io must be 'input' or 'output'", { at: 'io' });
137
+ if (options.onWarning !== undefined && typeof options.onWarning !== 'function') throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'onWarning must be a function', { at: 'onWarning' });
138
+ }
139
+
140
+ function isZodSchema(value: unknown): value is z.ZodType {
141
+ return isRecord(value) && isRecord(value._zod) && typeof value._zod.run === 'function';
142
+ }
143
+
144
+ interface SanitizedSchemas {
145
+ schemas: z.ZodType[];
146
+ originals: WeakMap<object, z.core.$ZodType>;
147
+ invalidDefaults: WeakSet<object>;
148
+ invalidMetadata: WeakSet<object>;
149
+ }
150
+
151
+ function isJsonValue(value: unknown, active = new Set<object>()): boolean {
152
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
153
+ if (typeof value === 'number') return Number.isFinite(value);
154
+ if (!value || typeof value !== 'object' || active.has(value)) return false;
155
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
156
+ if (Object.getOwnPropertySymbols(value).length) return false;
157
+ active.add(value);
158
+ const valid = Array.isArray(value)
159
+ ? Object.keys(value).length === value.length && value.every((child, index) => Object.prototype.hasOwnProperty.call(value, index) && isJsonValue(child, active))
160
+ : Object.values(value).every((child) => isJsonValue(child, active));
161
+ active.delete(value);
162
+ return valid;
163
+ }
164
+
165
+ function sanitizeUnsupportedDefaults(roots: z.ZodType[]): SanitizedSchemas {
166
+ const schemas = new WeakMap<object, z.ZodType>();
167
+ const originals = new WeakMap<object, z.core.$ZodType>();
168
+ const invalidDefaults = new WeakSet<object>();
169
+ const invalidMetadata = new WeakSet<object>();
170
+
171
+ const replace = (schema: z.ZodType, category: WeakSet<object>): z.ZodType => {
172
+ const replacement = z.custom();
173
+ schemas.set(schema, replacement);
174
+ originals.set(replacement, schema);
175
+ category.add(replacement);
176
+ return replacement;
177
+ };
178
+
179
+ const sanitize = (schema: z.ZodType): z.ZodType => {
180
+ const cached = schemas.get(schema);
181
+ if (cached) return cached;
182
+ const metadata = z.globalRegistry.get(schema);
183
+ if (metadata && Object.entries(metadata).some(([key, value]) => key !== 'id' && !isJsonValue(value))) return replace(schema, invalidMetadata);
184
+ const definition = schema._zod.def as unknown as Record<string, unknown>;
185
+ let defaultValue: unknown;
186
+ if (definition.type === 'default') {
187
+ try { defaultValue = definition.defaultValue; }
188
+ catch { defaultValue = Symbol('invalid default'); }
189
+ if (!isJsonValue(defaultValue)) return replace(schema, invalidDefaults);
190
+ }
191
+
192
+ let changed = definition.type === 'default';
193
+ const mapValue = (value: unknown): unknown => {
194
+ if (isZodSchema(value)) {
195
+ const converted = sanitize(value);
196
+ if (converted !== value) changed = true;
197
+ return converted;
198
+ }
199
+ if (Array.isArray(value)) {
200
+ const converted = value.map(mapValue);
201
+ if (converted.some((child, index) => child !== value[index])) changed = true;
202
+ return converted;
203
+ }
204
+ if (isRecord(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
205
+ const converted = Object.fromEntries(Object.entries(value).map(([key, child]) => [key, mapValue(child)]));
206
+ if (Object.keys(converted).some((key) => converted[key] !== value[key])) changed = true;
207
+ return converted;
208
+ }
209
+ return value;
210
+ };
211
+
212
+ const convertedDefinition: Record<string, unknown> = {};
213
+ for (const key of Object.keys(definition)) {
214
+ if (definition.type === 'default' && key === 'defaultValue') {
215
+ convertedDefinition[key] = defaultValue;
216
+ continue;
217
+ }
218
+ const value = definition[key];
219
+ if (definition.type === 'lazy' && key === 'getter' && typeof value === 'function') {
220
+ convertedDefinition[key] = () => sanitize(value());
221
+ changed = true;
222
+ } else convertedDefinition[key] = mapValue(value);
223
+ }
224
+ if (!changed) {
225
+ schemas.set(schema, schema);
226
+ return schema;
227
+ }
228
+ const converted = schema.clone(convertedDefinition as any);
229
+ schemas.set(schema, converted);
230
+ originals.set(converted, schema);
231
+ return converted;
232
+ };
233
+
234
+ return { schemas: roots.map(sanitize), originals, invalidDefaults, invalidMetadata };
235
+ }
236
+
237
+ /**
238
+ * Convert a Zod 4 schema to canonical JSON Schema or an OpenAPI Schema Object.
239
+ *
240
+ * @param schema Zod 4 schema to convert.
241
+ * @param options Target dialect, input/output mode, and warning callback.
242
+ * @returns Detached canonical JSON Schema or OpenAPI Schema Object.
243
+ * @throws {@link ZopiaError} when the schema or options are invalid.
244
+ */
245
+ export function zodToJsonSchema(
246
+ schema: z.ZodType,
247
+ options: ZodToJsonSchemaOptions = {},
248
+ ): Record<string, unknown> {
249
+ validateConversionOptions(options, false);
250
+ if (!isZodSchema(schema)) throw new ZopiaError('ZOPIA_SCHEMA_INVALID', 'expected a Zod 4 schema', { at: '#', hint: 'pass a Zod schema instance' });
251
+ try {
252
+ const target = options.target ?? 'openapi-3.1';
253
+ const warnings: ZopiaWarning[] = [];
254
+ const result = convert(schema, target, options, warnings);
255
+ const collector = new ZopiaWarningCollector(); collector.addAll(warnings);
256
+ for (const warning of collector.toArray()) options.onWarning?.(warning);
257
+ return result;
258
+ } catch (error) {
259
+ throw asZopiaError(error, 'ZOPIA_SCHEMA_INVALID', 'unable to convert Zod schema', { at: '#', hint: 'check the Zod schema and conversion options' });
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Convert a named set of Zod schemas while preserving references between them.
265
+ *
266
+ * @param schemas Unique schema-name and Zod 4 schema pairs.
267
+ * @param options Target dialect, input/output mode, and warning callback.
268
+ * @param uri Mapper from schema names to emitted reference identifiers.
269
+ * @returns Converted schemas keyed by their original names.
270
+ * @throws {@link ZopiaError} when schemas, options, or names are invalid.
271
+ */
272
+ export function zodSchemasToJsonSchema(
273
+ schemas: Iterable<readonly [string, z.ZodType]>,
274
+ options: InternalZodToJsonSchemaOptions = {},
275
+ uri: (name: string) => string = (name) => name,
276
+ ): Record<string, Record<string, unknown>> {
277
+ validateConversionOptions(options, true);
278
+ if (typeof uri !== 'function') throw new ZopiaError('ZOPIA_CONFIG_INVALID', 'schema URI mapper must be a function', { at: 'uri' });
279
+ try {
280
+ const target = options.target ?? 'openapi-3.1';
281
+ const zodTarget = zodTargetFor(target);
282
+ const entries = [...schemas];
283
+ for (const [name, schema] of entries) {
284
+ if (typeof name !== 'string' || !name) throw new ZopiaError('ZOPIA_SCHEMA_INVALID', 'named Zod schemas require non-empty names', { at: 'schemas' });
285
+ if (!isZodSchema(schema)) throw new ZopiaError('ZOPIA_SCHEMA_INVALID', `invalid named Zod schema: ${name}`, { at: name, hint: 'pass Zod 4 schema instances' });
286
+ }
287
+ const sanitized = sanitizeUnsupportedDefaults(entries.map(([, schema]) => schema));
288
+ const sanitizedEntries = entries.map(([name], index) => [name, sanitized.schemas[index]] as const);
289
+ const registry = z.registry<{ id?: string }>();
290
+ for (const [name, schema] of sanitizedEntries) registry.add(schema, { id: name });
291
+
292
+ const warnings: ZopiaWarning[] = [];
293
+ const unrepresentable = new WeakSet<object>();
294
+ const owners = indexNamedSchemaOwners(sanitizedEntries);
295
+ const converted = z.toJSONSchema(registry, {
296
+ target: zodTarget,
297
+ io: options.io ?? 'output',
298
+ uri,
299
+ metadata: metadataWithoutIds(sanitized.originals),
300
+ unrepresentable: warningHandler(warnings, unrepresentable, owners, sanitized.invalidDefaults, sanitized.invalidMetadata),
301
+ override: ({ zodSchema, jsonSchema }) => finalizeZodNode(zodSchema, jsonSchema, unrepresentable),
302
+ }).schemas as Record<string, Record<string, unknown>>;
303
+
304
+ const result: Record<string, Record<string, unknown>> = {};
305
+ for (const [name, schema] of Object.entries(converted)) {
306
+ finalizeDialect(schema, target, options.$schema);
307
+ delete schema.$id;
308
+ define(result, name, canonicalizeSchema(schema));
309
+ }
310
+ const collector = new ZopiaWarningCollector(); collector.addAll(warnings);
311
+ for (const warning of collector.toArray()) options.onWarning?.(warning);
312
+ return result;
313
+ } catch (error) {
314
+ throw asZopiaError(error, 'ZOPIA_SCHEMA_INVALID', 'unable to convert named Zod schemas', { at: 'schemas', hint: 'check schema names, values, and references' });
315
+ }
316
+ }
317
+
318
+ function convert(
319
+ schema: z.ZodType,
320
+ target: ZodJsonSchemaTarget,
321
+ options: ZodToJsonSchemaOptions,
322
+ warnings: ZopiaWarning[],
323
+ ): Record<string, unknown> {
324
+ const sanitized = sanitizeUnsupportedDefaults([schema]);
325
+ const unrepresentable = new WeakSet<object>();
326
+ const result = z.toJSONSchema(sanitized.schemas[0], {
327
+ target: zodTargetFor(target),
328
+ io: options.io ?? 'output',
329
+ metadata: metadataWithoutIds(sanitized.originals),
330
+ unrepresentable: warningHandler(warnings, unrepresentable, undefined, sanitized.invalidDefaults, sanitized.invalidMetadata),
331
+ override: ({ zodSchema, jsonSchema }) => finalizeZodNode(zodSchema, jsonSchema, unrepresentable),
332
+ }) as Record<string, unknown>;
333
+ finalizeDialect(result, target, options.$schema);
334
+ return canonicalizeSchema(result);
335
+ }
336
+
337
+ function zodTargetFor(target: InternalZodJsonSchemaTarget): 'draft-4' | 'draft-07' | 'draft-2020-12' | 'openapi-3.0' {
338
+ return target === 'openapi-3.1' ? 'draft-2020-12' : target;
339
+ }
340
+
341
+ function warningHandler(warnings: ZopiaWarning[], unrepresentable: WeakSet<object>, owners?: WeakMap<object, Set<string>>, invalidDefaults?: WeakSet<object>, invalidMetadata?: WeakSet<object>) {
342
+ return ({ zodSchema, path, message: sourceMessage }: { zodSchema: z.core.$ZodType; path: (string | number)[]; message: string }): 'any' => {
343
+ unrepresentable.add(zodSchema);
344
+ const message = invalidDefaults?.has(zodSchema)
345
+ ? 'Default value cannot be represented in JSON Schema'
346
+ : invalidMetadata?.has(zodSchema) ? 'Metadata cannot be represented in JSON Schema' : sourceMessage;
347
+ const names = owners?.get(zodSchema);
348
+ if (names?.size) for (const name of names) warnings.push({
349
+ code: 'ZOPIA_WARN_UNREPRESENTABLE',
350
+ at: jsonPointer([name, ...path]),
351
+ message,
352
+ });
353
+ else warnings.push({
354
+ code: 'ZOPIA_WARN_UNREPRESENTABLE',
355
+ ...(path.length === 0 ? {} : { at: jsonPointer(path) }),
356
+ message,
357
+ });
358
+ return 'any';
359
+ };
360
+ }
361
+
362
+ function indexNamedSchemaOwners(entries: Array<readonly [string, z.ZodType]>): WeakMap<object, Set<string>> {
363
+ const owners = new WeakMap<object, Set<string>>();
364
+ const roots = new WeakSet<object>(entries.map(([, schema]) => schema));
365
+ for (const [name, root] of entries) {
366
+ const seen = new WeakSet<object>();
367
+ const visit = (value: unknown): void => {
368
+ if (value === null || typeof value !== 'object' || seen.has(value)) return;
369
+ seen.add(value);
370
+ const record = value as Record<string, unknown>;
371
+ if (record._zod && typeof record._zod === 'object') {
372
+ if (value !== root && roots.has(value)) return;
373
+ const names = owners.get(value) ?? new Set<string>();
374
+ names.add(name);
375
+ owners.set(value, names);
376
+ visit((record._zod as Record<string, unknown>).def);
377
+ return;
378
+ }
379
+ if (Array.isArray(value)) for (const child of value) visit(child);
380
+ else if (value instanceof Map || value instanceof Set) for (const child of value.values()) visit(child);
381
+ else for (const child of Object.values(record)) visit(child);
382
+ };
383
+ visit(root);
384
+ }
385
+ return owners;
386
+ }
387
+
388
+ function jsonPointer(path: (string | number)[]): string {
389
+ return `#/${path.map((part) => String(part).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}`;
390
+ }
391
+
392
+ function metadataWithoutIds(originals?: WeakMap<object, z.core.$ZodType>): typeof z.globalRegistry {
393
+ return {
394
+ get(schema: z.core.$ZodType): Record<string, unknown> | undefined {
395
+ const metadata = z.globalRegistry.get(originals?.get(schema) ?? schema);
396
+ if (!metadata) return undefined;
397
+ const copy: Record<string, unknown> = { ...metadata };
398
+ delete copy.id;
399
+ return copy;
400
+ },
401
+ } as typeof z.globalRegistry;
402
+ }
403
+
404
+ function finalizeZodNode(
405
+ schema: z.core.$ZodType,
406
+ jsonSchema: Record<string, unknown>,
407
+ unrepresentable: WeakSet<object>,
408
+ ): void {
409
+ if (unrepresentable.has(schema)) {
410
+ for (const key of Object.keys(jsonSchema)) delete jsonSchema[key];
411
+ return;
412
+ }
413
+
414
+ stripSafeIntegerBounds(jsonSchema);
415
+ stripBuiltInFormatPattern(schema, jsonSchema);
416
+ }
417
+
418
+ function stripSafeIntegerBounds(schema: Record<string, unknown>): void {
419
+ if (schema.minimum === -MAX_SAFE_INTEGER) delete schema.minimum;
420
+ if (schema.maximum === MAX_SAFE_INTEGER) delete schema.maximum;
421
+ }
422
+
423
+ function stripBuiltInFormatPattern(schema: z.core.$ZodType, jsonSchema: Record<string, unknown>): void {
424
+ const format = typeof jsonSchema.format === 'string' ? jsonSchema.format : undefined;
425
+ if (!format || !KNOWN_FORMATS.has(format)) return;
426
+
427
+ const patterns = builtInPatternsFor(schema, format);
428
+ if (patterns.size === 0) return;
429
+ if (typeof jsonSchema.pattern === 'string' && patterns.has(jsonSchema.pattern)) delete jsonSchema.pattern;
430
+
431
+ if (!Array.isArray(jsonSchema.allOf)) return;
432
+ const remaining = jsonSchema.allOf.filter((member) => {
433
+ if (!isRecord(member) || typeof member.pattern !== 'string' || !patterns.has(member.pattern)) return true;
434
+ const keys = Object.keys(member);
435
+ return !keys.every((key) => key === 'type' || key === 'pattern') || (member.type !== undefined && member.type !== 'string');
436
+ });
437
+ if (remaining.length === 0) delete jsonSchema.allOf;
438
+ else jsonSchema.allOf = remaining;
439
+ }
440
+
441
+ function builtInPatternsFor(schema: z.core.$ZodType, outputFormat: string): Set<string> {
442
+ const patterns = new Set<string>();
443
+ const definition = schema._zod.def as unknown as Record<string, unknown>;
444
+ const checks = [
445
+ ...(definition.check === 'string_format' ? [schema] : []),
446
+ ...(Array.isArray(definition.checks) ? definition.checks : []),
447
+ ];
448
+
449
+ for (const check of checks) {
450
+ if (!isRecord(check) || !isRecord(check._zod) || !isRecord(check._zod.def)) continue;
451
+ const checkDefinition = check._zod.def;
452
+ if (checkDefinition.check !== 'string_format') continue;
453
+ const checkFormat = normalizedFormat(checkDefinition.format);
454
+ const pattern = checkDefinition.pattern;
455
+ if (checkFormat === outputFormat && pattern instanceof RegExp) patterns.add(pattern.source);
456
+ }
457
+ return patterns;
458
+ }
459
+
460
+ function normalizedFormat(format: unknown): string | undefined {
461
+ if (format === 'guid') return 'uuid';
462
+ if (format === 'url') return 'uri';
463
+ if (format === 'datetime') return 'date-time';
464
+ return typeof format === 'string' ? format : undefined;
465
+ }
466
+
467
+ function finalizeDialect(
468
+ result: Record<string, unknown>,
469
+ target: InternalZodJsonSchemaTarget,
470
+ includeDialect: boolean | undefined,
471
+ ): void {
472
+ const include = includeDialect ?? true;
473
+ if (!include) {
474
+ delete result.$schema;
475
+ return;
476
+ }
477
+
478
+ if (target === 'openapi-3.0') result.$schema = 'http://json-schema.org/draft-07/schema#';
479
+ else if (target === 'openapi-3.1') result.$schema = 'https://json-schema.org/draft/2020-12/schema';
480
+ }
481
+
482
+ function collapseRedundantLiteralIntersection(schema: Record<string, unknown>): Record<string, unknown> {
483
+ if (Object.keys(schema).length !== 1 || !Array.isArray(schema.allOf) || schema.allOf.length !== 2) return schema;
484
+ const [base, literal] = schema.allOf;
485
+ if (!isRecord(base) || !isRecord(literal) || Object.keys(base).length !== 1 || base.type !== literal.type || !Object.prototype.hasOwnProperty.call(literal, 'const')) return schema;
486
+ return { ...literal };
487
+ }
488
+
489
+ function canonicalizeSchema(schema: Record<string, unknown>): Record<string, unknown> {
490
+ const collapsed = collapseRedundantLiteralIntersection(schema);
491
+ const normalized = { ...collapsed };
492
+ if (isRecord(normalized.properties) && Object.keys(normalized.properties).length === 0) delete normalized.properties;
493
+ if (isRecord(normalized.propertyNames) && Object.keys(normalized.propertyNames).length === 1 && normalized.propertyNames.type === 'string') delete normalized.propertyNames;
494
+ const result: Record<string, unknown> = {};
495
+ const entries = Object.entries(normalized).sort(([left], [right]) => {
496
+ const leftRank = KEY_RANK.get(left) ?? Number.POSITIVE_INFINITY;
497
+ const rightRank = KEY_RANK.get(right) ?? Number.POSITIVE_INFINITY;
498
+ return leftRank - rightRank || (left < right ? -1 : left > right ? 1 : 0);
499
+ });
500
+
501
+ for (const [key, value] of entries) define(result, key, canonicalizeKeywordValue(key, value));
502
+ return result;
503
+ }
504
+
505
+ function canonicalizeKeywordValue(key: string, value: unknown): unknown {
506
+ if (SCHEMA_MAP_KEYS.has(key) && isRecord(value)) {
507
+ const result: Record<string, unknown> = {};
508
+ for (const [name, schema] of Object.entries(value)) {
509
+ define(result, name, isRecord(schema) ? canonicalizeSchema(schema) : schema);
510
+ }
511
+ return result;
512
+ }
513
+
514
+ if ((key === 'items' || SCHEMA_ARRAY_KEYS.has(key)) && Array.isArray(value)) {
515
+ return value.map((schema) => isRecord(schema) ? canonicalizeSchema(schema) : schema);
516
+ }
517
+ if ((key === 'items' || SCHEMA_VALUE_KEYS.has(key)) && isRecord(value)) return canonicalizeSchema(value);
518
+
519
+ if (key === 'dependencies' && isRecord(value)) {
520
+ const result: Record<string, unknown> = {};
521
+ for (const [name, dependency] of Object.entries(value)) {
522
+ define(result, name, isRecord(dependency) ? canonicalizeSchema(dependency) : dependency);
523
+ }
524
+ return result;
525
+ }
526
+
527
+ return value;
528
+ }
529
+
530
+ function isRecord(value: unknown): value is Record<string, unknown> {
531
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
532
+ }
533
+
534
+ function define(target: Record<string, unknown>, key: string, value: unknown): void {
535
+ Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
536
+ }