@workos/oagen-emitters 0.4.0 → 0.5.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 (105) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +8 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +10 -34
  21. package/src/dotnet/index.ts +6 -4
  22. package/src/dotnet/models.ts +58 -82
  23. package/src/dotnet/naming.ts +44 -6
  24. package/src/dotnet/resources.ts +350 -29
  25. package/src/dotnet/tests.ts +44 -24
  26. package/src/dotnet/type-map.ts +44 -17
  27. package/src/dotnet/wrappers.ts +21 -10
  28. package/src/go/client.ts +35 -3
  29. package/src/go/enums.ts +4 -0
  30. package/src/go/index.ts +10 -5
  31. package/src/go/models.ts +6 -1
  32. package/src/go/resources.ts +534 -73
  33. package/src/go/tests.ts +39 -3
  34. package/src/go/type-map.ts +8 -3
  35. package/src/go/wrappers.ts +79 -21
  36. package/src/index.ts +14 -0
  37. package/src/kotlin/client.ts +7 -2
  38. package/src/kotlin/enums.ts +30 -3
  39. package/src/kotlin/models.ts +97 -6
  40. package/src/kotlin/naming.ts +7 -1
  41. package/src/kotlin/resources.ts +370 -39
  42. package/src/kotlin/tests.ts +120 -6
  43. package/src/node/client.ts +38 -11
  44. package/src/node/field-plan.ts +12 -14
  45. package/src/node/fixtures.ts +39 -3
  46. package/src/node/models.ts +281 -37
  47. package/src/node/resources.ts +156 -52
  48. package/src/node/tests.ts +76 -27
  49. package/src/node/type-map.ts +1 -31
  50. package/src/node/utils.ts +96 -6
  51. package/src/node/wrappers.ts +31 -1
  52. package/src/php/models.ts +0 -33
  53. package/src/php/resources.ts +199 -18
  54. package/src/php/tests.ts +26 -2
  55. package/src/php/type-map.ts +16 -2
  56. package/src/php/wrappers.ts +6 -2
  57. package/src/plugin.ts +50 -0
  58. package/src/python/client.ts +13 -3
  59. package/src/python/enums.ts +28 -3
  60. package/src/python/index.ts +35 -27
  61. package/src/python/models.ts +138 -1
  62. package/src/python/resources.ts +234 -17
  63. package/src/python/tests.ts +260 -16
  64. package/src/python/type-map.ts +16 -2
  65. package/src/ruby/client.ts +238 -0
  66. package/src/ruby/enums.ts +149 -0
  67. package/src/ruby/index.ts +93 -0
  68. package/src/ruby/manifest.ts +35 -0
  69. package/src/ruby/models.ts +360 -0
  70. package/src/ruby/naming.ts +187 -0
  71. package/src/ruby/rbi.ts +313 -0
  72. package/src/ruby/resources.ts +799 -0
  73. package/src/ruby/tests.ts +459 -0
  74. package/src/ruby/type-map.ts +97 -0
  75. package/src/ruby/wrappers.ts +161 -0
  76. package/src/shared/model-utils.ts +131 -7
  77. package/src/shared/naming-utils.ts +36 -0
  78. package/src/shared/non-spec-services.ts +13 -0
  79. package/src/shared/resolved-ops.ts +75 -1
  80. package/test/dotnet/client.test.ts +2 -2
  81. package/test/dotnet/models.test.ts +7 -9
  82. package/test/dotnet/resources.test.ts +135 -3
  83. package/test/dotnet/tests.test.ts +5 -5
  84. package/test/entrypoint.test.ts +89 -0
  85. package/test/go/client.test.ts +6 -6
  86. package/test/go/resources.test.ts +156 -7
  87. package/test/kotlin/models.test.ts +1 -1
  88. package/test/kotlin/resources.test.ts +210 -0
  89. package/test/node/models.test.ts +134 -1
  90. package/test/node/resources.test.ts +134 -26
  91. package/test/node/utils.test.ts +140 -0
  92. package/test/php/models.test.ts +5 -4
  93. package/test/php/resources.test.ts +66 -1
  94. package/test/plugin.test.ts +50 -0
  95. package/test/python/client.test.ts +56 -0
  96. package/test/python/models.test.ts +99 -0
  97. package/test/python/resources.test.ts +294 -0
  98. package/test/python/tests.test.ts +91 -0
  99. package/test/ruby/client.test.ts +81 -0
  100. package/test/ruby/resources.test.ts +386 -0
  101. package/test/shared/resolved-ops.test.ts +122 -0
  102. package/tsdown.config.ts +1 -1
  103. package/dist/index.mjs.map +0 -1
  104. package/scripts/generate-php.js +0 -13
  105. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
4
  import { mapTypeRef, mapWireTypeRef } from './type-map.js';
3
5
  import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
@@ -7,7 +9,6 @@ import {
7
9
  buildGenericModelDefaults,
8
10
  pruneUnusedImports,
9
11
  TS_BUILTINS,
10
- detectStringDateConvention,
11
12
  buildKnownTypeNames,
12
13
  isBaselineGeneric,
13
14
  createServiceDirResolver,
@@ -15,6 +16,8 @@ import {
15
16
  isListWrapperModel,
16
17
  buildDeduplicationMap,
17
18
  relativeImport,
19
+ modelHasNewFields,
20
+ computeNonEventReachable,
18
21
  } from './utils.js';
19
22
  import { assignEnumsToServices } from './enums.js';
20
23
  import {
@@ -23,6 +26,7 @@ import {
23
26
  buildSkipFormatFields,
24
27
  shouldSkipSerializeForModel,
25
28
  emitSerializerBody,
29
+ hasDateTimeConversion,
26
30
  } from './field-plan.js';
27
31
 
28
32
  /**
@@ -52,10 +56,9 @@ function enrichGenericDefaultsFromBaseline(
52
56
  const baseline = ctx.apiSurface.interfaces[domainName];
53
57
  if (!baseline?.fields) continue;
54
58
 
55
- // Only enrich generic defaults for models whose baseline file will be
56
- // preserved via skipIfExists (paths match). If the file is generated
57
- // fresh in a new directory, it won't have generics, so references
58
- // to it don't need type args.
59
+ // Only enrich generic defaults for models whose baseline file path matches
60
+ // the generated path. If the file is generated in a new directory, it
61
+ // won't have generics, so references to it don't need type args.
59
62
  const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
60
63
  const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
61
64
  if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
@@ -72,23 +75,56 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
72
75
  const {
73
76
  modelToService,
74
77
  resolveDir,
75
- useStringDates,
76
78
  dedup: sharedDedup,
77
79
  genericDefaults: sharedDefaults,
78
80
  } = shared ?? buildSharedContext(models, ctx);
79
81
  const genericDefaults = sharedDefaults;
80
- const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
82
+ const typeRefOpts = { genericDefaults };
81
83
  const wireTypeRefOpts = { genericDefaults };
82
84
  const files: GeneratedFile[] = [];
83
85
  const dedup = sharedDedup;
84
86
 
87
+ // Only generate files for models reachable from non-event service operations.
88
+ // Event operations (listEvents) pull in hundreds of webhook payload models
89
+ // that the existing SDK handles via hand-written event types. Skip those.
90
+ const reachableModels = computeNonEventReachable(ctx.spec.services, models);
91
+
92
+ // Force-generate models that are dependencies of generated models but whose
93
+ // baseline definitions are inline in another file. The merger will replace the
94
+ // parent symbol, losing the inline definition, so a separate file is needed.
95
+ const forceGenerate = new Set<string>();
96
+ for (const model of models) {
97
+ if (!reachableModels.has(model.name)) continue;
98
+ if (!modelHasNewFields(model, ctx)) continue;
99
+ const service = modelToService.get(model.name);
100
+ const dirName = resolveDir(service);
101
+ const parentPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
102
+ const deps = collectFieldDependencies(model);
103
+ for (const dep of deps.models) {
104
+ if (forceGenerate.has(dep)) continue;
105
+ const depName = resolveInterfaceName(dep, ctx);
106
+ const depBaseline = ctx.apiSurface?.interfaces?.[depName];
107
+ const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
108
+ if (depSrc === parentPath) {
109
+ // The dependency's baseline is inline in the parent's file — force-generate
110
+ forceGenerate.add(dep);
111
+ }
112
+ }
113
+ }
114
+
85
115
  for (const model of models) {
116
+ if (!reachableModels.has(model.name)) continue;
117
+
86
118
  // Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
87
119
  if (isListMetadataModel(model)) continue;
88
120
 
89
121
  // Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
90
122
  if (isListWrapperModel(model)) continue;
91
123
 
124
+ // Skip models that are unchanged from baseline (no new fields),
125
+ // unless they're force-generated (inline dependency of a regenerated model).
126
+ if (!modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
127
+
92
128
  // Deduplication: if this model is structurally identical to a canonical model,
93
129
  // emit a type alias instead of a full interface.
94
130
  const canonicalName = dedup.get(model.name);
@@ -97,8 +133,8 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
97
133
  const dirName = resolveDir(service);
98
134
 
99
135
  // Skip typeAlias resolution for dedup models. The canonical file may
100
- // be preserved (skipIfExists) and still export its raw name, so the
101
- // import names must match the raw exports, not resolved aliases.
136
+ // still export its raw name, so the import names must match the raw
137
+ // exports, not resolved aliases.
102
138
  const skipTA = { skipTypeAlias: true };
103
139
  const domainName = resolveInterfaceName(model.name, ctx, skipTA);
104
140
  const responseName = wireInterfaceName(domainName);
@@ -128,7 +164,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
128
164
  files.push({
129
165
  path: aliasPath,
130
166
  content: aliasLines.join('\n'),
131
- skipIfExists: true,
167
+ overwriteExisting: true,
132
168
  });
133
169
  continue;
134
170
  }
@@ -211,10 +247,27 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
211
247
  if (irEnumName && !deps.enums.has(irEnumName)) {
212
248
  const eService = enumToService.get(irEnumName);
213
249
  const eDir = resolveDir(eService);
214
- const relPath =
215
- eDir === dirName
216
- ? `./${fileName(irEnumName)}.interface`
217
- : `../../${eDir}/interfaces/${fileName(irEnumName)}.interface`;
250
+ // Check baseline sourceFile — if the enum lives at a different path
251
+ // than the generated one, import from the baseline location.
252
+ const bEnum = ctx.apiSurface?.enums?.[irEnumName];
253
+ const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
254
+ const bSrc = (bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile;
255
+ const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
256
+ const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
257
+ // If defined inline in the same file, just add to importable names
258
+ if (bSrc === cPath) {
259
+ importableNames.add(name);
260
+ continue;
261
+ }
262
+ let relPath: string;
263
+ if (bSrc && bSrc !== gPath) {
264
+ relPath = relativeImport(cPath, bSrc).replace(/\.ts$/, '');
265
+ } else {
266
+ relPath =
267
+ eDir === dirName
268
+ ? `./${fileName(irEnumName)}.interface`
269
+ : `../../${eDir}/interfaces/${fileName(irEnumName)}.interface`;
270
+ }
218
271
  crossServiceImports.set(name, { name, relPath });
219
272
  importableNames.add(name);
220
273
  continue;
@@ -246,10 +299,32 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
246
299
  lines.push(`import type { ${depName}, ${wireInterfaceName(depName)} } from '${relPath}';`);
247
300
  }
248
301
  for (const dep of deps.enums) {
302
+ // Check if the enum has a baseline sourceFile — if it lives at a
303
+ // different path than the generated one, import from the baseline
304
+ // location since the enum file won't be regenerated there.
305
+ const baselineEnum = ctx.apiSurface?.enums?.[dep];
306
+ const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
307
+ const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
249
308
  const depService = enumToService.get(dep);
250
309
  const depDir = resolveDir(depService);
251
- const relPath =
252
- depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
310
+ const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
311
+ const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
312
+
313
+ // If the baseline enum is defined in the SAME file we're generating,
314
+ // skip the import — the merger will preserve the inline definition.
315
+ if (baselineSrc === currentFilePath) {
316
+ importableNames.add(dep);
317
+ continue;
318
+ }
319
+
320
+ let relPath: string;
321
+ if (baselineSrc && baselineSrc !== generatedPath) {
322
+ // Baseline provides the enum from a different file — import from there.
323
+ relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
324
+ } else {
325
+ relPath =
326
+ depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
327
+ }
253
328
  lines.push(`import type { ${dep} } from '${relPath}';`);
254
329
  }
255
330
  for (const [, imp] of crossServiceImports) {
@@ -303,6 +378,7 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
303
378
  if (
304
379
  baselineField &&
305
380
  !domainResponseOptionalMismatch &&
381
+ !hasDateTimeConversion(field.type) &&
306
382
  baselineTypeResolvable(baselineField.type, importableNames) &&
307
383
  baselineFieldCompatible(baselineField, field)
308
384
  ) {
@@ -366,10 +442,100 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
366
442
  lines.push('}');
367
443
  } // close else for non-empty wire interface
368
444
 
445
+ // When overwriting an existing interface file, preserve inline types whose
446
+ // sourceFile matches this file but which aren't generated as separate files.
447
+ // Query the apiSurface rather than parsing the target file with regex.
448
+ const filePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
449
+ if (ctx.apiSurface) {
450
+ const generatedNames = new Set<string>();
451
+ for (const line of lines) {
452
+ const m = line.match(/^export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/);
453
+ if (m) generatedNames.add(m[1]);
454
+ }
455
+
456
+ // Read the existing file to extract inline declarations verbatim
457
+ if (ctx.targetDir) {
458
+ try {
459
+ const existingContent = fs.readFileSync(path.join(ctx.targetDir, filePath), 'utf-8');
460
+ // Collect names of inline types from the apiSurface
461
+ const inlineNames = new Set<string>();
462
+ const checkSurface = (items: Record<string, any> | undefined) => {
463
+ if (!items) return;
464
+ for (const [name, item] of Object.entries(items)) {
465
+ const src = (item as any).sourceFile as string | undefined;
466
+ if (src !== filePath) continue;
467
+ if (generatedNames.has(name)) continue;
468
+ // Check that no separate file is generated for this type
469
+ const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
470
+ if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
471
+ inlineNames.add(name);
472
+ }
473
+ };
474
+ checkSurface(ctx.apiSurface.interfaces);
475
+ checkSurface(ctx.apiSurface.typeAliases);
476
+ checkSurface(ctx.apiSurface.enums);
477
+
478
+ // Extract each inline type's verbatim declaration from the existing file
479
+ if (inlineNames.size > 0) {
480
+ const existingLines = existingContent.split('\n');
481
+ let ei = 0;
482
+ while (ei < existingLines.length) {
483
+ const eline = existingLines[ei];
484
+ // Match exported or non-exported declarations
485
+ const dm = eline.match(/^(export\s+)?(?:interface|type|enum|class|const|function)\s+(\w+)/);
486
+ if (!dm || !inlineNames.has(dm[2])) {
487
+ ei++;
488
+ continue;
489
+ }
490
+
491
+ // Collect the full declaration via brace tracking
492
+ const block: string[] = [eline];
493
+ let braces = (eline.match(/\{/g) || []).length - (eline.match(/\}/g) || []).length;
494
+ if (braces === 0 && eline.includes(';')) {
495
+ lines.push('');
496
+ lines.push(block.join('\n'));
497
+ ei++;
498
+ continue;
499
+ }
500
+ // Multi-line type alias (union with |)
501
+ if (braces === 0) {
502
+ ei++;
503
+ while (ei < existingLines.length) {
504
+ const nl = existingLines[ei];
505
+ block.push(nl);
506
+ ei++;
507
+ if (
508
+ nl.trimEnd().endsWith(';') ||
509
+ (nl.trim() !== '' && !nl.trim().startsWith('|') && !nl.trim().startsWith('&'))
510
+ )
511
+ break;
512
+ }
513
+ lines.push('');
514
+ lines.push(block.join('\n'));
515
+ continue;
516
+ }
517
+ // Brace-delimited
518
+ ei++;
519
+ while (ei < existingLines.length && braces > 0) {
520
+ const nl = existingLines[ei];
521
+ block.push(nl);
522
+ braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
523
+ ei++;
524
+ }
525
+ lines.push('');
526
+ lines.push(block.join('\n'));
527
+ }
528
+ }
529
+ } catch {
530
+ // No existing file — nothing to preserve
531
+ }
532
+ }
533
+ }
534
+
369
535
  files.push({
370
- path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
536
+ path: filePath,
371
537
  content: pruneUnusedImports(lines).join('\n'),
372
- skipIfExists: true,
538
+ overwriteExisting: true,
373
539
  });
374
540
  }
375
541
 
@@ -477,18 +643,17 @@ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>):
477
643
  interface SharedModelContext {
478
644
  modelToService: Map<string, string>;
479
645
  resolveDir: (irService: string | undefined) => string;
480
- useStringDates: boolean;
481
646
  dedup: Map<string, string>;
482
647
  genericDefaults: Map<string, string>;
483
648
  }
484
649
 
485
650
  function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
486
651
  const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
487
- const useStringDates = detectStringDateConvention(models, ctx);
488
652
  const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
489
653
  enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
490
- const dedup = buildDeduplicationMap(models, ctx);
491
- return { modelToService, resolveDir, useStringDates, dedup, genericDefaults };
654
+ const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
655
+ const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
656
+ return { modelToService, resolveDir, dedup, genericDefaults };
492
657
  }
493
658
 
494
659
  // ---------------------------------------------------------------------------
@@ -507,14 +672,94 @@ export function generateSerializers(
507
672
  ): GeneratedFile[] {
508
673
  if (models.length === 0) return [];
509
674
 
510
- const { modelToService, resolveDir, useStringDates, dedup } = shared ?? buildSharedContext(models, ctx);
675
+ const { modelToService, resolveDir, dedup } = shared ?? buildSharedContext(models, ctx);
511
676
  const files: GeneratedFile[] = [];
512
677
  const skippedSerializeModels = new Set<string>();
513
678
 
679
+ // Reuse the same reachability set from generateModels to skip serializers
680
+ // for unreachable models (e.g., event/webhook payload types).
681
+ const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
682
+
683
+ // Pre-populate skippedSerializeModels for baseline models that won't be
684
+ // regenerated. Their existing serializers may only export deserialize.
685
+ if (ctx.targetDir) {
686
+ for (const model of models) {
687
+ if (!serializerReachable.has(model.name)) continue;
688
+ if (modelHasNewFields(model, ctx)) continue; // will be regenerated
689
+ const service = modelToService.get(model.name);
690
+ const dirName = resolveDir(service);
691
+ const domainName = resolveInterfaceName(model.name, ctx);
692
+ const serializerFile = path.join(
693
+ ctx.targetDir,
694
+ 'src',
695
+ dirName,
696
+ 'serializers',
697
+ `${fileName(model.name)}.serializer.ts`,
698
+ );
699
+ try {
700
+ const content = fs.readFileSync(serializerFile, 'utf-8');
701
+ if (!new RegExp(`\\bserialize${domainName}\\b`).test(content)) {
702
+ skippedSerializeModels.add(model.name);
703
+ }
704
+ } catch {
705
+ // Serializer doesn't exist — model may be new or generated differently
706
+ }
707
+ }
708
+ }
709
+
710
+ // Force-generate serializers for inline dependency models (same logic as interfaces).
711
+ const forceGenerateSerializer = new Set<string>();
712
+ for (const model of models) {
713
+ if (!serializerReachable.has(model.name)) continue;
714
+ if (!modelHasNewFields(model, ctx)) continue;
715
+ const service = modelToService.get(model.name);
716
+ const dirName = resolveDir(service);
717
+ const parentPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
718
+ const deps = collectFieldDependencies(model);
719
+ for (const dep of deps.models) {
720
+ const depName = resolveInterfaceName(dep, ctx);
721
+ const depBaseline = ctx.apiSurface?.interfaces?.[depName];
722
+ const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
723
+ if (depSrc === parentPath) {
724
+ forceGenerateSerializer.add(dep);
725
+ }
726
+ }
727
+ }
728
+
729
+ // --- Pass 1: pre-compute which models skip serialize (ordering-independent) ---
730
+ // This must run BEFORE file generation so that buildSerializerImports can
731
+ // check the fully-populated set and avoid importing non-existent serialize functions.
732
+ const eligibleModels: Model[] = [];
514
733
  for (const model of models) {
734
+ if (!serializerReachable.has(model.name)) continue;
515
735
  if (isListMetadataModel(model)) continue;
516
736
  if (isListWrapperModel(model)) continue;
737
+ if (!modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
738
+ eligibleModels.push(model);
739
+ }
517
740
 
741
+ // First pass: determine shouldSkipSerialize for every eligible model
742
+ for (const model of eligibleModels) {
743
+ if (dedup.has(model.name)) continue; // dedup aliases don't get their own serialize
744
+ const domainName = resolveInterfaceName(model.name, ctx);
745
+ const responseName = wireInterfaceName(domainName);
746
+ const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
747
+ const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
748
+ const shouldSkip = shouldSkipSerializeForModel(
749
+ model,
750
+ baselineResponse,
751
+ baselineDomain,
752
+ dedup,
753
+ skippedSerializeModels,
754
+ ctx,
755
+ );
756
+ if (shouldSkip) {
757
+ skippedSerializeModels.add(model.name);
758
+ }
759
+ }
760
+
761
+ // --- Pass 2: generate serializer files using fully-populated skip set ---
762
+ for (const model of eligibleModels) {
518
763
  // Deduplication: for structurally identical models, re-export the canonical serializer
519
764
  const canonicalName = dedup.get(model.name);
520
765
  if (canonicalName) {
@@ -536,9 +781,13 @@ export function generateSerializers(
536
781
  if (serializerPath === canonSerializerPath) continue;
537
782
  if (domainName === canonDomainName) continue;
538
783
  const rel = relativeImport(serializerPath, canonSerializerPath);
784
+ const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
785
+ const reexportContent = canonSkipSerialize
786
+ ? `export { deserialize${canonDomainName} as deserialize${domainName} } from '${rel}';`
787
+ : `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`;
539
788
  files.push({
540
789
  path: serializerPath,
541
- content: `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`,
790
+ content: reexportContent,
542
791
  overwriteExisting: true,
543
792
  });
544
793
  continue;
@@ -554,20 +803,10 @@ export function generateSerializers(
554
803
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
555
804
  const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
556
805
 
557
- const skipFormatFields = buildSkipFormatFields(model, useStringDates, baselineDomain);
558
- const shouldSkipSerialize = shouldSkipSerializeForModel(
559
- model,
560
- baselineResponse,
561
- baselineDomain,
562
- dedup,
563
- skippedSerializeModels,
564
- ctx,
565
- );
566
- if (shouldSkipSerialize) {
567
- skippedSerializeModels.add(model.name);
568
- }
806
+ const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
807
+ const shouldSkipSerialize = skippedSerializeModels.has(model.name);
569
808
 
570
- const sctx = { modelToService, resolveDir, useStringDates, dedup, skippedSerializeModels, ctx };
809
+ const sctx = { modelToService, resolveDir, dedup, skippedSerializeModels, ctx };
571
810
  const lines = [
572
811
  ...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
573
812
  ...emitSerializerBody(
@@ -586,9 +825,14 @@ export function generateSerializers(
586
825
  files.push({
587
826
  path: serializerPath,
588
827
  content: pruneUnusedImports(lines).join('\n'),
828
+ overwriteExisting: true,
589
829
  });
590
830
  }
591
831
 
832
+ // Stash the fully-computed skip set on the context so the test generator
833
+ // can read it without duplicating the detection logic.
834
+ (ctx as any)._skippedSerializeModels = skippedSerializeModels;
835
+
592
836
  return files;
593
837
  }
594
838