@gabrielbryk/json-schema-to-zod 2.10.1 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +6 -33
  4. package/check-types-lift.sh +23 -0
  5. package/check-types.sh +20 -0
  6. package/dist/{esm/cli.js → cli.js} +0 -6
  7. package/dist/{esm/core → core}/analyzeSchema.js +4 -5
  8. package/dist/core/emitZod.js +263 -0
  9. package/dist/{esm/generators → generators}/generateBundle.js +26 -13
  10. package/dist/{esm/index.js → index.js} +6 -0
  11. package/dist/jsonSchemaToZod.js +17 -0
  12. package/dist/parsers/parseAllOf.js +125 -0
  13. package/dist/parsers/parseAnyOf.js +28 -0
  14. package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
  15. package/dist/parsers/parseBoolean.js +4 -0
  16. package/dist/parsers/parseConst.js +22 -0
  17. package/dist/parsers/parseEnum.js +35 -0
  18. package/dist/{esm/parsers → parsers}/parseIfThenElse.js +10 -6
  19. package/dist/parsers/parseMultipleType.js +10 -0
  20. package/dist/parsers/parseNot.js +14 -0
  21. package/dist/parsers/parseNull.js +4 -0
  22. package/dist/parsers/parseNullable.js +12 -0
  23. package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
  24. package/dist/{esm/parsers → parsers}/parseObject.js +200 -37
  25. package/dist/parsers/parseOneOf.js +365 -0
  26. package/dist/{esm/parsers → parsers}/parseSchema.js +55 -117
  27. package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
  28. package/dist/{esm/parsers → parsers}/parseString.js +29 -18
  29. package/dist/types/Types.d.ts +32 -4
  30. package/dist/types/core/analyzeSchema.d.ts +3 -2
  31. package/dist/types/generators/generateBundle.d.ts +0 -2
  32. package/dist/types/index.d.ts +6 -0
  33. package/dist/types/parsers/parseAllOf.d.ts +2 -2
  34. package/dist/types/parsers/parseAnyOf.d.ts +2 -2
  35. package/dist/types/parsers/parseArray.d.ts +2 -2
  36. package/dist/types/parsers/parseBoolean.d.ts +2 -1
  37. package/dist/types/parsers/parseConst.d.ts +2 -2
  38. package/dist/types/parsers/parseDefault.d.ts +2 -2
  39. package/dist/types/parsers/parseEnum.d.ts +2 -2
  40. package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
  41. package/dist/types/parsers/parseMultipleType.d.ts +2 -2
  42. package/dist/types/parsers/parseNot.d.ts +2 -2
  43. package/dist/types/parsers/parseNull.d.ts +2 -1
  44. package/dist/types/parsers/parseNullable.d.ts +2 -2
  45. package/dist/types/parsers/parseNumber.d.ts +2 -2
  46. package/dist/types/parsers/parseObject.d.ts +2 -2
  47. package/dist/types/parsers/parseOneOf.d.ts +2 -2
  48. package/dist/types/parsers/parseSchema.d.ts +2 -2
  49. package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
  50. package/dist/types/parsers/parseString.d.ts +2 -2
  51. package/dist/types/utils/anyOrUnknown.d.ts +5 -4
  52. package/dist/types/utils/esmEmitter.d.ts +29 -0
  53. package/dist/types/utils/extractInlineObject.d.ts +15 -0
  54. package/dist/types/utils/liftInlineObjects.d.ts +21 -0
  55. package/dist/types/utils/namingService.d.ts +21 -0
  56. package/dist/types/utils/resolveRef.d.ts +7 -0
  57. package/dist/types/utils/schemaRepresentation.d.ts +71 -0
  58. package/dist/utils/anyOrUnknown.js +13 -0
  59. package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
  60. package/dist/utils/esmEmitter.js +87 -0
  61. package/dist/utils/extractInlineObject.js +119 -0
  62. package/dist/utils/liftInlineObjects.js +476 -0
  63. package/dist/utils/namingService.js +58 -0
  64. package/dist/utils/resolveRef.js +92 -0
  65. package/dist/utils/schemaRepresentation.js +569 -0
  66. package/docs/IMPROVEMENT-PLAN.md +243 -0
  67. package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
  68. package/docs/proposals/bundle-refactor.md +1 -1
  69. package/docs/proposals/discriminated-union-with-default.md +248 -0
  70. package/docs/proposals/inline-object-lifting.md +77 -0
  71. package/eslint.config.js +4 -2
  72. package/jest.config.mjs +19 -0
  73. package/package.json +17 -20
  74. package/scripts/generateWorkflowSchema.ts +0 -1
  75. package/dist/cjs/Types.js +0 -2
  76. package/dist/cjs/cli.js +0 -70
  77. package/dist/cjs/core/analyzeSchema.js +0 -62
  78. package/dist/cjs/core/emitZod.js +0 -157
  79. package/dist/cjs/generators/generateBundle.js +0 -510
  80. package/dist/cjs/index.js +0 -50
  81. package/dist/cjs/jsonSchemaToZod.js +0 -10
  82. package/dist/cjs/package.json +0 -1
  83. package/dist/cjs/parsers/parseAllOf.js +0 -46
  84. package/dist/cjs/parsers/parseAnyOf.js +0 -18
  85. package/dist/cjs/parsers/parseArray.js +0 -90
  86. package/dist/cjs/parsers/parseBoolean.js +0 -5
  87. package/dist/cjs/parsers/parseConst.js +0 -7
  88. package/dist/cjs/parsers/parseDefault.js +0 -8
  89. package/dist/cjs/parsers/parseEnum.js +0 -21
  90. package/dist/cjs/parsers/parseIfThenElse.js +0 -35
  91. package/dist/cjs/parsers/parseMultipleType.js +0 -10
  92. package/dist/cjs/parsers/parseNot.js +0 -12
  93. package/dist/cjs/parsers/parseNull.js +0 -5
  94. package/dist/cjs/parsers/parseNullable.js +0 -12
  95. package/dist/cjs/parsers/parseNumber.js +0 -116
  96. package/dist/cjs/parsers/parseObject.js +0 -318
  97. package/dist/cjs/parsers/parseOneOf.js +0 -53
  98. package/dist/cjs/parsers/parseSchema.js +0 -419
  99. package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
  100. package/dist/cjs/parsers/parseString.js +0 -317
  101. package/dist/cjs/utils/anyOrUnknown.js +0 -14
  102. package/dist/cjs/utils/buildRefRegistry.js +0 -56
  103. package/dist/cjs/utils/cliTools.js +0 -108
  104. package/dist/cjs/utils/cycles.js +0 -113
  105. package/dist/cjs/utils/half.js +0 -7
  106. package/dist/cjs/utils/jsdocs.js +0 -20
  107. package/dist/cjs/utils/omit.js +0 -11
  108. package/dist/cjs/utils/resolveUri.js +0 -16
  109. package/dist/cjs/utils/withMessage.js +0 -21
  110. package/dist/cjs/zodToJsonSchema.js +0 -89
  111. package/dist/esm/core/emitZod.js +0 -153
  112. package/dist/esm/jsonSchemaToZod.js +0 -6
  113. package/dist/esm/package.json +0 -1
  114. package/dist/esm/parsers/parseAllOf.js +0 -43
  115. package/dist/esm/parsers/parseAnyOf.js +0 -14
  116. package/dist/esm/parsers/parseBoolean.js +0 -1
  117. package/dist/esm/parsers/parseConst.js +0 -3
  118. package/dist/esm/parsers/parseEnum.js +0 -17
  119. package/dist/esm/parsers/parseMultipleType.js +0 -6
  120. package/dist/esm/parsers/parseNot.js +0 -8
  121. package/dist/esm/parsers/parseNull.js +0 -1
  122. package/dist/esm/parsers/parseNullable.js +0 -8
  123. package/dist/esm/parsers/parseOneOf.js +0 -49
  124. package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
  125. package/dist/esm/utils/anyOrUnknown.js +0 -10
  126. package/jest.config.cjs +0 -4
  127. package/postcjs.cjs +0 -1
  128. package/postesm.cjs +0 -1
  129. /package/dist/{esm/Types.js → Types.js} +0 -0
  130. /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
  131. /package/dist/{esm/utils → utils}/cliTools.js +0 -0
  132. /package/dist/{esm/utils → utils}/cycles.js +0 -0
  133. /package/dist/{esm/utils → utils}/half.js +0 -0
  134. /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
  135. /package/dist/{esm/utils → utils}/omit.js +0 -0
  136. /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
  137. /package/dist/{esm/utils → utils}/withMessage.js +0 -0
  138. /package/dist/{esm/zodToJsonSchema.js → zodToJsonSchema.js} +0 -0
@@ -0,0 +1,119 @@
1
+ import { parseSchema } from "../parsers/parseSchema.js";
2
+ /**
3
+ * Rule 1 from Zod v4: Put Object Types at Top-Level
4
+ *
5
+ * Extracts inline object schemas to top-level declarations when they have a title.
6
+ * This prevents embedding object schema declarations inside unions/intersections
7
+ * which can break recursive type inference.
8
+ *
9
+ * We extract any titled object schema, including those with:
10
+ * - $refs to other schemas (dependency ordering handles this)
11
+ * - Composition keywords (oneOf/anyOf/allOf for validation or extension)
12
+ *
13
+ * @returns The reference name if extracted, or null if not extractable
14
+ */
15
+ export const extractInlineObject = (schema, refs, path) => {
16
+ // Skip if not an object
17
+ if (typeof schema !== "object" || schema === null) {
18
+ return null;
19
+ }
20
+ // Skip if it's a $ref - already handled by ref resolution
21
+ if ("$ref" in schema || "$dynamicRef" in schema) {
22
+ return null;
23
+ }
24
+ // Only extract objects with titles
25
+ const title = schema.title;
26
+ if (!title) {
27
+ return null;
28
+ }
29
+ const schemaObj = schema;
30
+ // Must be object-like: explicit type: object with properties
31
+ // Be conservative to avoid creating circular dependencies:
32
+ // - Skip if it has composition keywords (oneOf/anyOf/allOf) - these can create cycles
33
+ // - Skip if any property has a $ref - these can create ordering issues
34
+ if (schemaObj.type !== "object" || !schemaObj.properties) {
35
+ return null;
36
+ }
37
+ // Skip schemas with composition keywords as they can create circular type dependencies
38
+ if (schemaObj.anyOf || schemaObj.oneOf || schemaObj.allOf) {
39
+ return null;
40
+ }
41
+ // Skip if any property has a $ref - these can cause ordering issues
42
+ if (hasNestedRef(schemaObj)) {
43
+ return null;
44
+ }
45
+ // Generate a unique name from the title
46
+ const baseName = sanitizeIdentifier(title);
47
+ const refName = getUniqueName(baseName, refs.usedNames);
48
+ refs.usedNames?.add(refName);
49
+ // Check if already declared
50
+ if (refs.declarations?.has(refName)) {
51
+ return refName;
52
+ }
53
+ // Mark as in progress to handle potential recursion
54
+ refs.inProgress?.add(refName);
55
+ // Parse the schema to get its declaration
56
+ const parsed = parseSchema(schema, {
57
+ ...refs,
58
+ path,
59
+ currentSchemaName: refName,
60
+ });
61
+ refs.inProgress?.delete(refName);
62
+ // Add to declarations with type - parseSchema returns SchemaRepresentation directly
63
+ refs.declarations?.set(refName, parsed);
64
+ // Track dependencies - the extracted schema depends on current, and current depends on extracted
65
+ if (refs.currentSchemaName) {
66
+ const currentDeps = refs.dependencies?.get(refs.currentSchemaName) ?? new Set();
67
+ currentDeps.add(refName);
68
+ refs.dependencies?.set(refs.currentSchemaName, currentDeps);
69
+ }
70
+ return refName;
71
+ };
72
+ /**
73
+ * Check if a schema contains any $ref - these can cause ordering issues when extracted.
74
+ */
75
+ const hasNestedRef = (schema) => {
76
+ const checkValue = (value) => {
77
+ if (typeof value !== "object" || value === null) {
78
+ return false;
79
+ }
80
+ if (Array.isArray(value)) {
81
+ return value.some(checkValue);
82
+ }
83
+ const obj = value;
84
+ if (typeof obj.$ref === "string" || typeof obj.$dynamicRef === "string") {
85
+ return true;
86
+ }
87
+ for (const key of Object.keys(obj)) {
88
+ if (checkValue(obj[key])) {
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ };
94
+ return checkValue(schema);
95
+ };
96
+ const sanitizeIdentifier = (value) => {
97
+ // Convert to PascalCase and remove invalid characters
98
+ const words = value
99
+ .replace(/[^a-zA-Z0-9_$\s]/g, " ")
100
+ .split(/\s+/)
101
+ .filter(Boolean);
102
+ const pascalCase = words
103
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
104
+ .join("");
105
+ const cleaned = pascalCase.replace(/^[^a-zA-Z_$]+/, "").replace(/[^a-zA-Z0-9_$]/g, "");
106
+ return cleaned || "InlineSchema";
107
+ };
108
+ const getUniqueName = (baseName, used) => {
109
+ if (!used || !used.has(baseName)) {
110
+ return baseName;
111
+ }
112
+ let counter = 2;
113
+ let candidate = `${baseName}${counter}`;
114
+ while (used.has(candidate)) {
115
+ counter += 1;
116
+ candidate = `${baseName}${counter}`;
117
+ }
118
+ return candidate;
119
+ };
@@ -0,0 +1,476 @@
1
+ import { generateNameFromPath } from "./namingService.js";
2
+ import { buildRefRegistry } from "./buildRefRegistry.js";
3
+ import { resolveRef } from "./resolveRef.js";
4
+ import { resolveUri } from "./resolveUri.js";
5
+ /**
6
+ * Conservatively lift inline object-like schemas into top-level $defs.
7
+ * Skips when disabled or when candidates are ambiguous (contains $ref/dynamicRef).
8
+ */
9
+ export const liftInlineObjects = (schema, options) => {
10
+ if (!options.enable || typeof schema !== "object" || schema === null) {
11
+ return { schema, defs: getDefs(schema), addedDefNames: [], pathToDefName: new Map() };
12
+ }
13
+ // Clone to avoid mutating user-provided schema
14
+ const root = JSON.parse(JSON.stringify(schema));
15
+ const defs = getDefs(root);
16
+ const existingNames = new Set(Object.keys(defs));
17
+ const addedDefNames = [];
18
+ const pathToDefName = new Map();
19
+ const hashToDef = new Map();
20
+ const { registry: refRegistry, rootBaseUri } = buildRefRegistry(root);
21
+ const cyclePaths = computeCyclicPaths(root, refRegistry, rootBaseUri);
22
+ const parentBase = options.parentName ?? (typeof root.title === "string" ? root.title : "Root");
23
+ const transformed = visit(root, {
24
+ path: [],
25
+ inDefs: false,
26
+ parentName: parentBase,
27
+ defs,
28
+ existingNames,
29
+ addedDefNames,
30
+ pathToDefName,
31
+ nameForPath: options.nameForPath,
32
+ dedup: options.dedup === true,
33
+ hashToDef,
34
+ refRegistry,
35
+ rootBaseUri,
36
+ allowInDefs: options.allowInDefs !== false,
37
+ rootSchema: root,
38
+ cyclePaths,
39
+ });
40
+ // Persist defs back on root
41
+ transformed.$defs = defs;
42
+ return { schema: transformed, defs, addedDefNames, pathToDefName };
43
+ };
44
+ const allowedHoistContexts = [
45
+ "properties",
46
+ "patternProperties",
47
+ "additionalProperties",
48
+ "items",
49
+ "additionalItems",
50
+ "dependentSchemas",
51
+ "contains",
52
+ "unevaluatedProperties",
53
+ ];
54
+ const visit = (node, ctx) => {
55
+ if (Array.isArray(node)) {
56
+ return node.map((entry, index) => visit(entry, { ...ctx, path: [...ctx.path, index], context: ctx.context }));
57
+ }
58
+ if (typeof node !== "object" || node === null)
59
+ return node;
60
+ const obj = node;
61
+ const isRef = typeof obj.$ref === "string" || typeof obj.$dynamicRef === "string";
62
+ const isObjectLike = isObjectSchema(obj);
63
+ const canLift = isObjectLike &&
64
+ !isRef &&
65
+ ctx.path.length > 0 &&
66
+ ctx.context !== undefined &&
67
+ allowedHoistContexts.includes(ctx.context) &&
68
+ !subtreeHasCycle(obj, ctx, ctx.path) &&
69
+ // Allow refs inside the candidate; only block if the candidate itself is a ref/dynamicRef (handled above).
70
+ !isRecursiveRef(obj, ctx) &&
71
+ !isMetaOnly(obj);
72
+ if (canLift) {
73
+ const branchInfo = extractCallConst(ctx);
74
+ // Use schema's own title, fall back to parent schema title (e.g., from oneOf branch)
75
+ const schemaTitle = typeof obj.title === "string" ? obj.title : ctx.parentSchemaTitle;
76
+ const defName = generateNameFromPath({
77
+ parentName: ctx.parentName,
78
+ path: ctx.path,
79
+ existingNames: ctx.existingNames,
80
+ branchInfo,
81
+ schemaTitle,
82
+ nameForPath: ctx.nameForPath,
83
+ });
84
+ ctx.existingNames.add(defName);
85
+ ctx.addedDefNames.push(defName);
86
+ ctx.pathToDefName.set(ctx.path.join("/"), defName);
87
+ const candidateClone = deepTransform(obj, ctx, false);
88
+ const hash = ctx.dedup ? structuralHash(candidateClone) : null;
89
+ if (hash && ctx.hashToDef.has(hash)) {
90
+ const existingName = ctx.hashToDef.get(hash);
91
+ return { $ref: `#/$defs/${existingName}` };
92
+ }
93
+ if (hash) {
94
+ ctx.hashToDef.set(hash, defName);
95
+ }
96
+ ctx.defs[defName] = candidateClone;
97
+ return { $ref: `#/$defs/${defName}` };
98
+ }
99
+ return deepTransform(obj, ctx, false);
100
+ };
101
+ const deepTransform = (obj, ctx, forceInDefs) => {
102
+ const nextInDefs = ctx.inDefs || forceInDefs;
103
+ const clone = { ...obj };
104
+ // Extract current object's title to pass to children (e.g., oneOf branch title)
105
+ const currentTitle = typeof obj.title === "string" ? obj.title : undefined;
106
+ // $defs are handled via ctx.defs; skip hoisting inside them.
107
+ // properties - pass current schema's title as parentSchemaTitle for child properties
108
+ if (clone.properties && typeof clone.properties === "object") {
109
+ const newProps = {};
110
+ for (const [key, value] of Object.entries(clone.properties)) {
111
+ newProps[key] = visit(value, {
112
+ ...ctx,
113
+ path: [...ctx.path, key],
114
+ inDefs: nextInDefs,
115
+ context: "properties",
116
+ parentSchemaTitle: currentTitle,
117
+ });
118
+ }
119
+ clone.properties = newProps;
120
+ }
121
+ // $defs traversal (hoist inside defs if allowed)
122
+ if (clone.$defs && typeof clone.$defs === "object" && ctx.allowInDefs) {
123
+ const defsObj = clone.$defs;
124
+ for (const [key, value] of Object.entries(defsObj)) {
125
+ const visited = visit(value, { ...ctx, path: [...ctx.path, "$defs", key], inDefs: true, context: "root" });
126
+ ctx.defs[key] = visited;
127
+ }
128
+ clone.$defs = ctx.defs;
129
+ }
130
+ // patternProperties
131
+ if (clone.patternProperties && typeof clone.patternProperties === "object") {
132
+ const newPatterns = {};
133
+ for (const [key, value] of Object.entries(clone.patternProperties)) {
134
+ newPatterns[key] = visit(value, { ...ctx, path: [...ctx.path, key], inDefs: nextInDefs, context: "patternProperties" });
135
+ }
136
+ clone.patternProperties = newPatterns;
137
+ }
138
+ // additionalProperties
139
+ if (clone.additionalProperties && typeof clone.additionalProperties === "object") {
140
+ clone.additionalProperties = visit(clone.additionalProperties, {
141
+ ...ctx,
142
+ path: [...ctx.path, "additionalProperties"],
143
+ inDefs: nextInDefs,
144
+ context: "additionalProperties",
145
+ });
146
+ }
147
+ // items / additionalItems
148
+ if (clone.items) {
149
+ clone.items = visit(clone.items, { ...ctx, path: [...ctx.path, "items"], inDefs: nextInDefs, context: "items" });
150
+ }
151
+ if (clone.additionalItems) {
152
+ clone.additionalItems = visit(clone.additionalItems, {
153
+ ...ctx,
154
+ path: [...ctx.path, "additionalItems"],
155
+ inDefs: nextInDefs,
156
+ context: "additionalItems",
157
+ });
158
+ }
159
+ // compositions
160
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
161
+ if (Array.isArray(clone[keyword])) {
162
+ clone[keyword] = clone[keyword].map((entry, index) => visit(entry, { ...ctx, path: [...ctx.path, keyword, index], inDefs: nextInDefs, context: keyword }));
163
+ }
164
+ }
165
+ // conditionals
166
+ for (const keyword of ["if", "then", "else", "not", "contains", "unevaluatedProperties"]) {
167
+ if (clone[keyword]) {
168
+ clone[keyword] = visit(clone[keyword], { ...ctx, path: [...ctx.path, keyword], inDefs: nextInDefs, context: keyword });
169
+ }
170
+ }
171
+ // dependentSchemas
172
+ if (clone.dependentSchemas && typeof clone.dependentSchemas === "object") {
173
+ const newDeps = {};
174
+ for (const [key, value] of Object.entries(clone.dependentSchemas)) {
175
+ newDeps[key] = visit(value, {
176
+ ...ctx,
177
+ path: [...ctx.path, "dependentSchemas", key],
178
+ inDefs: nextInDefs,
179
+ context: "dependentSchemas",
180
+ });
181
+ }
182
+ clone.dependentSchemas = newDeps;
183
+ }
184
+ return clone;
185
+ };
186
+ const getDefs = (schema) => {
187
+ if (typeof schema === "object" && schema !== null && typeof schema.$defs === "object") {
188
+ return { ...schema.$defs };
189
+ }
190
+ return {};
191
+ };
192
+ const isObjectSchema = (schema) => {
193
+ if (schema.type === "object")
194
+ return true;
195
+ return Boolean(schema.properties || schema.patternProperties || schema.additionalProperties || schema.required || schema.unevaluatedProperties);
196
+ };
197
+ const isMetaOnly = (schema) => {
198
+ const keys = Object.keys(schema);
199
+ return keys.every((k) => ["title", "description", "$id", "$schema", "$anchor", "$dynamicAnchor", "examples"].includes(k));
200
+ };
201
+ const isRecursiveRef = (schema, ctx) => {
202
+ // Only guard when refs are present on the schema itself
203
+ const ref = typeof schema.$ref === "string" ? schema.$ref : typeof schema.$dynamicRef === "string" ? schema.$dynamicRef : null;
204
+ if (!ref)
205
+ return false;
206
+ const resolved = resolveRef(schema, ref, {
207
+ path: ctx.path,
208
+ seen: new Map(),
209
+ refRegistry: ctx.refRegistry,
210
+ rootBaseUri: ctx.rootBaseUri,
211
+ root: ctx.defs,
212
+ });
213
+ if (!resolved)
214
+ return false;
215
+ // If the resolved schema is this schema (self) or an ancestor along the path, treat as recursive
216
+ const pointerKey = resolved.pointerKey;
217
+ if (!pointerKey)
218
+ return false;
219
+ // Check if pointer resolves back to current path or its prefix
220
+ const currentPathStr = ctx.path.join("/");
221
+ const targetPathStr = resolved.path.join("/");
222
+ return targetPathStr === currentPathStr || currentPathStr.startsWith(targetPathStr);
223
+ };
224
+ const structuralHash = (schema) => {
225
+ const normalize = (value) => {
226
+ if (Array.isArray(value))
227
+ return value.map(normalize);
228
+ if (value && typeof value === "object") {
229
+ const obj = value;
230
+ const entries = Object.entries(obj)
231
+ .filter(([key]) => !["title", "description"].includes(key))
232
+ .sort(([a], [b]) => a.localeCompare(b))
233
+ .map(([k, v]) => [k, normalize(v)]);
234
+ return Object.fromEntries(entries);
235
+ }
236
+ return value;
237
+ };
238
+ const normalized = normalize(schema);
239
+ return JSON.stringify(normalized);
240
+ };
241
+ const extractCallConst = (ctx) => {
242
+ // Inspect the parent object (path minus last segment) for a call const to use as branch info
243
+ if (!ctx.rootSchema || ctx.path.length === 0)
244
+ return undefined;
245
+ const parentPath = ctx.path.slice(0, -1);
246
+ const parentNode = getAtPath(ctx.rootSchema, parentPath);
247
+ if (parentNode && typeof parentNode === "object" && parentNode.properties) {
248
+ const props = parentNode.properties;
249
+ const callProp = props["call"];
250
+ if (callProp && typeof callProp === "object" && callProp.const) {
251
+ const v = callProp.const;
252
+ if (typeof v === "string")
253
+ return v;
254
+ }
255
+ }
256
+ return undefined;
257
+ };
258
+ const getAtPath = (root, path) => {
259
+ let current = root;
260
+ for (const segment of path) {
261
+ if (typeof current !== "object" || current === null)
262
+ return undefined;
263
+ if (typeof segment === "number") {
264
+ if (Array.isArray(current) && segment < current.length) {
265
+ current = current[segment];
266
+ }
267
+ else {
268
+ return undefined;
269
+ }
270
+ }
271
+ else {
272
+ current = current[segment];
273
+ }
274
+ }
275
+ return current;
276
+ };
277
+ const normalizePath = (path) => {
278
+ const skip = new Set(["properties", "patternProperties", "dependentSchemas"]);
279
+ const normalized = [];
280
+ for (const segment of path) {
281
+ if (typeof segment === "string" && skip.has(segment))
282
+ continue;
283
+ normalized.push(segment);
284
+ }
285
+ return normalized.join("/");
286
+ };
287
+ const computeCyclicPaths = (schema, refRegistry, rootBaseUri) => {
288
+ const edges = new Map();
289
+ const nodes = new Set();
290
+ const addEdge = (from, to) => {
291
+ if (!edges.has(from))
292
+ edges.set(from, new Set());
293
+ edges.get(from).add(to);
294
+ };
295
+ const walk = (node, path, baseUri, ownerPath) => {
296
+ if (typeof node !== "object" || node === null)
297
+ return;
298
+ const obj = node;
299
+ const pathStr = normalizePath(path);
300
+ const ownerStr = normalizePath(ownerPath);
301
+ nodes.add(pathStr);
302
+ nodes.add(ownerStr);
303
+ const nextBase = typeof obj.$id === "string" ? resolveUri(baseUri, obj.$id) : baseUri;
304
+ const ref = typeof obj.$ref === "string" ? obj.$ref : typeof obj.$dynamicRef === "string" ? obj.$dynamicRef : obj.$recursiveRef;
305
+ if (typeof ref === "string") {
306
+ const resolved = resolveRef(obj, ref, {
307
+ path,
308
+ refRegistry,
309
+ rootBaseUri,
310
+ root: schema,
311
+ currentBaseUri: nextBase,
312
+ seen: new Map(),
313
+ });
314
+ if (resolved) {
315
+ const targetPath = normalizePath(resolved.path);
316
+ addEdge(ownerStr, targetPath);
317
+ nodes.add(targetPath);
318
+ }
319
+ }
320
+ // $defs
321
+ if (obj.$defs && typeof obj.$defs === "object") {
322
+ for (const [defKey, defVal] of Object.entries(obj.$defs)) {
323
+ const childPath = [...path, "$defs", defKey];
324
+ addEdge(ownerStr, normalizePath(childPath));
325
+ walk(defVal, childPath, nextBase, childPath);
326
+ }
327
+ }
328
+ // properties
329
+ if (obj.properties && typeof obj.properties === "object") {
330
+ for (const [propKey, propVal] of Object.entries(obj.properties)) {
331
+ const childPath = [...path, propKey];
332
+ addEdge(ownerStr, normalizePath(childPath));
333
+ walk(propVal, childPath, nextBase, childPath);
334
+ }
335
+ }
336
+ // patternProperties
337
+ if (obj.patternProperties && typeof obj.patternProperties === "object") {
338
+ for (const [patKey, patVal] of Object.entries(obj.patternProperties)) {
339
+ const childPath = [...path, patKey];
340
+ addEdge(ownerStr, normalizePath(childPath));
341
+ walk(patVal, childPath, nextBase, childPath);
342
+ }
343
+ }
344
+ // dependentSchemas
345
+ if (obj.dependentSchemas && typeof obj.dependentSchemas === "object") {
346
+ for (const [depKey, depVal] of Object.entries(obj.dependentSchemas)) {
347
+ const childPath = [...path, depKey];
348
+ addEdge(ownerStr, normalizePath(childPath));
349
+ walk(depVal, childPath, nextBase, childPath);
350
+ }
351
+ }
352
+ // additionalProperties / items / contains / unevaluatedProperties / not / if / then / else
353
+ const singleKeys = [
354
+ "additionalProperties",
355
+ "items",
356
+ "additionalItems",
357
+ "contains",
358
+ "unevaluatedProperties",
359
+ "not",
360
+ "if",
361
+ "then",
362
+ "else",
363
+ ];
364
+ for (const key of singleKeys) {
365
+ const value = obj[key];
366
+ if (value && typeof value === "object") {
367
+ const childPath = [...path, key];
368
+ addEdge(ownerStr, normalizePath(childPath));
369
+ walk(value, childPath, nextBase, childPath);
370
+ }
371
+ }
372
+ // compositions
373
+ for (const key of ["allOf", "anyOf", "oneOf"]) {
374
+ const value = obj[key];
375
+ if (Array.isArray(value)) {
376
+ value.forEach((v, i) => {
377
+ const childPath = [...path, key, i];
378
+ if (typeof v === "object" && v !== null)
379
+ addEdge(ownerStr, normalizePath(childPath));
380
+ walk(v, childPath, nextBase, childPath);
381
+ });
382
+ }
383
+ }
384
+ };
385
+ walk(schema, [], rootBaseUri, []);
386
+ const cycles = new Set();
387
+ const index = new Map();
388
+ const lowLink = new Map();
389
+ const onStack = new Set();
390
+ const stack = [];
391
+ let currentIndex = 0;
392
+ const strongConnect = (v) => {
393
+ index.set(v, currentIndex);
394
+ lowLink.set(v, currentIndex);
395
+ currentIndex += 1;
396
+ stack.push(v);
397
+ onStack.add(v);
398
+ const targets = edges.get(v) ?? new Set();
399
+ for (const w of targets) {
400
+ if (!index.has(w)) {
401
+ strongConnect(w);
402
+ lowLink.set(v, Math.min(lowLink.get(v), lowLink.get(w)));
403
+ }
404
+ else if (onStack.has(w)) {
405
+ lowLink.set(v, Math.min(lowLink.get(v), index.get(w)));
406
+ }
407
+ }
408
+ if (lowLink.get(v) === index.get(v)) {
409
+ const scc = [];
410
+ let w;
411
+ do {
412
+ w = stack.pop();
413
+ if (w === undefined)
414
+ break;
415
+ onStack.delete(w);
416
+ scc.push(w);
417
+ } while (w !== v);
418
+ const hasSelfLoop = (edges.get(v) ?? new Set()).has(v);
419
+ if (scc.length > 1 || hasSelfLoop) {
420
+ scc.forEach((n) => cycles.add(n));
421
+ }
422
+ }
423
+ };
424
+ nodes.forEach((n) => {
425
+ if (!index.has(n))
426
+ strongConnect(n);
427
+ });
428
+ return cycles;
429
+ };
430
+ const subtreeHasCycle = (node, ctx, pathPrefix) => {
431
+ // Keywords that create structural recursion (require z.lazy()) vs property references
432
+ // When a schema is reached through these keywords and participates in a cycle,
433
+ // it will generate z.lazy() which causes type annotation issues when lifted
434
+ const structuralKeywords = new Set([
435
+ "additionalProperties", "items", "additionalItems", "contains",
436
+ "unevaluatedProperties", "not", "if", "then", "else",
437
+ "allOf", "anyOf", "oneOf"
438
+ ]);
439
+ const rootPathStr = normalizePath(pathPrefix);
440
+ const walk = (value, path, parentContext) => {
441
+ if (typeof value !== "object" || value === null)
442
+ return false;
443
+ const obj = value;
444
+ // Check if this node has a $ref that points back to the root or an ancestor
445
+ // This would create self-referential recursion which blocks lifting
446
+ const ref = typeof obj.$ref === "string" ? obj.$ref : typeof obj.$dynamicRef === "string" ? obj.$dynamicRef : null;
447
+ if (ref) {
448
+ // Resolve the ref to see if it points to root or an ancestor
449
+ if (ref.startsWith("#/")) {
450
+ const refPath = ref.slice(2).split("/");
451
+ const refPathStr = normalizePath(refPath);
452
+ // If the ref points to the root path or a prefix of it, this is self-referential
453
+ if (refPathStr === rootPathStr || rootPathStr.startsWith(refPathStr + "/")) {
454
+ return true;
455
+ }
456
+ }
457
+ // Otherwise, $ref to external definitions don't create inline recursion
458
+ return false;
459
+ }
460
+ const pathStr = normalizePath(path);
461
+ // Only check cycle membership if we got here through a structural keyword
462
+ // (not through "properties" which just creates named refs, not z.lazy())
463
+ // For the root node, use the context from the visitor (ctx.context)
464
+ const effectiveContext = parentContext ?? ctx.context;
465
+ if (effectiveContext !== undefined && structuralKeywords.has(effectiveContext)) {
466
+ if (ctx.cyclePaths.has(pathStr))
467
+ return true;
468
+ }
469
+ for (const [key, child] of Object.entries(obj)) {
470
+ if (walk(child, [...path, key], key))
471
+ return true;
472
+ }
473
+ return false;
474
+ };
475
+ return walk(node, pathPrefix, null);
476
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Generate a stable PascalCase name for a lifted inline schema.
3
+ * - Uses parentName as a base (default: Root).
4
+ * - Adds path segments (properties/indices) to disambiguate.
5
+ * - Applies suffixes to avoid collisions.
6
+ * - Allows an optional hook to override naming.
7
+ */
8
+ export const generateNameFromPath = (options) => {
9
+ const { parentName, path, existingNames, branchInfo, nameForPath, schemaTitle } = options;
10
+ if (nameForPath) {
11
+ const custom = nameForPath(path, { parentName, existingNames, branchInfo });
12
+ if (custom) {
13
+ return ensureUnique(custom, existingNames);
14
+ }
15
+ }
16
+ const baseParent = parentName ? toPascalCase(parentName) : "Root";
17
+ const branchSegment = branchInfo ? toPascalCase(String(branchInfo)) : undefined;
18
+ const segments = path.map((segment) => {
19
+ if (typeof segment === "number") {
20
+ return `Option${segment}`;
21
+ }
22
+ const pascal = toPascalCase(segment);
23
+ return pascal || "Anon";
24
+ });
25
+ const preferredTitle = schemaTitle ? toPascalCase(schemaTitle) : undefined;
26
+ if (preferredTitle && !existingNames.has(preferredTitle)) {
27
+ return preferredTitle;
28
+ }
29
+ const prefix = branchSegment ? [baseParent, branchSegment] : [baseParent];
30
+ let fallbackBase = [...prefix, ...segments].join("") || baseParent;
31
+ for (let k = 1; k <= segments.length; k += 1) {
32
+ const candidate = [...prefix, ...segments.slice(-k)].join("");
33
+ if (candidate && !existingNames.has(candidate)) {
34
+ return candidate;
35
+ }
36
+ }
37
+ // If we still collide, prefer the title with suffix if available, otherwise suffix the full path.
38
+ if (preferredTitle) {
39
+ fallbackBase = preferredTitle;
40
+ }
41
+ return ensureUnique(fallbackBase, existingNames);
42
+ };
43
+ const ensureUnique = (candidate, existingNames) => {
44
+ if (!existingNames.has(candidate))
45
+ return candidate;
46
+ let i = 2;
47
+ while (existingNames.has(`${candidate}${i}`)) {
48
+ i += 1;
49
+ }
50
+ return `${candidate}${i}`;
51
+ };
52
+ const toPascalCase = (value) => {
53
+ return value
54
+ .split(/[^A-Za-z0-9]+/)
55
+ .filter(Boolean)
56
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
57
+ .join("");
58
+ };