@pikku/openapi-parser 0.12.8 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { parseOpenAPISpec, computeContractHash, type ParsedSpec, type ParsedOperation, type ParsedParam, type ErrorResponse, type SecuritySchemeInfo, } from './parse-openapi.js';
2
- export { generateAddonFromOpenAPI, } from './codegen.js';
2
+ export { generateAddonFromOpenAPI } from './codegen.js';
3
3
  export { generateOperationNames, detectCommonPrefix, type NamedOperation, } from './naming.js';
4
- export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, } from './openapi-to-zod-schema.js';
4
+ export { type OpenAPISchema, type ZodCodegenContext, createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { parseOpenAPISpec, computeContractHash, } from './parse-openapi.js';
2
- export { generateAddonFromOpenAPI, } from './codegen.js';
2
+ export { generateAddonFromOpenAPI } from './codegen.js';
3
3
  export { generateOperationNames, detectCommonPrefix, } from './naming.js';
4
- export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, } from './openapi-to-zod-schema.js';
4
+ export { createContext, schemaToZod, schemaVarName, sanitizeTypeName, snakeToCamel, } from './openapi-to-zod-schema.js';
package/dist/naming.d.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  * Derives function/method names from HTTP method + path.
3
3
  * Uses operationId when available, otherwise generates from path segments.
4
4
  */
5
+ /**
6
+ * Ensure a name is a valid JS/TS identifier:
7
+ * - Not a reserved word
8
+ * - Does not start with a digit
9
+ * - Does not contain numeric separator patterns (e.g. 1_000)
10
+ */
11
+ export declare function sanitizeIdentifier(name: string): string;
5
12
  export declare function singularize(word: string): string;
6
13
  export declare function toCamelCase(str: string): string;
7
14
  export declare function toPascalCase(str: string): string;
package/dist/naming.js CHANGED
@@ -2,6 +2,112 @@
2
2
  * Derives function/method names from HTTP method + path.
3
3
  * Uses operationId when available, otherwise generates from path segments.
4
4
  */
5
+ /** JavaScript/TypeScript reserved words that cannot be used as identifiers */
6
+ const JS_RESERVED_WORDS = new Set([
7
+ // ECMAScript reserved words
8
+ 'abstract',
9
+ 'arguments',
10
+ 'await',
11
+ 'boolean',
12
+ 'break',
13
+ 'byte',
14
+ 'case',
15
+ 'catch',
16
+ 'char',
17
+ 'class',
18
+ 'const',
19
+ 'continue',
20
+ 'debugger',
21
+ 'default',
22
+ 'delete',
23
+ 'do',
24
+ 'double',
25
+ 'else',
26
+ 'enum',
27
+ 'eval',
28
+ 'export',
29
+ 'extends',
30
+ 'false',
31
+ 'final',
32
+ 'finally',
33
+ 'float',
34
+ 'for',
35
+ 'function',
36
+ 'goto',
37
+ 'if',
38
+ 'implements',
39
+ 'import',
40
+ 'in',
41
+ 'instanceof',
42
+ 'int',
43
+ 'interface',
44
+ 'let',
45
+ 'long',
46
+ 'native',
47
+ 'new',
48
+ 'null',
49
+ 'package',
50
+ 'private',
51
+ 'protected',
52
+ 'public',
53
+ 'return',
54
+ 'short',
55
+ 'static',
56
+ 'super',
57
+ 'switch',
58
+ 'synchronized',
59
+ 'this',
60
+ 'throw',
61
+ 'throws',
62
+ 'transient',
63
+ 'true',
64
+ 'try',
65
+ 'typeof',
66
+ 'undefined',
67
+ 'var',
68
+ 'void',
69
+ 'volatile',
70
+ 'while',
71
+ 'with',
72
+ 'yield',
73
+ // TypeScript additional reserved words in strict mode
74
+ 'as',
75
+ 'any',
76
+ 'declare',
77
+ 'get',
78
+ 'module',
79
+ 'require',
80
+ 'number',
81
+ 'object',
82
+ 'set',
83
+ 'string',
84
+ 'symbol',
85
+ 'type',
86
+ 'from',
87
+ 'of',
88
+ ]);
89
+ /**
90
+ * Ensure a name is a valid JS/TS identifier:
91
+ * - Not a reserved word
92
+ * - Does not start with a digit
93
+ * - Does not contain numeric separator patterns (e.g. 1_000)
94
+ */
95
+ export function sanitizeIdentifier(name) {
96
+ // Strip numeric separator patterns like 1_000 → 1000
97
+ name = name.replace(/(\d)_(\d)/g, '$1$2');
98
+ // Strip any remaining invalid characters
99
+ name = name.replace(/[^a-zA-Z0-9_$]/g, '');
100
+ // Prefix with 'n' if name starts with a digit (not underscore, since
101
+ // pikku inspector may strip leading underscores from type names)
102
+ if (/^[0-9]/.test(name)) {
103
+ name = 'n' + name;
104
+ }
105
+ // Prefix with underscore if name is a reserved word
106
+ if (JS_RESERVED_WORDS.has(name)) {
107
+ name = '_' + name;
108
+ }
109
+ return name || 'unnamed';
110
+ }
5
111
  const IRREGULAR_PLURALS = {
6
112
  addresses: 'address',
7
113
  statuses: 'status',
@@ -25,11 +131,18 @@ export function singularize(word) {
25
131
  return word.slice(0, -3) + 'y';
26
132
  }
27
133
  // -shes, -ches, -xes, -zes, -sses → drop "es"
28
- if (lower.endsWith('shes') || lower.endsWith('ches') || lower.endsWith('xes') || lower.endsWith('zes') || lower.endsWith('sses')) {
134
+ if (lower.endsWith('shes') ||
135
+ lower.endsWith('ches') ||
136
+ lower.endsWith('xes') ||
137
+ lower.endsWith('zes') ||
138
+ lower.endsWith('sses')) {
29
139
  return word.slice(0, -2);
30
140
  }
31
141
  // General: drop trailing "s" (covers courses→course, products→product, etc.)
32
- if (lower.endsWith('s') && !lower.endsWith('ss') && !lower.endsWith('us') && lower.length > 2) {
142
+ if (lower.endsWith('s') &&
143
+ !lower.endsWith('ss') &&
144
+ !lower.endsWith('us') &&
145
+ lower.length > 2) {
33
146
  return word.slice(0, -1);
34
147
  }
35
148
  return word;
@@ -37,6 +150,7 @@ export function singularize(word) {
37
150
  export function toCamelCase(str) {
38
151
  return str
39
152
  .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
153
+ .replace(/[^a-zA-Z0-9]+$/g, '') // strip trailing non-alphanumeric chars
40
154
  .replace(/^[A-Z]/, (c) => c.toLowerCase());
41
155
  }
42
156
  export function toPascalCase(str) {
@@ -75,10 +189,12 @@ export function detectCommonPrefix(paths) {
75
189
  /** Convert operationId to camelCase function name */
76
190
  function fromOperationId(operationId) {
77
191
  // operationId might be snake_case, camelCase, PascalCase, or kebab-case
78
- return toCamelCase(operationId
192
+ let name = toCamelCase(operationId
193
+ .replace(/[{}]/g, '') // strip curly braces from path parameters
79
194
  .replace(/[^a-zA-Z0-9_-]/g, '_')
80
- .replace(/_+/g, '_')
81
- .replace(/^_|_$/g, ''));
195
+ .replace(/[-_]+/g, '_')
196
+ .replace(/^_|_$/g, '')) || 'unnamed';
197
+ return sanitizeIdentifier(name);
82
198
  }
83
199
  const METHOD_PREFIXES = {
84
200
  get: 'get',
@@ -94,6 +210,11 @@ const METHOD_PREFIXES = {
94
210
  export function generateOperationNames(operations, commonPrefix) {
95
211
  const results = [];
96
212
  const usedNames = new Map();
213
+ // Track names case-insensitively to prevent file system collisions on
214
+ // case-insensitive systems (macOS, Windows). Two operationIds that differ
215
+ // only in casing (e.g. userGetOauth2Application vs userGetOAuth2Application)
216
+ // would produce files that collide on disk.
217
+ const usedNamesLower = new Map();
97
218
  for (const op of operations) {
98
219
  let name;
99
220
  if (op.operationId) {
@@ -102,13 +223,20 @@ export function generateOperationNames(operations, commonPrefix) {
102
223
  else {
103
224
  name = deriveNameFromPath(op.method, op.path, commonPrefix);
104
225
  }
105
- // Handle collisions
106
- const existing = usedNames.get(name) ?? 0;
226
+ // Ensure name is a valid JS/TS identifier
227
+ name = sanitizeIdentifier(name);
228
+ // Handle collisions (case-insensitive to avoid file system conflicts)
229
+ const nameLower = name.toLowerCase();
230
+ const existingExact = usedNames.get(name) ?? 0;
231
+ const existingCI = usedNamesLower.get(nameLower) ?? 0;
232
+ const existing = Math.max(existingExact, existingCI);
107
233
  if (existing > 0) {
108
234
  usedNames.set(name, existing + 1);
235
+ usedNamesLower.set(nameLower, existing + 1);
109
236
  name = `${name}${existing + 1}`;
110
237
  }
111
238
  usedNames.set(name, (usedNames.get(name) ?? 0) || 1);
239
+ usedNamesLower.set(name.toLowerCase(), (usedNamesLower.get(name.toLowerCase()) ?? 0) || 1);
112
240
  results.push({
113
241
  method: op.method,
114
242
  path: op.path,
@@ -124,10 +252,7 @@ function deriveNameFromPath(method, path, commonPrefix) {
124
252
  if (commonPrefix && cleanPath.startsWith(commonPrefix)) {
125
253
  cleanPath = '/' + cleanPath.slice(commonPrefix.length);
126
254
  }
127
- const segments = cleanPath
128
- .replace(/^\//, '')
129
- .split('/')
130
- .filter(Boolean);
255
+ const segments = cleanPath.replace(/^\//, '').split('/').filter(Boolean);
131
256
  const methodLower = method.toLowerCase();
132
257
  const prefix = METHOD_PREFIXES[methodLower] || methodLower;
133
258
  // Separate param and non-param segments
@@ -15,8 +15,8 @@ export interface OpenAPISchema {
15
15
  default?: unknown;
16
16
  minimum?: number;
17
17
  maximum?: number;
18
- exclusiveMinimum?: number;
19
- exclusiveMaximum?: number;
18
+ exclusiveMinimum?: number | boolean;
19
+ exclusiveMaximum?: number | boolean;
20
20
  minLength?: number;
21
21
  maxLength?: number;
22
22
  pattern?: string;
@@ -37,8 +37,25 @@ export interface ZodCodegenContext {
37
37
  usedRefs: Set<string>;
38
38
  /** Indent level for readability */
39
39
  indent: number;
40
+ /** Track schemas currently being processed to detect cycles */
41
+ seen: Set<OpenAPISchema>;
42
+ /**
43
+ * Map from resolved schema object identity to its component name.
44
+ * After $ref resolution, many references lose their $ref string and
45
+ * become the same JS object as the component schema. This map lets
46
+ * schemaToZod detect those resolved references by identity.
47
+ */
48
+ schemaIdentityMap: Map<object, string>;
49
+ /** Current nesting depth for preventing excessively deep types */
50
+ depth: number;
51
+ /** When true, convert snake_case property keys to camelCase in z.object() */
52
+ camelCase?: boolean;
40
53
  }
41
- export declare function createContext(schemaRefs?: Map<string, string>): ZodCodegenContext;
54
+ export declare function createContext(schemaRefs?: Map<string, string>, schemaIdentityMap?: Map<object, string>, opts?: {
55
+ camelCase?: boolean;
56
+ }): ZodCodegenContext;
57
+ /** Convert a snake_case or kebab-case string to camelCase */
58
+ export declare function snakeToCamel(s: string): string;
42
59
  /**
43
60
  * Convert a single OpenAPI schema to a Zod expression string.
44
61
  * Does NOT include .optional() — that's handled at the property level based on `required`.
@@ -46,9 +63,5 @@ export declare function createContext(schemaRefs?: Map<string, string>): ZodCode
46
63
  export declare function schemaToZod(schema: OpenAPISchema, ctx: ZodCodegenContext, opts?: {
47
64
  optional?: boolean;
48
65
  }): string;
49
- /**
50
- * Generate the Zod variable name for a component schema.
51
- * e.g. "PaginatedResponse" → "PaginatedResponseSchema"
52
- */
53
66
  export declare function sanitizeTypeName(name: string): string;
54
67
  export declare function schemaVarName(name: string): string;
@@ -3,18 +3,39 @@
3
3
  *
4
4
  * Chaining order: base → refinements → .nullable() → .optional() → .default() → .describe()
5
5
  */
6
- export function createContext(schemaRefs) {
6
+ export function createContext(schemaRefs, schemaIdentityMap, opts) {
7
7
  return {
8
8
  schemaRefs: schemaRefs ?? new Map(),
9
9
  usedRefs: new Set(),
10
10
  indent: 0,
11
+ seen: new Set(),
12
+ schemaIdentityMap: schemaIdentityMap ?? new Map(),
13
+ depth: 0,
14
+ camelCase: opts?.camelCase,
11
15
  };
12
16
  }
17
+ /** Convert a snake_case or kebab-case string to camelCase */
18
+ export function snakeToCamel(s) {
19
+ return s.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase());
20
+ }
13
21
  /**
14
22
  * Convert a single OpenAPI schema to a Zod expression string.
15
23
  * Does NOT include .optional() — that's handled at the property level based on `required`.
16
24
  */
17
25
  export function schemaToZod(schema, ctx, opts = {}) {
26
+ // Bug 5: Depth limit to prevent TS2589 "excessively deep" type errors
27
+ if (ctx.depth > 10) {
28
+ let code = 'z.any()';
29
+ if (schema.nullable)
30
+ code += '.nullable()';
31
+ if (opts.optional)
32
+ code += '.optional()';
33
+ if (schema.description) {
34
+ const safeDesc = schema.description.replace(/\*\//g, '* /');
35
+ code += `.describe(${JSON.stringify(safeDesc)})`;
36
+ }
37
+ return code;
38
+ }
18
39
  let code = schemaToZodBase(schema, ctx);
19
40
  // Refinements
20
41
  code = applyRefinements(code, schema);
@@ -26,18 +47,105 @@ export function schemaToZod(schema, ctx, opts = {}) {
26
47
  if (opts.optional) {
27
48
  code += '.optional()';
28
49
  }
29
- // Default
50
+ // Default — validate type compatibility and coerce mismatches
30
51
  if (schema.default !== undefined) {
31
- code += `.default(${JSON.stringify(schema.default)})`;
52
+ let defaultValue = schema.default;
53
+ if (schema.enum && schema.enum.length > 0) {
54
+ // For enum schemas, the default must exactly match one of the enum values.
55
+ // Don't apply type coercion — the Zod schema uses the original enum types
56
+ // (e.g. z.literal(1) not z.literal("1")), so coercing would cause TS2769.
57
+ const enumValues = new Set(schema.enum.map((v) => JSON.stringify(v)));
58
+ if (enumValues.has(JSON.stringify(defaultValue))) {
59
+ code += `.default(${JSON.stringify(defaultValue)})`;
60
+ }
61
+ // else: default not in enum — skip it entirely
62
+ }
63
+ else if (defaultValue === null && !schema.nullable) {
64
+ // skip null defaults unless nullable
65
+ }
66
+ else if (schema.type === 'object' && typeof defaultValue === 'string') {
67
+ // skip string defaults on object types (often a stray description)
68
+ }
69
+ else if (schema.properties &&
70
+ typeof defaultValue === 'object' &&
71
+ defaultValue !== null &&
72
+ !Array.isArray(defaultValue)) {
73
+ // For object schemas with properties, validate default keys match schema properties.
74
+ // Extra keys in the default cause TS2769 because z.object() rejects unknown keys.
75
+ const schemaKeys = new Set(Object.keys(schema.properties));
76
+ const defaultKeys = Object.keys(defaultValue);
77
+ const hasExtraKeys = defaultKeys.some((k) => !schemaKeys.has(k));
78
+ if (!hasExtraKeys) {
79
+ code += `.default(${JSON.stringify(defaultValue)})`;
80
+ }
81
+ // else: default has keys not in schema — skip it
82
+ }
83
+ else if (schema.type === 'string' &&
84
+ typeof defaultValue === 'object' &&
85
+ defaultValue !== null) {
86
+ // skip object/array defaults on string types
87
+ }
88
+ else if ((!schema.type || schema.type === 'object') &&
89
+ schema.properties &&
90
+ typeof defaultValue === 'string') {
91
+ // skip string defaults on implicit object types (has properties but no explicit type)
92
+ }
93
+ else if (schema.type === 'array' && !Array.isArray(defaultValue)) {
94
+ // skip type-mismatched defaults (non-array default on array type)
95
+ }
96
+ else if (schema.type !== 'array' && Array.isArray(defaultValue)) {
97
+ // skip array defaults on non-array types
98
+ }
99
+ else if (schema.type === 'boolean' && typeof defaultValue === 'string') {
100
+ defaultValue = defaultValue === 'true';
101
+ code += `.default(${JSON.stringify(defaultValue)})`;
102
+ }
103
+ else if ((schema.type === 'number' || schema.type === 'integer') &&
104
+ typeof defaultValue === 'string') {
105
+ const parsed = Number(defaultValue);
106
+ if (!isNaN(parsed))
107
+ code += `.default(${JSON.stringify(parsed)})`;
108
+ }
109
+ else if (schema.type === 'string' && typeof defaultValue === 'number') {
110
+ code += `.default(${JSON.stringify(String(defaultValue))})`;
111
+ }
112
+ else if (schema.type === 'string' && typeof defaultValue === 'boolean') {
113
+ code += `.default(${JSON.stringify(String(defaultValue))})`;
114
+ }
115
+ else if (schema.type === 'boolean' && typeof defaultValue !== 'boolean') {
116
+ code += `.default(${defaultValue === 'true' || defaultValue === 1})`;
117
+ }
118
+ else if ((schema.type === 'number' || schema.type === 'integer') &&
119
+ typeof defaultValue === 'boolean') {
120
+ code += `.default(${defaultValue ? 1 : 0})`;
121
+ }
122
+ else {
123
+ code += `.default(${JSON.stringify(defaultValue)})`;
124
+ }
32
125
  }
33
- // Description
126
+ // Description — sanitize */ to prevent breaking JSDoc comments downstream
34
127
  if (schema.description) {
35
- code += `.describe(${JSON.stringify(schema.description)})`;
128
+ const safeDesc = schema.description.replace(/\*\//g, '* /');
129
+ code += `.describe(${JSON.stringify(safeDesc)})`;
36
130
  }
37
131
  return code;
38
132
  }
39
133
  function schemaToZodBase(schema, ctx) {
40
- // Handle $ref
134
+ // Cycle detection: if we've already started processing this exact schema
135
+ // object, return z.any() to break infinite recursion
136
+ if (ctx.seen.has(schema)) {
137
+ return 'z.any()';
138
+ }
139
+ ctx.seen.add(schema);
140
+ try {
141
+ return schemaToZodBaseInner(schema, ctx);
142
+ }
143
+ finally {
144
+ ctx.seen.delete(schema);
145
+ }
146
+ }
147
+ function schemaToZodBaseInner(schema, ctx) {
148
+ // Handle $ref (surviving after resolution)
41
149
  if (schema.$ref) {
42
150
  const refName = schema.$ref.split('/').pop();
43
151
  const zodName = ctx.schemaRefs.get(refName);
@@ -48,6 +156,18 @@ function schemaToZodBase(schema, ctx) {
48
156
  // Unknown ref — fallback to z.unknown()
49
157
  return 'z.unknown()';
50
158
  }
159
+ // Handle resolved $ref by object identity: if this schema object IS a
160
+ // known component schema, reference the variable instead of inlining.
161
+ if (ctx.schemaIdentityMap.size > 0) {
162
+ const identityName = ctx.schemaIdentityMap.get(schema);
163
+ if (identityName) {
164
+ const zodName = ctx.schemaRefs.get(identityName);
165
+ if (zodName) {
166
+ ctx.usedRefs.add(identityName);
167
+ return zodName;
168
+ }
169
+ }
170
+ }
51
171
  // Handle allOf — merge into single object
52
172
  if (schema.allOf && schema.allOf.length > 0) {
53
173
  return handleAllOf(schema.allOf, ctx);
@@ -82,6 +202,13 @@ function schemaToZodBase(schema, ctx) {
82
202
  if (schema.properties) {
83
203
  return handleObject(schema, ctx);
84
204
  }
205
+ // Bug 4: Infer number type when minimum/maximum present but no type
206
+ if (schema.minimum !== undefined ||
207
+ schema.maximum !== undefined ||
208
+ schema.exclusiveMinimum !== undefined ||
209
+ schema.exclusiveMaximum !== undefined) {
210
+ return 'z.number()';
211
+ }
85
212
  return 'z.unknown()';
86
213
  }
87
214
  }
@@ -103,24 +230,28 @@ function handleString(schema) {
103
230
  }
104
231
  }
105
232
  function handleEnum(values) {
106
- // Single value — use z.literal
107
- if (values.length === 1) {
108
- return `z.literal(${JSON.stringify(values[0])})`;
233
+ const primitives = values.filter((v) => v === null ||
234
+ typeof v === 'string' ||
235
+ typeof v === 'number' ||
236
+ typeof v === 'boolean');
237
+ if (primitives.length === 0)
238
+ return 'z.unknown()';
239
+ if (primitives.length === 1) {
240
+ return `z.literal(${JSON.stringify(primitives[0])})`;
109
241
  }
110
- // If all values are strings, use z.enum
111
- if (values.every((v) => typeof v === 'string')) {
112
- const enumValues = values.map((v) => JSON.stringify(v)).join(', ');
242
+ if (primitives.every((v) => typeof v === 'string')) {
243
+ const enumValues = primitives.map((v) => JSON.stringify(v)).join(', ');
113
244
  return `z.enum([${enumValues}])`;
114
245
  }
115
- // Mixed types — use z.union of z.literal
116
- const literals = values
246
+ const literals = primitives
117
247
  .map((v) => `z.literal(${JSON.stringify(v)})`)
118
248
  .join(', ');
119
249
  return `z.union([${literals}])`;
120
250
  }
121
251
  function handleArray(schema, ctx) {
252
+ const innerCtx = { ...ctx, depth: ctx.depth + 1 };
122
253
  const itemsZod = schema.items
123
- ? schemaToZodBase(schema.items, ctx)
254
+ ? schemaToZodBase(schema.items, innerCtx)
124
255
  : 'z.unknown()';
125
256
  return `z.array(${itemsZod})`;
126
257
  }
@@ -138,7 +269,11 @@ function handleObject(schema, ctx) {
138
269
  }
139
270
  return 'z.record(z.string(), z.unknown())';
140
271
  }
141
- const inner = { ...ctx, indent: ctx.indent + 1 };
272
+ const inner = {
273
+ ...ctx,
274
+ indent: ctx.indent + 1,
275
+ depth: ctx.depth + 1,
276
+ };
142
277
  const pad = indent(inner);
143
278
  const closePad = indent(ctx);
144
279
  const requiredSet = new Set(schema.required ?? []);
@@ -146,22 +281,34 @@ function handleObject(schema, ctx) {
146
281
  for (const [key, propSchema] of Object.entries(schema.properties)) {
147
282
  const isOptional = !requiredSet.has(key);
148
283
  const propZod = schemaToZod(propSchema, inner, { optional: isOptional });
149
- entries.push(`${pad}${safeKey(key)}: ${propZod},`);
284
+ const outputKey = ctx.camelCase ? snakeToCamel(key) : key;
285
+ entries.push(`${pad}${safeKey(outputKey)}: ${propZod},`);
150
286
  }
151
287
  return `z.object({\n${entries.join('\n')}\n${closePad}})`;
152
288
  }
153
289
  function handleAllOf(schemas, ctx) {
290
+ // Helper to check if a sub-schema is a reference (by $ref or identity map)
291
+ function getRefName(sub) {
292
+ if (sub.$ref) {
293
+ return sub.$ref.split('/').pop();
294
+ }
295
+ if (ctx.schemaIdentityMap.size > 0) {
296
+ const identityName = ctx.schemaIdentityMap.get(sub);
297
+ if (identityName && ctx.schemaRefs.has(identityName)) {
298
+ return identityName;
299
+ }
300
+ }
301
+ return undefined;
302
+ }
154
303
  // Collect all properties from all schemas
155
304
  const mergedProps = {};
156
305
  const mergedRequired = [];
157
306
  for (const sub of schemas) {
158
- if (sub.$ref) {
159
- const refName = sub.$ref.split('/').pop();
307
+ const refName = getRefName(sub);
308
+ if (refName) {
160
309
  const zodName = ctx.schemaRefs.get(refName);
161
310
  if (zodName) {
162
311
  ctx.usedRefs.add(refName);
163
- // If it's a pure ref in an allOf, we'd need to merge/extend
164
- // For simplicity, if we have refs mixed with objects, use .merge()
165
312
  }
166
313
  }
167
314
  if (sub.properties) {
@@ -172,11 +319,11 @@ function handleAllOf(schemas, ctx) {
172
319
  }
173
320
  }
174
321
  // If we have only refs, use .merge()
175
- const refSchemas = schemas.filter((s) => s.$ref);
176
- const objSchemas = schemas.filter((s) => !s.$ref && (s.properties || s.type === 'object'));
322
+ const refSchemas = schemas.filter((s) => getRefName(s) !== undefined);
323
+ const objSchemas = schemas.filter((s) => getRefName(s) === undefined && (s.properties || s.type === 'object'));
177
324
  if (refSchemas.length > 0 && objSchemas.length === 0) {
178
325
  const parts = refSchemas.map((s) => {
179
- const refName = s.$ref.split('/').pop();
326
+ const refName = getRefName(s);
180
327
  ctx.usedRefs.add(refName);
181
328
  return ctx.schemaRefs.get(refName) || 'z.object({})';
182
329
  });
@@ -202,9 +349,23 @@ function handleUnion(schemas, ctx) {
202
349
  return `z.union([${members.join(', ')}])`;
203
350
  }
204
351
  function applyRefinements(code, schema) {
352
+ // Don't apply refinements to types that don't support .min()/.max()/.regex() etc.
353
+ // This includes z.any(), z.unknown(), z.union(...), z.literal(...), z.enum(...),
354
+ // z.lazy(...), z.record(...), and schema references (variable names without z. prefix).
355
+ if (code === 'z.any()' ||
356
+ code === 'z.unknown()' ||
357
+ code.startsWith('z.union(') ||
358
+ code.startsWith('z.literal(') ||
359
+ code.startsWith('z.enum(') ||
360
+ code.startsWith('z.lazy(') ||
361
+ code.startsWith('z.record(') ||
362
+ // Schema variable references (e.g. FooSchema) — no z. prefix
363
+ (!code.startsWith('z.') && /^[a-zA-Z_$]/.test(code))) {
364
+ return code;
365
+ }
205
366
  let result = code;
206
- // String refinements
207
- if (schema.type === 'string' || (!schema.type && !schema.format)) {
367
+ // String refinements — only apply to explicit string types
368
+ if (schema.type === 'string' && !schema.enum) {
208
369
  if (schema.minLength !== undefined) {
209
370
  result += `.min(${schema.minLength})`;
210
371
  }
@@ -215,18 +376,38 @@ function applyRefinements(code, schema) {
215
376
  result += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`;
216
377
  }
217
378
  }
218
- // Number refinements
219
- if (schema.type === 'number' || schema.type === 'integer') {
379
+ // Number refinements — also apply when no type but min/max present (inferred number)
380
+ const isNumberType = schema.type === 'number' ||
381
+ schema.type === 'integer' ||
382
+ (!schema.type &&
383
+ !schema.properties &&
384
+ (schema.minimum !== undefined ||
385
+ schema.maximum !== undefined ||
386
+ schema.exclusiveMinimum !== undefined ||
387
+ schema.exclusiveMaximum !== undefined));
388
+ if (isNumberType && !schema.enum) {
389
+ const exMinIsBoolean = typeof schema.exclusiveMinimum === 'boolean';
390
+ const exMaxIsBoolean = typeof schema.exclusiveMaximum === 'boolean';
220
391
  if (schema.minimum !== undefined) {
221
- result += `.min(${schema.minimum})`;
392
+ if (exMinIsBoolean && schema.exclusiveMinimum) {
393
+ result += `.gt(${schema.minimum})`;
394
+ }
395
+ else {
396
+ result += `.min(${schema.minimum})`;
397
+ }
222
398
  }
223
399
  if (schema.maximum !== undefined) {
224
- result += `.max(${schema.maximum})`;
400
+ if (exMaxIsBoolean && schema.exclusiveMaximum) {
401
+ result += `.lt(${schema.maximum})`;
402
+ }
403
+ else {
404
+ result += `.max(${schema.maximum})`;
405
+ }
225
406
  }
226
- if (schema.exclusiveMinimum !== undefined) {
407
+ if (!exMinIsBoolean && schema.exclusiveMinimum !== undefined) {
227
408
  result += `.gt(${schema.exclusiveMinimum})`;
228
409
  }
229
- if (schema.exclusiveMaximum !== undefined) {
410
+ if (!exMaxIsBoolean && schema.exclusiveMaximum !== undefined) {
230
411
  result += `.lt(${schema.exclusiveMaximum})`;
231
412
  }
232
413
  }
@@ -252,8 +433,39 @@ function safeKey(key) {
252
433
  * Generate the Zod variable name for a component schema.
253
434
  * e.g. "PaginatedResponse" → "PaginatedResponseSchema"
254
435
  */
436
+ const RESERVED_TYPE_NAMES = new Set([
437
+ 'object',
438
+ 'string',
439
+ 'number',
440
+ 'boolean',
441
+ 'symbol',
442
+ 'undefined',
443
+ 'null',
444
+ 'void',
445
+ 'never',
446
+ 'any',
447
+ 'unknown',
448
+ 'bigint',
449
+ 'function',
450
+ 'class',
451
+ 'enum',
452
+ 'interface',
453
+ 'type',
454
+ 'import',
455
+ 'export',
456
+ ]);
255
457
  export function sanitizeTypeName(name) {
256
- return name.replace(/[^a-zA-Z0-9_$]/g, '');
458
+ // Strip numeric separator patterns (e.g. 1_000 → 1000)
459
+ let cleaned = name.replace(/(\d)_(\d)/g, '$1$2');
460
+ cleaned = cleaned.replace(/[^a-zA-Z0-9_$]/g, '');
461
+ // Prefix with 'n' if starts with a digit
462
+ if (/^[0-9]/.test(cleaned)) {
463
+ cleaned = 'n' + cleaned;
464
+ }
465
+ if (RESERVED_TYPE_NAMES.has(cleaned.toLowerCase())) {
466
+ return `${cleaned}Type`;
467
+ }
468
+ return cleaned;
257
469
  }
258
470
  export function schemaVarName(name) {
259
471
  const sanitized = sanitizeTypeName(name);