@gabrielbryk/json-schema-to-zod 2.10.1 → 2.11.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 (138) hide show
  1. package/AGENTS.md +44 -0
  2. package/CHANGELOG.md +28 -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 +167 -31
  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,248 @@
1
+ # Proposal: Discriminated Union with Default Case Detection
2
+
3
+ ## Status: Blocked by Zod v4 Type System
4
+
5
+ **TL;DR**: The runtime optimization works, but Zod v4's type system prevents `ZodDiscriminatedUnion` from being nested inside `ZodUnion`. Until this is resolved upstream, we cannot implement this optimization while maintaining type safety.
6
+
7
+ ## Summary
8
+
9
+ Enhance `parseOneOf` to detect and optimize JSON Schema patterns where a oneOf contains multiple variants with constant discriminator values plus a "catch-all" default variant using `not: { enum: [...] }`.
10
+
11
+ ## Motivation
12
+
13
+ Consider this common JSON Schema pattern (from Serverless Workflow spec):
14
+
15
+ ```yaml
16
+ callTask:
17
+ oneOf:
18
+ - title: CallAsyncAPI
19
+ properties:
20
+ call: { const: "asyncapi" }
21
+ - title: CallGRPC
22
+ properties:
23
+ call: { const: "grpc" }
24
+ - title: CallHTTP
25
+ properties:
26
+ call: { const: "http" }
27
+ # ... more known variants
28
+ - title: CallFunction # Default/catch-all
29
+ properties:
30
+ call:
31
+ not:
32
+ enum: ["asyncapi", "grpc", "http", "openapi", "a2a", "mcp"]
33
+ ```
34
+
35
+ The `CallFunction` variant uses `not: { enum: [...] }` where the enum values **exactly match** the const values of other variants. This is semantically a discriminated union with a default case.
36
+
37
+ ### Current Output
38
+
39
+ ```typescript
40
+ z.union([
41
+ asyncApiSchema,
42
+ grpcSchema,
43
+ httpSchema,
44
+ openapiSchema,
45
+ a2aSchema,
46
+ mcpSchema,
47
+ callFunctionSchema // Default case
48
+ ])
49
+ ```
50
+
51
+ This uses O(n) sequential matching - Zod tries each schema until one passes.
52
+
53
+ ### Proposed Output
54
+
55
+ ```typescript
56
+ z.union([
57
+ z.discriminatedUnion("call", [
58
+ asyncApiSchema,
59
+ grpcSchema,
60
+ httpSchema,
61
+ openapiSchema,
62
+ a2aSchema,
63
+ mcpSchema
64
+ ]),
65
+ callFunctionSchema // Default case
66
+ ])
67
+ ```
68
+
69
+ **Benefits:**
70
+ - Known values (`"asyncapi"`, `"grpc"`, etc.) use O(1) discriminated union lookup
71
+ - Unknown values fail fast from discriminatedUnion, then try the default case
72
+ - More efficient runtime validation for the common case
73
+
74
+ ## Detection Algorithm
75
+
76
+ ### Step 1: Identify Discriminator Candidates
77
+
78
+ For each property key that appears in all oneOf options:
79
+ 1. Collect options where the property has a constant value (`const` or `enum: [single]`)
80
+ 2. Identify if exactly ONE option has `not: { enum: [...] }` for the same property
81
+
82
+ ### Step 2: Validate Default Case Pattern
83
+
84
+ The "default case" pattern is valid when:
85
+ 1. All other options have constant discriminator values
86
+ 2. Exactly one option has `not: { enum: values }`
87
+ 3. The `values` in the negated enum **exactly match** the const values from other options
88
+ 4. The discriminator property is required in all options
89
+
90
+ ### Step 3: Generate Optimized Output
91
+
92
+ ```typescript
93
+ type DiscriminatorResult =
94
+ | { type: 'full'; key: string }
95
+ | { type: 'withDefault'; key: string; defaultIndex: number; constValues: string[] }
96
+ | undefined;
97
+ ```
98
+
99
+ ## Implementation
100
+
101
+ ### Changes to `parseOneOf.ts`
102
+
103
+ 1. **Enhance `findImplicitDiscriminator`** to detect the default case pattern:
104
+
105
+ ```typescript
106
+ const findImplicitDiscriminator = (
107
+ options: JsonSchema[],
108
+ refs: Refs
109
+ ): DiscriminatorResult => {
110
+ // ... existing logic to collect properties and required fields
111
+
112
+ for (const key of candidateKeys) {
113
+ const constValues: string[] = [];
114
+ let defaultIndex: number | undefined;
115
+ let defaultEnumValues: string[] | undefined;
116
+
117
+ for (let i = 0; i < resolvedOptions.length; i++) {
118
+ const prop = resolvedOptions[i].properties[key];
119
+
120
+ if (prop.const) {
121
+ constValues.push(prop.const);
122
+ } else if (prop.not?.enum) {
123
+ // Potential default case
124
+ if (defaultIndex !== undefined) {
125
+ // Multiple defaults - can't optimize
126
+ break;
127
+ }
128
+ defaultIndex = i;
129
+ defaultEnumValues = prop.not.enum;
130
+ } else {
131
+ // Neither const nor not.enum - can't use discriminated union
132
+ break;
133
+ }
134
+ }
135
+
136
+ // Check if default enum matches const values
137
+ if (defaultIndex !== undefined && defaultEnumValues) {
138
+ const constSet = new Set(constValues);
139
+ const enumSet = new Set(defaultEnumValues);
140
+ if (setsEqual(constSet, enumSet)) {
141
+ return { type: 'withDefault', key, defaultIndex, constValues };
142
+ }
143
+ }
144
+
145
+ // All have const values
146
+ if (constValues.length === resolvedOptions.length) {
147
+ return { type: 'full', key };
148
+ }
149
+ }
150
+
151
+ return undefined;
152
+ };
153
+ ```
154
+
155
+ 2. **Update `parseOneOf`** to handle the `withDefault` case:
156
+
157
+ ```typescript
158
+ export const parseOneOf = (schema, refs) => {
159
+ const discriminator = findImplicitDiscriminator(schema.oneOf, refs);
160
+
161
+ if (discriminator?.type === 'withDefault') {
162
+ const { key, defaultIndex, constValues } = discriminator;
163
+
164
+ // Parse all options
165
+ const allParsed = schema.oneOf.map((s, i) => parseSchema(s, {...}));
166
+
167
+ // Separate known variants from default
168
+ const knownVariants = allParsed.filter((_, i) => i !== defaultIndex);
169
+ const defaultVariant = allParsed[defaultIndex];
170
+
171
+ // Generate discriminated union with default fallback
172
+ const discriminatedExpr = `z.discriminatedUnion("${key}", [${knownVariants.map(v => v.expression).join(", ")}])`;
173
+
174
+ return {
175
+ expression: `z.union([${discriminatedExpr}, ${defaultVariant.expression}])`,
176
+ type: `z.ZodUnion<readonly [z.ZodDiscriminatedUnion<"${key}", readonly [${knownVariants.map(v => v.type).join(", ")}]>, ${defaultVariant.type}]>`
177
+ };
178
+ }
179
+
180
+ // ... existing logic for full discriminated union or regular union
181
+ };
182
+ ```
183
+
184
+ ## Zod v4 Type System Limitation
185
+
186
+ **Problem**: In Zod v4, `ZodDiscriminatedUnion` cannot be used as a member of `ZodUnion` at the type level.
187
+
188
+ When we generate:
189
+ ```typescript
190
+ z.union([
191
+ z.discriminatedUnion("call", [known1, known2, ...]),
192
+ defaultVariant
193
+ ])
194
+ ```
195
+
196
+ The runtime works correctly, but TypeScript fails with:
197
+ ```
198
+ Type 'ZodDiscriminatedUnion<...>' is not assignable to type 'SomeType'.
199
+ The types of '_zod.values' are incompatible between these types.
200
+ ```
201
+
202
+ **Root Cause**: Zod v4's internal `SomeType` constraint doesn't include `ZodDiscriminatedUnion`. This appears to be an intentional design decision, as discriminated unions are meant to be "leaf" schemas, not composable with regular unions.
203
+
204
+ **Workaround Attempted**: Use a type annotation listing all individual variants while keeping the optimized runtime expression. This fails because Zod v4's strict tuple checking requires the type annotation tuple length to match the runtime tuple length.
205
+
206
+ **Potential Solutions**:
207
+ 1. **Wait for Zod v4 update**: If Zod adds `ZodDiscriminatedUnion` to `SomeType`, this would work
208
+ 2. **Use type assertion**: Cast the result with `as unknown as ZodUnion<...>` - unsafe but functional
209
+ 3. **Request Zod feature**: Open an issue requesting discriminated union composability
210
+
211
+ For now, we fall back to regular `z.union()` for schemas with a default case.
212
+
213
+ ## Edge Cases
214
+
215
+ 1. **Multiple default cases**: If more than one option has `not: { enum }`, fall back to regular union
216
+ 2. **Partial enum match**: If `not: { enum }` doesn't exactly match other const values, fall back to regular union
217
+ 3. **Non-string discriminators**: Only string values are supported (same as current discriminated union)
218
+ 4. **Nested allOf**: Must resolve properties from allOf members (already implemented)
219
+
220
+ ## Type Safety
221
+
222
+ The generated type correctly represents the union structure:
223
+
224
+ ```typescript
225
+ z.ZodUnion<readonly [
226
+ z.ZodDiscriminatedUnion<"call", readonly [
227
+ z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodLiteral<"asyncapi">, ...}>>,
228
+ z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodLiteral<"grpc">, ...}>>,
229
+ // ... other known variants
230
+ ]>,
231
+ z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodAny, ...}>> // Default
232
+ ]>
233
+ ```
234
+
235
+ ## Testing
236
+
237
+ Add test cases for:
238
+ 1. Basic discriminated union with default case
239
+ 2. Default case with exact enum match
240
+ 3. Default case with partial enum match (should fall back to union)
241
+ 4. Multiple potential defaults (should fall back to union)
242
+ 5. Real-world workflow spec CallTask schema
243
+
244
+ ## Future Considerations
245
+
246
+ - Could extend to support multiple "catch-all" patterns beyond `not: { enum }`
247
+ - Could support numeric discriminators if needed
248
+ - Could potentially use Zod's `.catch()` for even more efficient default handling
@@ -0,0 +1,77 @@
1
+ # Inline Object Lifting (Top-Level Reusable Types)
2
+
3
+ ## Goal
4
+ Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and single-file outputs emit reusable Zod schemas (e.g., CallTask `with` objects such as AsyncAPI become first-class exports). This is now the default behavior (opt-out via a flag). Move lifting into an IR transformation pipeline (not just a raw-schema pre-pass) for stronger guarantees and shared behavior.
5
+
6
+ ## Scope
7
+ - Applies to both `jsonSchemaToZod` (single file) and `generateSchemaBundle` (multi-file).
8
+ - Targets inline object-like schemas (properties/patternProperties/additionalProperties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not).
9
+ - Skip cyclic/self-referential candidates or ones where lifting would change semantics; err on not lifting when uncertain.
10
+
11
+ ## High-Level Flow (IR-centric)
12
+ 1) **Analyze to IR**: ingest JSON Schema → IR with refs, dependencies, SCCs, and registry (as per bundle refactor proposal).
13
+ 2) **Hoist transform (IR pass)**:
14
+ - Detect inline object-like nodes (properties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not, etc.).
15
+ - Skip boolean schemas, `$ref`/`$dynamicRef`, and `$defs` members.
16
+ - Use IR dependency graph/ref registry for accurate ancestor/self cycle detection; if ambiguous, skip.
17
+ - Optionally deduplicate by structural hash (excluding titles/descriptions) so identical shapes share one hoisted def.
18
+ 3) **Name** via a centralized naming service:
19
+ - Base on nearest named parent (root/def) + path; include discriminator/const-enum hints; suffix on collisions.
20
+ - Allow `nameForPath` hook; all passes/emitters call the same service.
21
+ 4) **Rewrite IR**:
22
+ - Create top-level def nodes for hoisted shapes; replace inline nodes with ref nodes.
23
+ - Preserve annotations; keep ASCII identifiers.
24
+ - Emit debug metadata (path → def name) for tests/telemetry.
25
+ 5) **Emit**:
26
+ - Single-file: emit Zod using transformed IR.
27
+ - Bundle: build defInfoMap/plan targets/imports from transformed IR; lifted defs flow like native `$defs`.
28
+
29
+ ## Options
30
+ - Extend `Options` and `GenerateBundleOptions` with:
31
+ - `liftInlineObjects?: { enable?: boolean; nameForPath?: (path, ctx) => string }`
32
+ - Default: `enable` is true; set `enable: false` to opt out.
33
+ - Use `name`/`rootName` as the base parent name; fallback to `Root` when absent.
34
+
35
+ ## Integration Points
36
+ - **Shared IR pass**: integrate the hoist transform into the IR pipeline used by both `jsonSchemaToZod` and `generateSchemaBundle`.
37
+ - **Single file**: run analysis → hoist pass → emit.
38
+ - **Bundle**: run analysis → hoist pass → plan/emit; new defs appear in defInfoMap/imports.
39
+ - **Nested types**: lifted items are no longer “nested” (expected when the flag is on).
40
+
41
+ ## Naming Strategy (default)
42
+ - Centralized naming service (IR utility) used by all passes/emitters.
43
+ - Base: nearest named ancestor (def name → PascalCase; root → `Root` or provided `name`/`rootName`).
44
+ - Path segments: PascalCase property names; union branches include discriminator const/enum when present; else index (`Option1`).
45
+ - Collision resolution: set of existing def names + assigned names; append numeric suffix.
46
+ - Hook: `nameForPath(path, { parentName, branchInfo, existingNames })`.
47
+
48
+ ## Safety / Skip Rules
49
+ - Skip boolean schemas, pure meta-only objects (no constraints), ambiguous `unevaluatedProperties` contexts where lifting could alter validation, and any detected cycles.
50
+ - Use ref registry/IR deps for cycle detection; if unclear, do not lift.
51
+ - Structural hash dedup optional: only if shapes match; otherwise, keep distinct.
52
+
53
+ ## Testing Plan
54
+ - **Unit (hoist IR pass)**:
55
+ - Lifts simple inline property object → top-level def + ref node.
56
+ - Lifts inside allOf/oneOf/anyOf branches; names stable and unique.
57
+ - Handles items/additionalProperties/patternProperties.
58
+ - Skips self/ancestor-ref cycles (using ref registry).
59
+ - Collision handling and custom `nameForPath` hook.
60
+ - Optional structural hash dedup: identical shapes hoisted once.
61
+ - **Integration**:
62
+ - Default-on: workflow fixture shows CallTask `with` shapes as top-level defs; CallTask branches reference them; `pnpm test` + workflow snapshot pass.
63
+ - Opt-out coverage: with `enable: false`, outputs match legacy layout (no lifting) to preserve backward compatibility when explicitly requested.
64
+ - Maintain snapshots for both modes to contain churn while default-on is adopted.
65
+
66
+ ## Risks / Mitigations
67
+ - **Semantics drift**: lifting could change validation if sibling keywords depend on inline position (e.g., `unevaluatedProperties`, composition). Mitigate with conservative skip logic; if context is ambiguous, do not lift. Default-on but easily opt-out with `enable: false`.
68
+ - **Cycle misdetection**: identity-only checks can miss `$ref`/`$dynamicRef` loops. Mitigate by reusing ref registry/IR deps for ancestor/self detection in the hoist pass.
69
+ - **Naming collisions**: new defs could clash with existing `$defs` or generated names. Mitigate with centralized naming service, suffixing, optional hook, and checks against defInfoMap inputs.
70
+ - **Bundle import gaps**: lifted defs must flow into defInfoMap/planBundleTargets. Mitigate by running the hoist pass before planning so new defNames/imports are included.
71
+ - **Single/bundle divergence**: if only bundle is wired, single-file outputs diverge. Mitigate by invoking the same IR hoist pass in `jsonSchemaToZod` and exposing the flag in shared `Options`.
72
+
73
+ ## Implementation Notes
74
+ - New IR transform: `src/utils/liftInlineObjects.ts` (pure, no side effects) operating on IR nodes rather than raw schema where possible; if raw is needed, still leverage ref registry.
75
+ - Returns `{ rootSchema, defs, addedDefNames, pathToDefName }` (or IR equivalents) for debugging/tests.
76
+ - Central naming service (shared util) used by hoist, emitters, and other passes; reuse `toPascalCase` and keep ASCII identifiers.
77
+ - Reuse traversal coverage from `findNestedTypesInSchema` to avoid missing positions; prefer IR graph traversal for accuracy.
package/eslint.config.js CHANGED
@@ -3,7 +3,7 @@ import pluginTs from "@typescript-eslint/eslint-plugin";
3
3
 
4
4
  export default [
5
5
  {
6
- ignores: ["dist", "node_modules", "test/output/**"],
6
+ ignores: ["dist", "node_modules", "test/output/**", "*.config.js", "*.config.cjs", ".tmp-*"],
7
7
  },
8
8
  {
9
9
  files: ["**/*.ts", "**/*.tsx"],
@@ -18,7 +18,9 @@ export default [
18
18
  "@typescript-eslint": pluginTs,
19
19
  },
20
20
  rules: {
21
- ...pluginTs.configs.recommended.rules,
21
+ // Use only non-type-aware rules from recommended
22
+ "@typescript-eslint/no-unused-vars": "error",
23
+ "@typescript-eslint/no-explicit-any": "warn",
22
24
  "@typescript-eslint/no-require-imports": "error",
23
25
  "@typescript-eslint/no-var-requires": "error",
24
26
  },
@@ -0,0 +1,19 @@
1
+ /** @type {import('@jest/types').Config.InitialOptions} */
2
+ export default {
3
+ testEnvironment: "node",
4
+ testMatch: ["**/test/**/*.test.ts"],
5
+ extensionsToTreatAsEsm: [".ts"],
6
+ transform: {
7
+ "^.+\\.tsx?$": [
8
+ "ts-jest",
9
+ {
10
+ useESM: true,
11
+ tsconfig: "tsconfig.jest.json",
12
+ diagnostics: false,
13
+ },
14
+ ],
15
+ },
16
+ moduleNameMapper: {
17
+ "^(\\.{1,2}/.*)\\.js$": "$1",
18
+ },
19
+ };
package/package.json CHANGED
@@ -1,29 +1,26 @@
1
1
  {
2
2
  "name": "@gabrielbryk/json-schema-to-zod",
3
- "version": "2.10.1",
3
+ "version": "2.11.0",
4
4
  "description": "Converts JSON schema objects or files into Zod schemas",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",
7
- "bin": "./dist/cjs/cli.js",
8
- "main": "./dist/cjs/index.js",
9
- "module": "./dist/esm/index.js",
7
+ "bin": "./dist/cli.js",
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
10
  "exports": {
11
- "import": {
11
+ ".": {
12
12
  "types": "./dist/types/index.d.ts",
13
- "default": "./dist/esm/index.js"
13
+ "default": "./dist/index.js"
14
14
  },
15
- "require": {
16
- "types": "./dist/types/index.d.ts",
17
- "default": "./dist/cjs/index.js"
18
- }
15
+ "./package.json": "./package.json"
19
16
  },
20
17
  "c8": {
21
18
  "exclude": [
22
19
  "dist",
23
20
  "createIndex.ts",
24
21
  "jest.config.js",
25
- "postcjs.js",
26
- "postesm.js",
22
+ "jest.config.ts",
23
+ "jest.config.mjs",
27
24
  "test"
28
25
  ]
29
26
  },
@@ -66,25 +63,25 @@
66
63
  "@typescript-eslint/eslint-plugin": "^8.49.0",
67
64
  "@typescript-eslint/parser": "^8.49.0",
68
65
  "@types/json-schema": "^7.0.15",
66
+ "@types/jest": "^29.5.14",
69
67
  "@types/node": "^20.9.0",
70
- "fast-diff": "^1.3.0",
71
68
  "eslint": "^9.39.1",
69
+ "jest": "^29.7.0",
72
70
  "js-yaml": "^4.1.0",
73
71
  "rimraf": "^5.0.5",
72
+ "ts-jest": "^29.3.4",
74
73
  "tsx": "^4.1.1",
75
74
  "typescript": "^5.2.2",
76
75
  "zod": "^4.1.13"
77
76
  },
78
77
  "scripts": {
79
- "build:types": "tsc -p tsconfig.types.json",
80
- "build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.cjs",
81
- "build:esm": "tsc -p tsconfig.esm.json && node postesm.cjs",
82
- "build": "pnpm gen && pnpm test && rimraf ./dist && pnpm build:types && pnpm build:cjs && pnpm build:esm",
78
+ "build:esm": "tsc -p tsconfig.build.json",
79
+ "build": "pnpm gen && pnpm test && rimraf ./dist && pnpm build:esm",
83
80
  "dry": "pnpm build && pnpm publish --dry-run",
84
- "dev": "tsx watch test/index.ts",
81
+ "dev": "NODE_OPTIONS=--experimental-vm-modules jest --watch",
85
82
  "gen": "tsx ./createIndex.ts",
86
- "test": "tsx test/index.ts",
83
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest",
87
84
  "lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
88
- "smoke:esm": "pnpm build:esm && node --input-type=module -e \"import { jsonSchemaToZod } from './dist/esm/index.js'; console.log(jsonSchemaToZod({type:'string'}));\""
85
+ "smoke:esm": "pnpm build:esm && node --input-type=module -e \"import { jsonSchemaToZod } from './dist/index.js'; console.log(jsonSchemaToZod({type:'string'}));\""
89
86
  }
90
87
  }
@@ -12,7 +12,6 @@ function main() {
12
12
  const schema = yaml.load(readFileSync(WORKFLOW_SOURCE, "utf8")) as any;
13
13
 
14
14
  const generated = jsonSchemaToZod(schema, {
15
- module: "esm",
16
15
  name: "workflowSchema",
17
16
  exportRefs: true,
18
17
  });
package/dist/cjs/Types.js DELETED
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/dist/cjs/cli.js DELETED
@@ -1,70 +0,0 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const jsonSchemaToZod_js_1 = require("./jsonSchemaToZod.js");
5
- const fs_1 = require("fs");
6
- const path_1 = require("path");
7
- const cliTools_js_1 = require("./utils/cliTools.js");
8
- const params = {
9
- input: {
10
- shorthand: "i",
11
- value: "string",
12
- required: process.stdin.isTTY &&
13
- "input is required when no JSON or file path is piped",
14
- description: "JSON or a source file path. Required if no data is piped.",
15
- },
16
- output: {
17
- shorthand: "o",
18
- value: "string",
19
- description: "A file path to write to. If not supplied stdout will be used.",
20
- },
21
- name: {
22
- shorthand: "n",
23
- value: "string",
24
- description: "The name of the schema in the output.",
25
- },
26
- depth: {
27
- shorthand: "d",
28
- value: "number",
29
- description: "Maximum depth of recursion before falling back to z.any(). Defaults to 0.",
30
- },
31
- module: {
32
- shorthand: "m",
33
- value: ["esm", "cjs", "none"],
34
- description: "Module syntax; 'esm', 'cjs' or 'none'. Defaults to 'esm'.",
35
- },
36
- type: {
37
- shorthand: "t",
38
- value: "string",
39
- description: "The name of the (optional) inferred type export."
40
- },
41
- noImport: {
42
- shorthand: "ni",
43
- description: "Removes the `import { z } from 'zod';` or equivalent from the output."
44
- },
45
- withJsdocs: {
46
- shorthand: "wj",
47
- description: "Generate jsdocs off of the description property.",
48
- },
49
- };
50
- async function main() {
51
- const args = (0, cliTools_js_1.parseArgs)(params, process.argv, true);
52
- const input = args.input || (await (0, cliTools_js_1.readPipe)());
53
- const jsonSchema = (0, cliTools_js_1.parseOrReadJSON)(input);
54
- const zodSchema = (0, jsonSchemaToZod_js_1.jsonSchemaToZod)(jsonSchema, {
55
- name: args.name,
56
- depth: args.depth,
57
- module: args.module || "esm",
58
- noImport: args.noImport,
59
- type: args.type,
60
- withJsdocs: args.withJsdocs,
61
- });
62
- if (args.output) {
63
- (0, fs_1.mkdirSync)((0, path_1.dirname)(args.output), { recursive: true });
64
- (0, fs_1.writeFileSync)(args.output, zodSchema);
65
- }
66
- else {
67
- console.log(zodSchema);
68
- }
69
- }
70
- void main();
@@ -1,62 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.analyzeSchema = void 0;
4
- const parseSchema_js_1 = require("../parsers/parseSchema.js");
5
- const cycles_js_1 = require("../utils/cycles.js");
6
- const buildRefRegistry_js_1 = require("../utils/buildRefRegistry.js");
7
- const analyzeSchema = (schema, options = {}) => {
8
- const { module, name, type, ...rest } = options;
9
- if (type && (!name || module !== "esm")) {
10
- throw new Error("Option `type` requires `name` to be set and `module` to be `esm`");
11
- }
12
- const normalized = {
13
- module,
14
- name,
15
- type,
16
- ...rest,
17
- exportRefs: rest.exportRefs ?? true,
18
- withMeta: rest.withMeta ?? true,
19
- };
20
- const refNameByPointer = new Map();
21
- const usedNames = new Set();
22
- if (name) {
23
- usedNames.add(name);
24
- }
25
- const declarations = new Map();
26
- const dependencies = new Map();
27
- const { registry: refRegistry, rootBaseUri } = (0, buildRefRegistry_js_1.buildRefRegistry)(schema);
28
- const pass1 = {
29
- module,
30
- name,
31
- path: [],
32
- seen: new Map(),
33
- declarations,
34
- dependencies,
35
- inProgress: new Set(),
36
- refNameByPointer,
37
- usedNames,
38
- root: schema,
39
- currentSchemaName: name,
40
- refRegistry,
41
- rootBaseUri,
42
- ...rest,
43
- withMeta: normalized.withMeta,
44
- };
45
- (0, parseSchema_js_1.parseSchema)(schema, pass1);
46
- const names = Array.from(declarations.keys());
47
- const cycleRefNames = (0, cycles_js_1.detectCycles)(names, dependencies);
48
- const { componentByName } = (0, cycles_js_1.computeScc)(names, dependencies);
49
- return {
50
- schema,
51
- options: normalized,
52
- refNameByPointer,
53
- usedNames,
54
- declarations,
55
- dependencies,
56
- cycleRefNames,
57
- cycleComponentByName: componentByName,
58
- refRegistry,
59
- rootBaseUri,
60
- };
61
- };
62
- exports.analyzeSchema = analyzeSchema;