@contractkit/plugin-typescript 0.16.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 (60) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +81 -0
  4. package/.turbo/turbo-test.log +19 -0
  5. package/CHANGELOG.md +151 -0
  6. package/README.md +153 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +1882 -0
  10. package/coverage/coverage-final.json +9 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/codegen-contract.ts.html +3331 -0
  18. package/coverage/src/codegen-operation.ts.html +2530 -0
  19. package/coverage/src/codegen-plain-types.ts.html +901 -0
  20. package/coverage/src/codegen-sdk.ts.html +2797 -0
  21. package/coverage/src/index.html +206 -0
  22. package/coverage/src/index.ts.html +1360 -0
  23. package/coverage/src/path-utils.ts.html +649 -0
  24. package/coverage/src/ts-render.ts.html +592 -0
  25. package/coverage/tests/helpers.ts.html +826 -0
  26. package/coverage/tests/index.html +116 -0
  27. package/dist/codegen-contract.d.ts +56 -0
  28. package/dist/codegen-contract.d.ts.map +1 -0
  29. package/dist/codegen-operation.d.ts +25 -0
  30. package/dist/codegen-operation.d.ts.map +1 -0
  31. package/dist/codegen-plain-types.d.ts +10 -0
  32. package/dist/codegen-plain-types.d.ts.map +1 -0
  33. package/dist/codegen-sdk.d.ts +38 -0
  34. package/dist/codegen-sdk.d.ts.map +1 -0
  35. package/dist/index.d.ts +77 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +3162 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/path-utils.d.ts +15 -0
  40. package/dist/path-utils.d.ts.map +1 -0
  41. package/dist/ts-render.d.ts +20 -0
  42. package/dist/ts-render.d.ts.map +1 -0
  43. package/eslint.config.js +6 -0
  44. package/package.json +43 -0
  45. package/src/codegen-contract.ts +1082 -0
  46. package/src/codegen-operation.ts +815 -0
  47. package/src/codegen-plain-types.ts +272 -0
  48. package/src/codegen-sdk.ts +904 -0
  49. package/src/index.ts +425 -0
  50. package/src/path-utils.ts +188 -0
  51. package/src/ts-render.ts +169 -0
  52. package/tests/codegen-contract.test.ts +1004 -0
  53. package/tests/codegen-operation.test.ts +939 -0
  54. package/tests/codegen-plain-types.test.ts +636 -0
  55. package/tests/codegen-sdk.test.ts +1500 -0
  56. package/tests/codegen-server.test.ts +192 -0
  57. package/tests/helpers.ts +247 -0
  58. package/tests/pipeline.test.ts +372 -0
  59. package/tsconfig.json +9 -0
  60. package/vitest.config.ts +14 -0
@@ -0,0 +1,1082 @@
1
+ import { relative, dirname } from 'node:path';
2
+ import type {
3
+ ContractRootNode,
4
+ ModelNode,
5
+ FieldNode,
6
+ ContractTypeNode,
7
+ ScalarTypeNode,
8
+ ArrayTypeNode,
9
+ TupleTypeNode,
10
+ RecordTypeNode,
11
+ EnumTypeNode,
12
+ LiteralTypeNode,
13
+ UnionTypeNode,
14
+ DiscriminatedUnionTypeNode,
15
+ InlineObjectTypeNode,
16
+ IntersectionTypeNode,
17
+ ObjectMode,
18
+ } from '@contractkit/core';
19
+ import {
20
+ collectTypeRefs,
21
+ computeModelsWithOutput as ckComputeModelsWithOutput,
22
+ collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
+ } from '@contractkit/core';
24
+
25
+ export function modeToWrapper(mode: ObjectMode): string {
26
+ switch (mode) {
27
+ case 'strict':
28
+ return 'z.strictObject';
29
+ case 'strip':
30
+ return 'z.object';
31
+ case 'loose':
32
+ return 'z.looseObject';
33
+ }
34
+ }
35
+
36
+ // ─── Cross-file import resolution ─────────────────────────────────────────
37
+
38
+ export interface ContractCodegenContext {
39
+ /** Map from model name → absolute output file path */
40
+ modelOutPaths: Map<string, string>;
41
+ /** Absolute output file path for the current contract file */
42
+ currentOutPath: string;
43
+ /** Set of model names that have Input variants (models with visibility modifiers) */
44
+ modelsWithInput?: Set<string>;
45
+ /** Set of model names that have Output variants (models with format(output=...)) */
46
+ modelsWithOutput?: Set<string>;
47
+ /** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
48
+ jsonValueImportPath?: string;
49
+ }
50
+
51
+ // ─── Public entry point ────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Compute which models need Input variants, including transitive dependencies.
55
+ * A model needs an Input variant if it has visibility-modified fields, OR if
56
+ * any of its field types (recursively) reference a model that has an Input variant.
57
+ */
58
+ export function computeModelsWithInput(models: ModelNode[], externalModelsWithInput: Set<string> = new Set()): Set<string> {
59
+ const result = new Set<string>();
60
+
61
+ // Initial pass: direct visibility modifiers
62
+ for (const model of models) {
63
+ if (model.fields.some(f => f.visibility !== 'normal')) {
64
+ result.add(model.name);
65
+ }
66
+ }
67
+
68
+ // Transitive closure: add models that reference models with Input variants,
69
+ // including through base model inheritance.
70
+ let changed = true;
71
+ while (changed) {
72
+ changed = false;
73
+ for (const model of models) {
74
+ if (result.has(model.name)) continue;
75
+ const refs = new Set<string>();
76
+ for (const field of model.fields) {
77
+ collectTypeRefs(field.type, refs);
78
+ }
79
+ // A model that extends a parent with Input variants also needs an Input variant,
80
+ // so that the write schema can extend ParentInput instead of Parent.
81
+ if (model.bases) for (const b of model.bases) refs.add(b);
82
+ // A type alias (model.type set) that references a model with Input variants
83
+ // also needs an Input variant.
84
+ if (model.type) collectTypeRefs(model.type, refs);
85
+ for (const ref of refs) {
86
+ if (result.has(ref) || externalModelsWithInput.has(ref)) {
87
+ result.add(model.name);
88
+ changed = true;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return result;
96
+ }
97
+
98
+ function generateComments(model: ModelNode, outPath?: string): string[] {
99
+ const lines: string[] = [];
100
+ lines.push('/**');
101
+ if (model.deprecated) {
102
+ lines.push(` * @deprecated`);
103
+ }
104
+ if (model.description) {
105
+ lines.push(` * ${model.description}`);
106
+ }
107
+
108
+ const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
109
+ lines.push(` * generated from [${model.name}](file://./${relPath}#L${model.loc.line})`);
110
+ lines.push('*/');
111
+ return lines;
112
+ }
113
+
114
+ export function generateContract(root: ContractRootNode, context?: ContractCodegenContext): string {
115
+ const needsDateTime = rootNeedsDateTime(root);
116
+ const needsDuration = rootNeedsScalar(root, 'duration');
117
+ const needsInterval = rootNeedsScalar(root, 'interval');
118
+ const needsBinary = rootNeedsScalar(root, 'binary');
119
+ const needsDatetime = rootNeedsScalar(root, 'datetime');
120
+ const needsJson = rootNeedsScalar(root, 'json');
121
+ const externalRefs = collectExternalRefs(root);
122
+ const lines: string[] = [];
123
+
124
+ // Compute which models have Input variants (local, incl. transitive deps + external)
125
+ const externalModelsWithInput = context?.modelsWithInput ?? new Set<string>();
126
+ const localModelsWithInput = computeModelsWithInput(root.models, externalModelsWithInput);
127
+ const allModelsWithInput = new Set([...localModelsWithInput, ...externalModelsWithInput]);
128
+
129
+ // Compute which models have Output variants (post-transform wire shape)
130
+ const externalModelsWithOutput = context?.modelsWithOutput ?? new Set<string>();
131
+ const localModelsWithOutput = ckComputeModelsWithOutput(root.models, externalModelsWithOutput);
132
+ const allModelsWithOutput = new Set([...localModelsWithOutput, ...externalModelsWithOutput]);
133
+
134
+ // Collect additional external Input refs needed for Input schema fields
135
+ const externalInputRefs = allModelsWithInput.size > 0 ? collectExternalInputRefs(root, allModelsWithInput) : [];
136
+ const externalOutputRefs = allModelsWithOutput.size > 0 ? ckCollectExternalOutputRefs(root, allModelsWithOutput) : [];
137
+ const allExternalRefs = [...new Set([...externalRefs, ...externalInputRefs, ...externalOutputRefs])].sort();
138
+
139
+ lines.push(`import { z } from 'zod';`);
140
+ const luxonImports: string[] = [];
141
+ if (needsDateTime) luxonImports.push('DateTime');
142
+ if (needsDuration) luxonImports.push('Duration');
143
+ if (needsInterval) luxonImports.push('Interval');
144
+ if (luxonImports.length > 0) lines.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
145
+ for (const ref of allExternalRefs) {
146
+ const importPath = resolveImportPath(ref, context);
147
+ lines.push(`import { ${ref} } from '${importPath}';`);
148
+ }
149
+ lines.push('');
150
+ if (needsBinary) {
151
+ lines.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
152
+ }
153
+ if (needsDatetime) {
154
+ lines.push(
155
+ `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
156
+ );
157
+ }
158
+ if (needsInterval) {
159
+ lines.push(
160
+ `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
161
+ );
162
+ }
163
+ if (needsJson) {
164
+ lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
165
+ lines.push(
166
+ `const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
167
+ );
168
+ }
169
+ if (needsBinary || needsDatetime || needsInterval || needsJson) lines.push('');
170
+
171
+ const modelsWithWriteonly = new Set(root.models.filter(m => m.fields.some(f => f.visibility === 'writeonly')).map(m => m.name));
172
+ const modelMap = new Map(root.models.map(m => [m.name, m]));
173
+
174
+ for (const model of topoSortModels(root.models)) {
175
+ lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, modelsWithWriteonly, modelMap, allModelsWithOutput));
176
+ lines.push('');
177
+ }
178
+
179
+ return lines.join('\n');
180
+ }
181
+
182
+ // ─── Model ─────────────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * If any ancestor in the base chain has a format(input=)/format(output=) transform,
186
+ * the parent schema compiles to a `ZodPipe` (object().transform()) which has no `.extend()`.
187
+ * To keep extension working, inline the parent's fields into the child and inherit format/mode
188
+ * so the child re-applies the transform on the merged shape. Returns the model unchanged when
189
+ * no ancestor has format, preserving the existing `.extend()`-based output.
190
+ */
191
+ function flattenFormatChain(model: ModelNode, modelMap: Map<string, ModelNode>): ModelNode {
192
+ if (!model.bases || model.bases.length === 0) return model;
193
+ // TODO(multi-base): currently only the first base is followed for format inheritance.
194
+ // Multi-base format flattening will need a topological merge across all bases.
195
+ const firstBase = model.bases[0]!;
196
+ const parent = modelMap.get(firstBase);
197
+ if (!parent) return model;
198
+ const flatParent = flattenFormatChain(parent, modelMap);
199
+ const parentHasFormat =
200
+ (flatParent.inputCase !== undefined && flatParent.inputCase !== 'camel') ||
201
+ (flatParent.outputCase !== undefined && flatParent.outputCase !== 'camel');
202
+ if (!parentHasFormat) return model;
203
+
204
+ const merged = new Map<string, FieldNode>();
205
+ for (const f of flatParent.fields) merged.set(f.name, f);
206
+ for (const f of model.fields) merged.set(f.name, f);
207
+
208
+ return {
209
+ ...model,
210
+ bases: undefined,
211
+ fields: [...merged.values()],
212
+ inputCase: model.inputCase ?? flatParent.inputCase,
213
+ outputCase: model.outputCase ?? flatParent.outputCase,
214
+ mode: model.mode ?? flatParent.mode,
215
+ };
216
+ }
217
+
218
+ function generateModel(
219
+ model: ModelNode,
220
+ outPath?: string,
221
+ modelsWithInput?: Set<string>,
222
+ modelsWithWriteonly?: Set<string>,
223
+ modelMap?: Map<string, ModelNode>,
224
+ modelsWithOutput?: Set<string>,
225
+ ): string[] {
226
+ // Type alias: Name : typeExpression
227
+ if (model.type) {
228
+ return generateTypeAlias(model, outPath, modelsWithInput, modelsWithOutput);
229
+ }
230
+
231
+ const effective = modelMap ? flattenFormatChain(model, modelMap) : model;
232
+
233
+ // A model needs Input/read split if it has visibility-modified fields OR if it
234
+ // transitively references models that have Input variants (captured in modelsWithInput).
235
+ const needsInputSplit = effective.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(effective.name) ?? false);
236
+
237
+ const lines = needsInputSplit
238
+ ? generateThreeSchemaModel(effective, outPath, modelsWithInput, modelsWithWriteonly)
239
+ : generateSimpleModel(effective, outPath);
240
+
241
+ // Emit Output type alias when this model (transitively) has format(output=...)
242
+ if (modelsWithOutput?.has(effective.name)) {
243
+ lines.push(`export type ${effective.name}Output = z.output<typeof ${effective.name}>;`);
244
+ }
245
+
246
+ return lines;
247
+ }
248
+
249
+ function generateTypeAlias(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>): string[] {
250
+ const lines: string[] = [];
251
+ lines.push(...generateComments(model, outPath));
252
+ lines.push(`export const ${model.name} = ${renderType(model.type!)};`);
253
+ lines.push(`export type ${model.name} = z.infer<typeof ${model.name}>;`);
254
+ if (modelsWithInput?.has(model.name)) {
255
+ lines.push(`export const ${model.name}Input = ${renderInputType(model.type!, modelsWithInput)};`);
256
+ lines.push(`export type ${model.name}Input = z.infer<typeof ${model.name}Input>;`);
257
+ }
258
+ if (modelsWithOutput?.has(model.name)) {
259
+ lines.push(`export type ${model.name}Output = z.output<typeof ${model.name}>;`);
260
+ }
261
+ return lines;
262
+ }
263
+
264
+ function generateSimpleModel(model: ModelNode, outPath?: string): string[] {
265
+ const lines: string[] = [];
266
+ lines.push(...generateComments(model, outPath));
267
+
268
+ const wrapper = modeToWrapper(model.mode ?? 'strict');
269
+
270
+ const { inputCase, outputCase } = model;
271
+ const hasInputTransform = !!inputCase && inputCase !== 'camel';
272
+ const hasOutputTransform = !!outputCase && outputCase !== 'camel';
273
+
274
+ if (hasInputTransform || hasOutputTransform) {
275
+ const inputBody =
276
+ inputCase === 'snake'
277
+ ? renderFieldsAsSnakeCase(model.fields, model.mode)
278
+ : inputCase === 'pascal'
279
+ ? renderFieldsAsPascalCase(model.fields, model.mode)
280
+ : renderFields(model.fields, model.mode);
281
+ lines.push(`export const ${model.name} = ${wrapper}({`);
282
+ lines.push(...inputBody.map(l => ` ${l}`));
283
+ lines.push(`}).transform(data => ({`);
284
+ for (const field of model.fields) {
285
+ const inputKey = applyCase(field.name, inputCase);
286
+ const outputKey = applyCase(field.name, outputCase);
287
+ lines.push(` ${quoteKey(outputKey)}: data.${inputKey},`);
288
+ }
289
+ lines.push(`}));`);
290
+ // When only outputCase is set, the developer-facing type is the schema's
291
+ // pre-transform shape (camelCase). With inputCase, the post-transform
292
+ // shape is what consumers work with.
293
+ const typeSource = hasOutputTransform && !hasInputTransform ? 'input' : 'output';
294
+ lines.push(`export type ${model.name} = z.${typeSource}<typeof ${model.name}>;`);
295
+ return lines;
296
+ }
297
+
298
+ const body = renderFields(model.fields, model.mode);
299
+ const bases = model.bases ?? [];
300
+ if (bases.length > 0) {
301
+ const head = bases[0]!;
302
+ const tail = bases
303
+ .slice(1)
304
+ .map(b => `.extend(${b}.shape)`)
305
+ .join('');
306
+ lines.push(`export const ${model.name} = ${head}${tail}.extend({`);
307
+ lines.push(...body.map(l => ` ${l}`));
308
+ lines.push(`});`);
309
+ } else {
310
+ lines.push(`export const ${model.name} = ${wrapper}({`);
311
+ lines.push(...body.map(l => ` ${l}`));
312
+ lines.push(`});`);
313
+ }
314
+
315
+ lines.push(`export type ${model.name} = z.infer<typeof ${model.name}>;`);
316
+ return lines;
317
+ }
318
+
319
+ /** Builds a Zod extension chain "Head.extend(B.shape).extend(C.shape)..." for a list of base names,
320
+ * applying a per-base name resolver (e.g. choosing "BaseInput" for bases that have an Input variant). */
321
+ function buildExtendChain(bases: string[], resolveName: (b: string) => string): { head: string; tail: string } {
322
+ const head = resolveName(bases[0]!);
323
+ const tail = bases
324
+ .slice(1)
325
+ .map(b => `.extend(${resolveName(b)}.shape)`)
326
+ .join('');
327
+ return { head, tail };
328
+ }
329
+
330
+ function generateThreeSchemaModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelsWithWriteonly?: Set<string>): string[] {
331
+ const lines: string[] = [];
332
+ const name = model.name;
333
+
334
+ lines.push(...generateComments(model, outPath));
335
+
336
+ const wrapper = modeToWrapper(model.mode ?? 'strict');
337
+
338
+ const allFields = model.fields;
339
+ const hasWriteonly = allFields.some(f => f.visibility === 'writeonly');
340
+
341
+ const bases = model.bases ?? [];
342
+
343
+ // Base schema — all fields (used internally when a submodel extends this one).
344
+ // Only needed when this model has writeonly fields; otherwise Base === Read.
345
+ if (hasWriteonly) {
346
+ const baseBody = renderFields(allFields, model.mode);
347
+ if (bases.length > 0) {
348
+ const { head, tail } = buildExtendChain(bases, b => (modelsWithWriteonly?.has(b) ? `${b}Base` : b));
349
+ lines.push(`const ${name}Base = ${head}${tail}.extend({`);
350
+ } else {
351
+ lines.push(`const ${name}Base = ${wrapper}({`);
352
+ }
353
+ lines.push(...baseBody.map(l => ` ${l}`));
354
+ lines.push(`});`);
355
+ lines.push('');
356
+ }
357
+
358
+ // Read schema — omit writeonly fields; extends parent read schema
359
+ const readFields = allFields.filter(f => f.visibility !== 'writeonly');
360
+ const readBody = renderFields(readFields, model.mode);
361
+ if (bases.length > 0) {
362
+ const { head, tail } = buildExtendChain(bases, b => b);
363
+ lines.push(`export const ${name} = ${head}${tail}.extend({`);
364
+ } else {
365
+ lines.push(`export const ${name} = ${wrapper}({`);
366
+ }
367
+ lines.push(...readBody.map(l => ` ${l}`));
368
+ lines.push(`});`);
369
+ lines.push(`export type ${name} = z.infer<typeof ${name}>;`);
370
+ lines.push('');
371
+
372
+ // Write schema — omit readonly fields (use Input variants for sub-type refs);
373
+ // extends ParentInput if parent has an Input variant, else extends parent read schema
374
+ const writeFields = allFields.filter(f => f.visibility !== 'readonly');
375
+ const writeBody = modelsWithInput ? renderInputFields(writeFields, modelsWithInput, model.mode) : renderFields(writeFields, model.mode);
376
+ if (bases.length > 0) {
377
+ const { head, tail } = buildExtendChain(bases, b => (modelsWithInput?.has(b) ? `${b}Input` : b));
378
+ lines.push(`export const ${name}Input = ${head}${tail}.extend({`);
379
+ } else {
380
+ lines.push(`export const ${name}Input = ${wrapper}({`);
381
+ }
382
+ lines.push(...writeBody.map(l => ` ${l}`));
383
+ lines.push(`});`);
384
+ lines.push(`export type ${name}Input = z.infer<typeof ${name}Input>;`);
385
+
386
+ return lines;
387
+ }
388
+
389
+ // ─── Fields ────────────────────────────────────────────────────────────────
390
+
391
+ function camelToSnake(s: string): string {
392
+ return s.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
393
+ }
394
+
395
+ function camelToPascal(s: string): string {
396
+ return s.charAt(0).toUpperCase() + s.slice(1);
397
+ }
398
+
399
+ function applyCase(name: string, caseTransform: 'camel' | 'snake' | 'pascal' | undefined): string {
400
+ if (!caseTransform || caseTransform === 'camel') return name;
401
+ if (caseTransform === 'snake') return camelToSnake(name);
402
+ return camelToPascal(name);
403
+ }
404
+
405
+ function renderFields(fields: FieldNode[], defaultMode?: ObjectMode): string[] {
406
+ return fields.flatMap(f => renderField(f, defaultMode));
407
+ }
408
+
409
+ function renderFieldsAsPascalCase(fields: FieldNode[], defaultMode?: ObjectMode): string[] {
410
+ return fields.map(f => {
411
+ const pascalKey = camelToPascal(f.name);
412
+ let expr = renderType(f.type, 'pascal', defaultMode);
413
+ if (f.default !== undefined) {
414
+ if (f.nullable) expr += '.nullable()';
415
+ const dv = typeof f.default === 'string' ? `"${escapeString(f.default)}"` : String(f.default);
416
+ expr += `.default(${dv})`;
417
+ } else if (f.optional) {
418
+ expr += '.nullish()';
419
+ } else if (f.nullable) {
420
+ expr += '.nullable()';
421
+ }
422
+ if (f.description) expr += `.describe("${escapeString(f.description)}")`;
423
+ return `${quoteKey(pascalKey)}: ${expr},`;
424
+ });
425
+ }
426
+
427
+ function renderFieldsAsSnakeCase(fields: FieldNode[], defaultMode?: ObjectMode): string[] {
428
+ return fields.map(f => {
429
+ const snakeKey = camelToSnake(f.name);
430
+ let expr = renderType(f.type, 'snake', defaultMode);
431
+ if (f.default !== undefined) {
432
+ if (f.nullable) expr += '.nullable()';
433
+ const dv = typeof f.default === 'string' ? `"${escapeString(f.default)}"` : String(f.default);
434
+ expr += `.default(${dv})`;
435
+ } else if (f.optional) {
436
+ // .nullish() accepts null or undefined from the API; the transform coerces null → undefined
437
+ expr += '.nullish()';
438
+ } else if (f.nullable) {
439
+ expr += '.nullable()';
440
+ }
441
+ if (f.description) expr += `.describe("${escapeString(f.description)}")`;
442
+ return `${quoteKey(snakeKey)}: ${expr},`;
443
+ });
444
+ }
445
+
446
+ function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
447
+ const lines: string[] = [];
448
+ if (field.deprecated) lines.push('/** @deprecated */');
449
+
450
+ let expr = renderType(field.type, undefined, defaultMode);
451
+
452
+ if (field.nullable) expr += '.nullable()';
453
+ if (field.default !== undefined) {
454
+ const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
455
+ expr += `.default(${dv})`;
456
+ } else if (field.optional) {
457
+ expr += '.optional()';
458
+ }
459
+ if (field.description) expr += `.describe("${escapeString(field.description)}")`;
460
+
461
+ lines.push(`${quoteKey(field.name)}: ${expr},`);
462
+ return lines;
463
+ }
464
+
465
+ // ─── Type rendering ────────────────────────────────────────────────────────
466
+
467
+ export function renderType(type: ContractTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
468
+ switch (type.kind) {
469
+ case 'scalar':
470
+ return renderScalar(type);
471
+ case 'array':
472
+ return renderArray(type, parseCaseTransform, defaultMode);
473
+ case 'tuple':
474
+ return renderTuple(type);
475
+ case 'record':
476
+ return renderRecord(type);
477
+ case 'enum':
478
+ return renderEnum(type);
479
+ case 'literal':
480
+ return renderLiteral(type);
481
+ case 'union':
482
+ return renderUnion(type, parseCaseTransform, defaultMode);
483
+ case 'discriminatedUnion':
484
+ return renderDiscriminatedUnion(type, parseCaseTransform, defaultMode);
485
+ case 'intersection':
486
+ return renderIntersection(type, parseCaseTransform, defaultMode);
487
+ case 'ref':
488
+ return type.name;
489
+ case 'lazy':
490
+ return `z.lazy(() => ${renderType(type.inner, parseCaseTransform, defaultMode)})`;
491
+ case 'inlineObject':
492
+ return renderInlineObject(type, parseCaseTransform, defaultMode);
493
+ default:
494
+ return 'z.unknown()';
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Render a regex source as a JS regex literal for `.regex(...)`. If the source already has
500
+ * anchors (`^` at the start and/or an unescaped `$` at the end) we trust the user's intent
501
+ * and emit it as-is; otherwise we wrap with `^...$` so contracts default to full-match
502
+ * semantics. Forward slashes are always escaped since `/` is the literal delimiter.
503
+ */
504
+ function renderRegexLiteral(source: string): string {
505
+ const body = source.replace(/\//g, '\\/');
506
+ if (regexHasAnchor(source)) return `/${body}/`;
507
+ return `/^${body}$/`;
508
+ }
509
+
510
+ function regexHasAnchor(source: string): boolean {
511
+ if (source.startsWith('^')) return true;
512
+ if (!source.endsWith('$')) return false;
513
+ // The trailing `$` is an anchor only if it isn't escaped — count immediately preceding
514
+ // backslashes; an even count (including zero) means `$` is unescaped.
515
+ let i = source.length - 2;
516
+ let backslashes = 0;
517
+ while (i >= 0 && source[i] === '\\') {
518
+ backslashes++;
519
+ i--;
520
+ }
521
+ return backslashes % 2 === 0;
522
+ }
523
+
524
+ function renderScalar(s: ScalarTypeNode): string {
525
+ switch (s.name) {
526
+ case 'string': {
527
+ let e = 'z.string()';
528
+ if (s.min !== undefined && s.max !== undefined) e += `.min(${s.min}).max(${s.max})`;
529
+ else if (s.min !== undefined) e += `.min(${s.min})`;
530
+ else if (s.max !== undefined) e += `.max(${s.max})`;
531
+ if (s.len !== undefined) e += `.length(${s.len})`;
532
+ if (s.regex) e += `.regex(${renderRegexLiteral(s.regex)})`;
533
+ return e;
534
+ }
535
+ case 'number': {
536
+ let e = 'z.coerce.number()';
537
+ if (s.min !== undefined) e += `.min(${s.min})`;
538
+ if (s.max !== undefined) e += `.max(${s.max})`;
539
+ return e;
540
+ }
541
+ case 'int': {
542
+ let e = 'z.coerce.number().int()';
543
+ if (s.min !== undefined) e += `.min(${s.min})`;
544
+ if (s.max !== undefined) e += `.max(${s.max})`;
545
+ return e;
546
+ }
547
+ case 'bigint': {
548
+ let inner = 'z.bigint()';
549
+ if (s.min !== undefined) inner += `.min(${s.min}n)`;
550
+ if (s.max !== undefined) inner += `.max(${s.max}n)`;
551
+ return `z.preprocess((val) => typeof val === 'string' ? BigInt(val.replace(/n$/, '')) : val, ${inner})`;
552
+ }
553
+ case 'boolean':
554
+ return `z.preprocess((v) => v === 'true' ? true : v === 'false' ? false : v, z.boolean())`;
555
+ case 'date': {
556
+ const fmt = s.format ?? 'yyyy-MM-dd';
557
+ return `z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, '${escapeString(fmt)}') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a date in format ${escapeString(fmt)}' }))`;
558
+ }
559
+ case 'time': {
560
+ const fmt = s.format ?? 'HH:mm:ss';
561
+ return `z.preprocess((val) => typeof val === 'string' ? DateTime.fromFormat(val, '${escapeString(fmt)}') : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be a time in format ${escapeString(fmt)}' }))`;
562
+ }
563
+ case 'datetime':
564
+ return '_ZodDatetime';
565
+ case 'interval':
566
+ return '_ZodInterval';
567
+ case 'duration': {
568
+ const validParts = [`val instanceof Duration && val.isValid`];
569
+ if (s.min !== undefined) validParts.push(`val.toMillis() >= Duration.fromISO('${s.min}').toMillis()`);
570
+ if (s.max !== undefined) validParts.push(`val.toMillis() <= Duration.fromISO('${s.max}').toMillis()`);
571
+ const validation = validParts.join(' && ');
572
+ let message = 'Must be an ISO 8601 duration';
573
+ if (s.min !== undefined && s.max !== undefined) message += ` between ${s.min} and ${s.max}`;
574
+ else if (s.min !== undefined) message += ` of at least ${s.min}`;
575
+ else if (s.max !== undefined) message += ` of at most ${s.max}`;
576
+ return `z.preprocess((val) => typeof val === 'string' ? Duration.fromISO(val) : val, z.custom<Duration>((val) => ${validation}, { message: '${message}' }))`;
577
+ }
578
+ case 'email':
579
+ return 'z.email()';
580
+ case 'url':
581
+ return 'z.url()';
582
+ case 'uuid':
583
+ return 'z.uuid()';
584
+ case 'unknown':
585
+ return 'z.unknown()';
586
+ case 'null':
587
+ return 'z.null()';
588
+ case 'object':
589
+ return 'z.record(z.string(), z.unknown())';
590
+ case 'binary':
591
+ return '_ZodBinary';
592
+ case 'json':
593
+ return '_ZodJson';
594
+ default:
595
+ return 'z.unknown()';
596
+ }
597
+ }
598
+
599
+ function renderArray(a: ArrayTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
600
+ let e = `z.array(${renderType(a.item, parseCaseTransform, defaultMode)})`;
601
+ if (a.min !== undefined) e += `.min(${a.min})`;
602
+ if (a.max !== undefined) e += `.max(${a.max})`;
603
+ return e;
604
+ }
605
+
606
+ function renderTuple(t: TupleTypeNode): string {
607
+ return `z.tuple([${t.items.map(i => renderType(i)).join(', ')}])`;
608
+ }
609
+
610
+ function renderRecord(r: RecordTypeNode): string {
611
+ return `z.record(${renderType(r.key)}, ${renderType(r.value)})`;
612
+ }
613
+
614
+ function renderEnum(e: EnumTypeNode): string {
615
+ const vals = e.values.map(v => `"${v}"`).join(', ');
616
+ return `z.enum([${vals}])`;
617
+ }
618
+
619
+ function renderLiteral(l: LiteralTypeNode): string {
620
+ if (typeof l.value === 'string') return `z.literal("${escapeString(l.value)}")`;
621
+ return `z.literal(${l.value})`;
622
+ }
623
+
624
+ function renderUnion(u: UnionTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
625
+ return `z.union([${u.members.map(m => renderType(m, parseCaseTransform, defaultMode)).join(', ')}])`;
626
+ }
627
+
628
+ function renderDiscriminatedUnion(u: DiscriminatedUnionTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
629
+ return `z.discriminatedUnion("${escapeString(u.discriminator)}", [${u.members.map(m => renderType(m, parseCaseTransform, defaultMode)).join(', ')}])`;
630
+ }
631
+
632
+ function renderIntersection(i: IntersectionTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
633
+ const [first, ...rest] = i.members;
634
+ // When the pattern is ref & { inlineObject(s) }, use .extend() to produce a
635
+ // single merged ZodObject. Using .and(z.strictObject) breaks because each
636
+ // strict side rejects the other side's keys during intersection parsing.
637
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
638
+ const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
639
+ const fieldLines =
640
+ parseCaseTransform === 'snake'
641
+ ? renderFieldsAsSnakeCase(allFields, defaultMode)
642
+ .map(l => ` ${l}`)
643
+ .join('\n')
644
+ : parseCaseTransform === 'pascal'
645
+ ? renderFieldsAsPascalCase(allFields, defaultMode)
646
+ .map(l => ` ${l}`)
647
+ .join('\n')
648
+ : allFields
649
+ .flatMap(f => renderField(f, defaultMode))
650
+ .map(l => ` ${l}`)
651
+ .join('\n');
652
+ return `${first.name}.extend({\n${fieldLines}\n})`;
653
+ }
654
+ let expr = renderType(first!, parseCaseTransform, defaultMode);
655
+ for (const member of rest) {
656
+ expr += `.and(${renderType(member, parseCaseTransform, defaultMode)})`;
657
+ }
658
+ return expr;
659
+ }
660
+
661
+ function renderInlineObject(o: InlineObjectTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
662
+ const wrapper = modeToWrapper(o.mode ?? defaultMode ?? 'strict');
663
+ if (parseCaseTransform === 'snake') {
664
+ const snakeLines = renderFieldsAsSnakeCase(o.fields, defaultMode);
665
+ const joined = snakeLines.map(l => ` ${l}`).join('\n');
666
+ const transformEntries = o.fields
667
+ .map(f => {
668
+ const snakeKey = camelToSnake(f.name);
669
+ // Optional fields use .nullish() on input; coerce null → undefined in output
670
+ const val = f.optional ? `data.${snakeKey} ?? undefined` : `data.${snakeKey}`;
671
+ return ` ${quoteKey(f.name)}: ${val},`;
672
+ })
673
+ .join('\n');
674
+ return `${wrapper}({\n${joined}\n}).transform(data => ({\n${transformEntries}\n}))`;
675
+ }
676
+ if (parseCaseTransform === 'pascal') {
677
+ const pascalLines = renderFieldsAsPascalCase(o.fields, defaultMode);
678
+ const joined = pascalLines.map(l => ` ${l}`).join('\n');
679
+ const transformEntries = o.fields
680
+ .map(f => {
681
+ const pascalKey = camelToPascal(f.name);
682
+ const val = f.optional ? `data.${pascalKey} ?? undefined` : `data.${pascalKey}`;
683
+ return ` ${quoteKey(f.name)}: ${val},`;
684
+ })
685
+ .join('\n');
686
+ return `${wrapper}({\n${joined}\n}).transform(data => ({\n${transformEntries}\n}))`;
687
+ }
688
+ const fields = o.fields
689
+ .flatMap(f => renderField(f, defaultMode))
690
+ .map(l => ` ${l}`)
691
+ .join('\n');
692
+ return `${wrapper}({\n${fields}\n})`;
693
+ }
694
+
695
+ // ─── Input type rendering ─────────────────────────────────────────────────
696
+
697
+ /**
698
+ * Like renderScalar, but coerces from string input (JSON wire format).
699
+ * Used for Input (write) schemas where data arrives as JSON strings.
700
+ */
701
+ function renderInputScalar(s: ScalarTypeNode): string {
702
+ return renderScalar(s);
703
+ }
704
+
705
+ /**
706
+ * Like renderType, but substitutes model refs with their Input variant
707
+ * when the model has visibility modifiers, and coerces scalars from strings.
708
+ * Used for Input (write) schema fields so that sub-type references also
709
+ * point to their Input variants.
710
+ */
711
+ export function renderInputType(type: ContractTypeNode, modelsWithInput?: Set<string>, defaultMode?: ObjectMode): string {
712
+ switch (type.kind) {
713
+ case 'scalar':
714
+ return renderInputScalar(type);
715
+ case 'ref':
716
+ return modelsWithInput?.has(type.name) ? `${type.name}Input` : type.name;
717
+ case 'array': {
718
+ let e = `z.array(${renderInputType(type.item, modelsWithInput, defaultMode)})`;
719
+ if (type.min !== undefined) e += `.min(${type.min})`;
720
+ if (type.max !== undefined) e += `.max(${type.max})`;
721
+ return e;
722
+ }
723
+ case 'tuple':
724
+ return `z.tuple([${type.items.map(i => renderInputType(i, modelsWithInput, defaultMode)).join(', ')}])`;
725
+ case 'record':
726
+ return `z.record(${renderInputType(type.key, modelsWithInput, defaultMode)}, ${renderInputType(type.value, modelsWithInput, defaultMode)})`;
727
+ case 'union':
728
+ return `z.union([${type.members.map(m => renderInputType(m, modelsWithInput, defaultMode)).join(', ')}])`;
729
+ case 'discriminatedUnion':
730
+ return `z.discriminatedUnion("${escapeString(type.discriminator)}", [${type.members.map(m => renderInputType(m, modelsWithInput, defaultMode)).join(', ')}])`;
731
+ case 'intersection': {
732
+ const [first, ...rest] = type.members;
733
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
734
+ const base = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
735
+ const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
736
+ const fieldLines = allFields.map(f => ` ${renderInputField(f, modelsWithInput ?? new Set(), defaultMode)}`).join('\n');
737
+ return `${base}.extend({\n${fieldLines}\n})`;
738
+ }
739
+ let expr = renderInputType(first!, modelsWithInput, defaultMode);
740
+ for (const member of rest) {
741
+ expr += `.and(${renderInputType(member, modelsWithInput, defaultMode)})`;
742
+ }
743
+ return expr;
744
+ }
745
+ case 'lazy':
746
+ return `z.lazy(() => ${renderInputType(type.inner, modelsWithInput, defaultMode)})`;
747
+ case 'inlineObject': {
748
+ const fields = type.fields
749
+ .flatMap(f => renderInputField(f, modelsWithInput ?? new Set(), defaultMode))
750
+ .map(l => ` ${l}`)
751
+ .join('\n');
752
+ return `${modeToWrapper(type.mode ?? defaultMode ?? 'strict')}({\n${fields}\n})`;
753
+ }
754
+ default:
755
+ return renderType(type, undefined, defaultMode);
756
+ }
757
+ }
758
+
759
+ function renderInputField(field: FieldNode, modelsWithInput: Set<string>, defaultMode?: ObjectMode): string[] {
760
+ const lines: string[] = [];
761
+ if (field.deprecated) lines.push('/** @deprecated */');
762
+
763
+ let expr = renderInputType(field.type, modelsWithInput, defaultMode);
764
+
765
+ if (field.nullable) expr += '.nullable()';
766
+ if (field.default !== undefined) {
767
+ const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
768
+ expr += `.default(${dv})`;
769
+ } else if (field.optional) {
770
+ expr += '.optional()';
771
+ }
772
+ if (field.description) expr += `.describe("${escapeString(field.description)}")`;
773
+
774
+ lines.push(`${quoteKey(field.name)}: ${expr},`);
775
+ return lines;
776
+ }
777
+
778
+ function renderInputFields(fields: FieldNode[], modelsWithInput: Set<string>, defaultMode?: ObjectMode): string[] {
779
+ return fields.flatMap(f => renderInputField(f, modelsWithInput, defaultMode));
780
+ }
781
+
782
+ // ─── Query type rendering ─────────────────────────────────────────────────
783
+
784
+ /**
785
+ * Like renderType, but wraps array types with z.preprocess to handle
786
+ * query strings where a single value arrives as a string instead of a string[].
787
+ * Also uses Input variants for model refs when modelsWithInput is provided.
788
+ */
789
+ export function renderQueryType(type: ContractTypeNode, modelsWithInput?: Set<string>, defaultMode?: ObjectMode): string {
790
+ switch (type.kind) {
791
+ case 'array': {
792
+ const inner = modelsWithInput ? renderInputType(type, modelsWithInput, defaultMode) : renderType(type, undefined, defaultMode);
793
+ return `z.preprocess((v) => typeof v === 'string' ? v.split(',') : v, ${inner})`;
794
+ }
795
+ case 'inlineObject': {
796
+ const fields = type.fields.map(f => ` ${renderQueryField(f, modelsWithInput, defaultMode)}`).join('\n');
797
+ return `${modeToWrapper(type.mode ?? defaultMode ?? 'strict')}({\n${fields}\n})`;
798
+ }
799
+ case 'intersection': {
800
+ const [first, ...rest] = type.members;
801
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
802
+ const base = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
803
+ const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
804
+ const fieldLines = allFields.map(f => ` ${renderQueryField(f, modelsWithInput, defaultMode)}`).join('\n');
805
+ return `${base}.extend({\n${fieldLines}\n})`;
806
+ }
807
+ let expr = renderQueryType(first!, modelsWithInput, defaultMode);
808
+ for (const member of rest) {
809
+ expr += `.and(${renderQueryType(member, modelsWithInput, defaultMode)})`;
810
+ }
811
+ return expr;
812
+ }
813
+ case 'ref':
814
+ return modelsWithInput?.has(type.name) ? `${type.name}Input` : type.name;
815
+ default:
816
+ return modelsWithInput ? renderInputType(type, modelsWithInput, defaultMode) : renderType(type, undefined, defaultMode);
817
+ }
818
+ }
819
+
820
+ function renderQueryField(field: FieldNode, modelsWithInput?: Set<string>, defaultMode?: ObjectMode): string {
821
+ let expr =
822
+ field.type.kind === 'array'
823
+ ? renderQueryType(field.type, modelsWithInput, defaultMode)
824
+ : modelsWithInput
825
+ ? renderInputType(field.type, modelsWithInput, defaultMode)
826
+ : renderType(field.type, undefined, defaultMode);
827
+
828
+ if (field.nullable) expr += '.nullable()';
829
+ if (field.default !== undefined) {
830
+ const dv = typeof field.default === 'string' ? `"${escapeString(field.default)}"` : String(field.default);
831
+ expr += `.default(${dv})`;
832
+ } else if (field.optional) {
833
+ expr += '.optional()';
834
+ }
835
+ if (field.description) expr += `.describe("${escapeString(field.description)}")`;
836
+
837
+ return `${quoteKey(field.name)}: ${expr},`;
838
+ }
839
+
840
+ function isValidIdentifier(name: string): boolean {
841
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
842
+ }
843
+
844
+ function quoteKey(name: string): string {
845
+ return isValidIdentifier(name) ? name : `'${name}'`;
846
+ }
847
+
848
+ // ─── String escaping ──────────────────────────────────────────────────────
849
+
850
+ function escapeString(s: string): string {
851
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
852
+ }
853
+
854
+ // ─── Helpers ───────────────────────────────────────────────────────────────
855
+
856
+ function rootNeedsDateTime(root: ContractRootNode): boolean {
857
+ return root.models.some(m => (m.type && typeNeedsDateTime(m.type)) || m.fields.some(f => typeNeedsDateTime(f.type)));
858
+ }
859
+
860
+ export function typeNeedsScalar(type: ContractTypeNode, name: string): boolean {
861
+ switch (type.kind) {
862
+ case 'scalar':
863
+ return type.name === name;
864
+ case 'array':
865
+ return typeNeedsScalar(type.item, name);
866
+ case 'tuple':
867
+ return type.items.some(i => typeNeedsScalar(i, name));
868
+ case 'record':
869
+ return typeNeedsScalar(type.key, name) || typeNeedsScalar(type.value, name);
870
+ case 'union':
871
+ return type.members.some(m => typeNeedsScalar(m, name));
872
+ case 'discriminatedUnion':
873
+ return type.members.some(m => typeNeedsScalar(m, name));
874
+ case 'intersection':
875
+ return type.members.some(m => typeNeedsScalar(m, name));
876
+ case 'lazy':
877
+ return typeNeedsScalar(type.inner, name);
878
+ case 'inlineObject':
879
+ return type.fields.some(f => typeNeedsScalar(f.type, name));
880
+ default:
881
+ return false;
882
+ }
883
+ }
884
+
885
+ export function rootNeedsScalar(root: ContractRootNode, name: string): boolean {
886
+ return root.models.some(m => (m.type && typeNeedsScalar(m.type, name)) || m.fields.some(f => typeNeedsScalar(f.type, name)));
887
+ }
888
+
889
+ export function typeNeedsDateTime(type: ContractTypeNode): boolean {
890
+ switch (type.kind) {
891
+ case 'scalar':
892
+ return type.name === 'date' || type.name === 'time' || type.name === 'datetime';
893
+ case 'array':
894
+ return typeNeedsDateTime(type.item);
895
+ case 'union':
896
+ return type.members.some(typeNeedsDateTime);
897
+ case 'discriminatedUnion':
898
+ return type.members.some(typeNeedsDateTime);
899
+ case 'intersection':
900
+ return type.members.some(typeNeedsDateTime);
901
+ case 'inlineObject':
902
+ return type.fields.some(f => typeNeedsDateTime(f.type));
903
+ default:
904
+ return false;
905
+ }
906
+ }
907
+
908
+ export function collectExternalRefs(root: ContractRootNode): string[] {
909
+ const localNames = new Set(root.models.map(m => m.name));
910
+ const refs = new Set<string>();
911
+
912
+ for (const model of root.models) {
913
+ if (model.bases?.[0] && !localNames.has(model.bases?.[0])) refs.add(model.bases?.[0]);
914
+ if (model.type) collectTypeRefs(model.type, refs);
915
+ for (const field of model.fields) {
916
+ collectTypeRefs(field.type, refs);
917
+ }
918
+ }
919
+
920
+ for (const name of localNames) refs.delete(name);
921
+ return [...refs].sort();
922
+ }
923
+
924
+ /** Collect external Input variant refs needed for Input schema fields. */
925
+ export function collectExternalInputRefs(root: ContractRootNode, modelsWithInput: Set<string>): string[] {
926
+ const localNames = new Set(root.models.map(m => m.name));
927
+ const refs = new Set<string>();
928
+
929
+ for (const model of root.models) {
930
+ if (!modelsWithInput.has(model.name)) continue;
931
+ // Type alias: collect Input refs from the aliased type expression.
932
+ if (model.type) {
933
+ collectInputTypeRefs(model.type, refs, modelsWithInput);
934
+ continue;
935
+ }
936
+ // When a model extends an external parent that has an Input variant,
937
+ // the write schema extends ParentInput — so we need to import it.
938
+ if (model.bases?.[0] && modelsWithInput.has(model.bases?.[0]) && !localNames.has(model.bases?.[0])) {
939
+ refs.add(`${model.bases?.[0]}Input`);
940
+ }
941
+ const writeFields = model.fields.filter(f => f.visibility !== 'readonly');
942
+ for (const field of writeFields) {
943
+ collectInputTypeRefs(field.type, refs, modelsWithInput);
944
+ }
945
+ }
946
+
947
+ // Remove locally defined Input variants (generated in this file)
948
+ for (const name of localNames) {
949
+ refs.delete(`${name}Input`);
950
+ }
951
+
952
+ return [...refs].sort();
953
+ }
954
+
955
+ function collectInputTypeRefs(type: ContractTypeNode, out: Set<string>, modelsWithInput: Set<string>): void {
956
+ switch (type.kind) {
957
+ case 'ref':
958
+ if (modelsWithInput.has(type.name)) out.add(`${type.name}Input`);
959
+ break;
960
+ case 'array':
961
+ collectInputTypeRefs(type.item, out, modelsWithInput);
962
+ break;
963
+ case 'tuple':
964
+ type.items.forEach(i => collectInputTypeRefs(i, out, modelsWithInput));
965
+ break;
966
+ case 'record':
967
+ collectInputTypeRefs(type.key, out, modelsWithInput);
968
+ collectInputTypeRefs(type.value, out, modelsWithInput);
969
+ break;
970
+ case 'union':
971
+ type.members.forEach(m => collectInputTypeRefs(m, out, modelsWithInput));
972
+ break;
973
+ case 'discriminatedUnion':
974
+ type.members.forEach(m => collectInputTypeRefs(m, out, modelsWithInput));
975
+ break;
976
+ case 'intersection':
977
+ type.members.forEach(m => collectInputTypeRefs(m, out, modelsWithInput));
978
+ break;
979
+ case 'lazy':
980
+ collectInputTypeRefs(type.inner, out, modelsWithInput);
981
+ break;
982
+ case 'inlineObject':
983
+ type.fields.forEach(f => collectInputTypeRefs(f.type, out, modelsWithInput));
984
+ break;
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Topologically sort models so dependencies are emitted before dependents.
990
+ * Falls back to source order for cycles (which would need z.lazy at runtime).
991
+ */
992
+ export function topoSortModels(models: ModelNode[]): ModelNode[] {
993
+ const localNames = new Set(models.map(m => m.name));
994
+ const modelMap = new Map(models.map(m => [m.name, m]));
995
+
996
+ // Build adjacency: model name → set of local model names it depends on
997
+ const deps = new Map<string, Set<string>>();
998
+ for (const model of models) {
999
+ const refs = new Set<string>();
1000
+ if (model.bases?.[0] && localNames.has(model.bases?.[0])) refs.add(model.bases?.[0]);
1001
+ if (model.type) collectTypeRefs(model.type, refs);
1002
+ for (const field of model.fields) {
1003
+ collectTypeRefs(field.type, refs);
1004
+ }
1005
+ // Keep only local dependencies
1006
+ const localDeps = new Set<string>();
1007
+ for (const r of refs) {
1008
+ if (localNames.has(r) && r !== model.name) localDeps.add(r);
1009
+ }
1010
+ deps.set(model.name, localDeps);
1011
+ }
1012
+
1013
+ // Kahn's algorithm
1014
+ const inDegree = new Map<string, number>();
1015
+ for (const name of localNames) inDegree.set(name, 0);
1016
+ for (const [, d] of deps) {
1017
+ for (const dep of d) {
1018
+ inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
1019
+ }
1020
+ }
1021
+
1022
+ // Note: inDegree counts how many models *depend on* this model,
1023
+ // but for Kahn's we need how many dependencies each model has.
1024
+ // Re-do: inDegree = number of unresolved deps for each model.
1025
+ const remaining = new Map<string, Set<string>>();
1026
+ for (const [name, d] of deps) {
1027
+ remaining.set(name, new Set(d));
1028
+ }
1029
+
1030
+ const queue: string[] = [];
1031
+ for (const name of localNames) {
1032
+ if (remaining.get(name)!.size === 0) queue.push(name);
1033
+ }
1034
+
1035
+ const sorted: ModelNode[] = [];
1036
+ while (queue.length > 0) {
1037
+ const name = queue.shift()!;
1038
+ sorted.push(modelMap.get(name)!);
1039
+ // Remove this model from all dependents' remaining sets
1040
+ for (const [other, rem] of remaining) {
1041
+ if (rem.delete(name) && rem.size === 0) {
1042
+ queue.push(other);
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ // Append any models not yet emitted (cycles)
1048
+ for (const model of models) {
1049
+ if (!sorted.includes(model)) sorted.push(model);
1050
+ }
1051
+
1052
+ return sorted;
1053
+ }
1054
+
1055
+ /**
1056
+ * Resolve the import path for an external model reference.
1057
+ * When a codegen context is available, computes the correct relative path
1058
+ * from the current file to the referenced model's output file.
1059
+ * Falls back to same-directory PascalCase → dot.case convention.
1060
+ */
1061
+ export function resolveImportPath(refName: string, context?: ContractCodegenContext): string {
1062
+ if (context) {
1063
+ const refOutPath = context.modelOutPaths.get(refName);
1064
+ if (refOutPath) {
1065
+ const fromDir = dirname(context.currentOutPath);
1066
+ let rel = relative(fromDir, refOutPath);
1067
+ // Replace .ts extension with .js for ESM imports
1068
+ rel = rel.replace(/\.ts$/, '.js');
1069
+ // Ensure relative path starts with ./ or ../
1070
+ if (!rel.startsWith('.')) rel = './' + rel;
1071
+ return rel;
1072
+ }
1073
+ }
1074
+ // Fallback: assume same directory, use PascalCase → dot.case convention
1075
+ const moduleName = pascalToDotCase(refName);
1076
+ return `./${moduleName}.js`;
1077
+ }
1078
+
1079
+ /** Convert PascalCase to dot-separated lowercase: CounterpartyAccount → counterparty.account */
1080
+ export function pascalToDotCase(name: string): string {
1081
+ return name.replace(/([a-z0-9])([A-Z])/g, '$1.$2').toLowerCase();
1082
+ }