@workos/oagen-emitters 0.12.0 → 0.12.2

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 (53) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
  12. package/dist/plugin-eCuvoL1T.mjs.map +1 -0
  13. package/dist/plugin.d.mts.map +1 -1
  14. package/dist/plugin.mjs +1 -1
  15. package/package.json +10 -10
  16. package/renovate.json +46 -6
  17. package/src/node/client.ts +19 -32
  18. package/src/node/enums.ts +67 -30
  19. package/src/node/errors.ts +2 -8
  20. package/src/node/field-plan.ts +188 -52
  21. package/src/node/fixtures.ts +11 -33
  22. package/src/node/index.ts +345 -20
  23. package/src/node/live-surface.ts +378 -0
  24. package/src/node/models.ts +540 -351
  25. package/src/node/naming.ts +119 -25
  26. package/src/node/node-overrides.ts +77 -0
  27. package/src/node/options.ts +41 -0
  28. package/src/node/resources.ts +455 -46
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +108 -83
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/src/rust/fixtures.ts +87 -1
  35. package/src/rust/models.ts +17 -2
  36. package/src/rust/resources.ts +697 -62
  37. package/src/rust/tests.ts +540 -20
  38. package/test/node/client.test.ts +106 -1201
  39. package/test/node/enums.test.ts +59 -130
  40. package/test/node/errors.test.ts +2 -3
  41. package/test/node/live-surface.test.ts +240 -0
  42. package/test/node/models.test.ts +396 -765
  43. package/test/node/naming.test.ts +69 -234
  44. package/test/node/resources.test.ts +376 -2036
  45. package/test/node/tests.test.ts +119 -0
  46. package/test/node/type-map.test.ts +49 -54
  47. package/test/node/utils.test.ts +29 -80
  48. package/test/rust/fixtures.test.ts +227 -0
  49. package/test/rust/models.test.ts +38 -0
  50. package/test/rust/resources.test.ts +505 -2
  51. package/test/rust/tests.test.ts +504 -0
  52. package/dist/plugin-C408Wh-o.mjs.map +0 -1
  53. package/test/node/serializers.test.ts +0 -444
@@ -1,8 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
4
- import { mapTypeRef, mapWireTypeRef } from './type-map.js';
5
- import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
3
+ import type { Model, Field, TypeRef, EmitterContext, GeneratedFile, Operation, Service } from '@workos/oagen';
4
+ import { planOperation } from '@workos/oagen';
5
+ import { mapTypeRef, mapWireTypeRef, isInlineEnum } from './type-map.js';
6
+ import {
7
+ fieldName,
8
+ wireFieldName,
9
+ fileName,
10
+ resolveInterfaceName,
11
+ wireInterfaceName,
12
+ resolveMethodName,
13
+ } from './naming.js';
6
14
  import {
7
15
  collectFieldDependencies,
8
16
  docComment,
@@ -18,6 +26,8 @@ import {
18
26
  relativeImport,
19
27
  modelHasNewFields,
20
28
  computeNonEventReachable,
29
+ isServiceCoveredByExisting,
30
+ hasMethodsAbsentFromBaseline,
21
31
  } from './utils.js';
22
32
  import { assignEnumsToServices } from './enums.js';
23
33
  import {
@@ -28,18 +38,39 @@ import {
28
38
  emitSerializerBody,
29
39
  hasDateTimeConversion,
30
40
  } from './field-plan.js';
41
+ import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile } from './live-surface.js';
42
+ import { isNodeOwnedService } from './options.js';
43
+ import { unwrapListModel } from './fixtures.js';
44
+ import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
45
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
46
+ import { collectWrapperResponseModels } from './wrappers.js';
47
+ import { resolveResourceClassName } from './resources.js';
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Shared context
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface SharedModelContext {
54
+ modelToService: Map<string, string>;
55
+ resolveDir: (irService: string | undefined) => string;
56
+ dedup: Map<string, string>;
57
+ genericDefaults: Map<string, string>;
58
+ }
59
+
60
+ interface GeneratedResourceModelUsage {
61
+ interfaceRoots: Set<string>;
62
+ serializerRoots: Set<string>;
63
+ }
64
+
65
+ function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
66
+ const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
67
+ const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
68
+ enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
69
+ const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
70
+ const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
71
+ return { modelToService, resolveDir, dedup, genericDefaults };
72
+ }
31
73
 
32
- /**
33
- * Detect baseline interfaces that are generic (have type parameters) even though
34
- * the IR model has no typeParams (OpenAPI doesn't support generics).
35
- *
36
- * Heuristic: if any field type in the baseline interface contains a PascalCase
37
- * name that isn't a known model, enum, or builtin, it's likely a type parameter
38
- * (e.g., `CustomAttributesType`), indicating the interface is generic.
39
- *
40
- * When detected, adds a default generic type arg so references like `Profile`
41
- * become `Profile<Record<string, unknown>>`.
42
- */
43
74
  function enrichGenericDefaultsFromBaseline(
44
75
  genericDefaults: Map<string, string>,
45
76
  models: Model[],
@@ -51,14 +82,11 @@ function enrichGenericDefaultsFromBaseline(
51
82
  const knownNames = buildKnownTypeNames(models, ctx);
52
83
 
53
84
  for (const model of models) {
54
- if (genericDefaults.has(model.name)) continue; // IR already handles it
85
+ if (genericDefaults.has(model.name)) continue;
55
86
  const domainName = resolveInterfaceName(model.name, ctx);
56
87
  const baseline = ctx.apiSurface.interfaces[domainName];
57
88
  if (!baseline?.fields) continue;
58
89
 
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.
62
90
  const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
63
91
  const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
64
92
  if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
@@ -69,6 +97,53 @@ function enrichGenericDefaultsFromBaseline(
69
97
  }
70
98
  }
71
99
 
100
+ function projectModelToManagedSurface(model: Model, shared: SharedModelContext, ctx: EmitterContext): Model {
101
+ if (!ctx.outputDir && !ctx.targetDir) return model;
102
+ if (!liveSurfaceHasExistingSdk()) return model;
103
+ const fields = model.fields.filter((field) => isSupportedFieldType(field.type, model.name, shared, ctx));
104
+ return fields.length === model.fields.length ? model : { ...model, fields };
105
+ }
106
+
107
+ function isSupportedFieldType(
108
+ ref: TypeRef,
109
+ ownerModelName: string,
110
+ shared: SharedModelContext,
111
+ ctx: EmitterContext,
112
+ ): boolean {
113
+ switch (ref.kind) {
114
+ case 'primitive':
115
+ case 'literal':
116
+ case 'map':
117
+ return true;
118
+ case 'model': {
119
+ if (ref.name === ownerModelName) return true;
120
+ const resolvedName = resolveInterfaceName(ref.name, ctx);
121
+ if (ctx.apiSurface?.interfaces?.[resolvedName] || ctx.apiSurface?.typeAliases?.[resolvedName]) return true;
122
+ const relPath = `src/${shared.resolveDir(shared.modelToService.get(ref.name))}/interfaces/${fileName(ref.name)}.interface.ts`;
123
+ return liveSurfaceHasManagedFile(relPath);
124
+ }
125
+ case 'enum': {
126
+ if (ctx.apiSurface?.enums?.[ref.name] || ctx.apiSurface?.typeAliases?.[ref.name]) return true;
127
+ const enumService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx).get(ref.name);
128
+ if (enumService) return true;
129
+ const relPath = `src/${shared.resolveDir(enumService)}/interfaces/${fileName(ref.name)}.interface.ts`;
130
+ return liveSurfaceHasManagedFile(relPath);
131
+ }
132
+ case 'array':
133
+ return isSupportedFieldType(ref.items, ownerModelName, shared, ctx);
134
+ case 'nullable':
135
+ return isSupportedFieldType(ref.inner, ownerModelName, shared, ctx);
136
+ case 'union':
137
+ return ref.variants.every((variant) => isSupportedFieldType(variant, ownerModelName, shared, ctx));
138
+ default:
139
+ return true;
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Interface generation
145
+ // ---------------------------------------------------------------------------
146
+
72
147
  export function generateModels(models: Model[], ctx: EmitterContext, shared?: SharedModelContext): GeneratedFile[] {
73
148
  if (models.length === 0) return [];
74
149
 
@@ -83,18 +158,22 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
83
158
  const wireTypeRefOpts = { genericDefaults };
84
159
  const files: GeneratedFile[] = [];
85
160
  const dedup = sharedDedup;
161
+ const projectedModels = models.map((model) =>
162
+ projectModelToManagedSurface(model, { modelToService, resolveDir, dedup, genericDefaults }, ctx),
163
+ );
164
+ const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
165
+ const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
166
+ const interfaceEligibleModels = resourceUsage
167
+ ? expandModelRoots(resourceUsage.interfaceRoots, projectedByName)
168
+ : undefined;
86
169
 
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
170
  const reachableModels = computeNonEventReachable(ctx.spec.services, models);
91
171
 
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
172
  const forceGenerate = new Set<string>();
96
- for (const model of models) {
173
+ for (const originalModel of models) {
174
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
97
175
  if (!reachableModels.has(model.name)) continue;
176
+ if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
98
177
  if (!modelHasNewFields(model, ctx)) continue;
99
178
  const service = modelToService.get(model.name);
100
179
  const dirName = resolveDir(service);
@@ -106,35 +185,24 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
106
185
  const depBaseline = ctx.apiSurface?.interfaces?.[depName];
107
186
  const depSrc = (depBaseline as any)?.sourceFile as string | undefined;
108
187
  if (depSrc === parentPath) {
109
- // The dependency's baseline is inline in the parent's file — force-generate
110
188
  forceGenerate.add(dep);
111
189
  }
112
190
  }
113
191
  }
114
192
 
115
- for (const model of models) {
193
+ for (const originalModel of models) {
194
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
116
195
  if (!reachableModels.has(model.name)) continue;
117
-
118
- // Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
196
+ if (interfaceEligibleModels && !interfaceEligibleModels.has(model.name)) continue;
119
197
  if (isListMetadataModel(model)) continue;
120
-
121
- // Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
122
198
  if (isListWrapperModel(model)) continue;
199
+ const service = modelToService.get(model.name);
200
+ const isOwnedModel = isNodeOwnedService(ctx, service);
201
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerate.has(model.name)) continue;
123
202
 
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
-
128
- // Deduplication: if this model is structurally identical to a canonical model,
129
- // emit a type alias instead of a full interface.
130
203
  const canonicalName = dedup.get(model.name);
131
- if (canonicalName) {
132
- const service = modelToService.get(model.name);
204
+ if (canonicalName && !isOwnedModel) {
133
205
  const dirName = resolveDir(service);
134
-
135
- // Skip typeAlias resolution for dedup models. The canonical file may
136
- // still export its raw name, so the import names must match the raw
137
- // exports, not resolved aliases.
138
206
  const skipTA = { skipTypeAlias: true };
139
207
  const domainName = resolveInterfaceName(model.name, ctx, skipTA);
140
208
  const responseName = wireInterfaceName(domainName);
@@ -144,9 +212,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
144
212
  const canonService = modelToService.get(canonicalName);
145
213
  const canonDir = resolveDir(canonService);
146
214
 
147
- // After noise suffix stripping (e.g., "OrganizationDto" → "Organization"),
148
- // the alias and canonical may resolve to the same file path or the same
149
- // type names. Skip — the canonical file already provides the types.
150
215
  const aliasPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
151
216
  const canonPath = `src/${canonDir}/interfaces/${fileName(canonicalName)}.interface.ts`;
152
217
  if (aliasPath === canonPath) continue;
@@ -155,12 +220,37 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
155
220
  canonDir === dirName
156
221
  ? `./${fileName(canonicalName)}.interface`
157
222
  : `../../${canonDir}/interfaces/${fileName(canonicalName)}.interface`;
158
- const aliasLines = [
159
- `import type { ${canonDomainName}, ${canonResponseName} } from '${canonRelPath}';`,
160
- '',
161
- `export type ${domainName} = ${canonDomainName};`,
162
- `export type ${responseName} = ${canonResponseName};`,
163
- ];
223
+
224
+ // Single-form aliases: when the resolver collapses the IR model and
225
+ // its wire form to the same baseline name (or the alias's own
226
+ // domain/wire shapes coincide), emit only the unique exports. The
227
+ // earlier code unconditionally emitted both `export type X = Y;` and
228
+ // `export type X' = Y';` lines, producing TS2300 duplicate-identifier
229
+ // errors when X === X' and Y === Y'.
230
+ const aliasExports: string[] = [];
231
+ const importNeeded = new Set<string>();
232
+ const declared = new Set<string>();
233
+ const pushAlias = (lhs: string, rhs: string): void => {
234
+ if (lhs === rhs) return;
235
+ if (declared.has(lhs)) return;
236
+ declared.add(lhs);
237
+ aliasExports.push(`export type ${lhs} = ${rhs};`);
238
+ importNeeded.add(rhs);
239
+ };
240
+ pushAlias(domainName, canonDomainName);
241
+ pushAlias(responseName, canonResponseName);
242
+ if (aliasExports.length === 0) continue;
243
+
244
+ // Only import names that are referenced on the RHS of an alias AND
245
+ // aren't declared locally (which would shadow / collide with the
246
+ // import).
247
+ const importSymbols = [...importNeeded]
248
+ .filter((n) => !declared.has(n))
249
+ .sort()
250
+ .join(', ');
251
+ const aliasLines = importSymbols
252
+ ? [`import type { ${importSymbols} } from '${canonRelPath}';`, '', ...aliasExports]
253
+ : [...aliasExports];
164
254
  files.push({
165
255
  path: aliasPath,
166
256
  content: aliasLines.join('\n'),
@@ -169,19 +259,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
169
259
  continue;
170
260
  }
171
261
 
172
- const service = modelToService.get(model.name);
173
262
  const dirName = resolveDir(service);
174
- // If this model is a dedup canonical (other models alias to it), skip
175
- // typeAlias resolution so the file exports the raw name. Dedup aliases
176
- // import using the raw name to stay consistent with preserved files.
177
263
  const isDedupCanonical = [...dedup.values()].includes(model.name);
178
264
  const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
179
265
  const responseName = wireInterfaceName(domainName);
180
266
  const deps = collectFieldDependencies(model);
181
267
  const lines: string[] = [];
182
268
 
183
- // Exclude the current model from generic defaults to avoid self-referencing
184
- // (e.g., Profile's own fields should use TCustom, not Profile<Record<...>>)
185
269
  let modelTypeRefOpts = typeRefOpts;
186
270
  let modelWireTypeRefOpts = wireTypeRefOpts;
187
271
  if (genericDefaults.has(model.name)) {
@@ -191,12 +275,9 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
191
275
  modelWireTypeRefOpts = { genericDefaults: filteredDefaults };
192
276
  }
193
277
 
194
- // Baseline interface data (for compat field type matching)
195
278
  const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
196
279
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
197
280
 
198
- // Build set of importable type names for this file:
199
- // the model itself, its Response variant, all IR-dep model names + Response variants, and all IR-dep enum names
200
281
  const importableNames = new Set<string>();
201
282
  importableNames.add(domainName);
202
283
  importableNames.add(responseName);
@@ -209,16 +290,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
209
290
  importableNames.add(dep);
210
291
  }
211
292
 
212
- // Pre-pass: discover baseline type names that aren't directly importable.
213
- // For each unresolvable name we either:
214
- // 1. Import the real type from another service (if it exists as an enum/model there)
215
- // 2. Create a local type alias from a suffix match
216
- // 3. Mark as unresolvable — the field will fall back to the IR-generated type
217
- const typeDecls = new Map<string, string>(); // aliasName → type expression
218
- const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
219
- const unresolvableNames = new Set<string>(); // names that can't be resolved — forces IR fallback
220
- const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
221
- // Build a lookup: resolved enum name → IR enum name
293
+ const typeDecls = new Map<string, string>();
294
+ const crossServiceImports = new Map<string, { name: string; relPath: string }>();
295
+ const unresolvableNames = new Set<string>();
296
+ const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services, ctx.spec.models, ctx);
222
297
  const resolvedEnumNames = new Map<string, string>();
223
298
  for (const e of ctx.spec.enums) {
224
299
  resolvedEnumNames.set(resolveInterfaceName(e.name, ctx), e.name);
@@ -241,20 +316,15 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
241
316
  if (crossServiceImports.has(name)) continue;
242
317
  if (unresolvableNames.has(name)) continue;
243
318
 
244
- // Check if this name exists as an enum in another service —
245
- // import the actual type so the extractor sees the real name
246
319
  const irEnumName = resolvedEnumNames.get(name);
247
320
  if (irEnumName && !deps.enums.has(irEnumName)) {
248
321
  const eService = enumToService.get(irEnumName);
249
322
  const eDir = resolveDir(eService);
250
- // Check baseline sourceFile — if the enum lives at a different path
251
- // than the generated one, import from the baseline location.
252
323
  const bEnum = ctx.apiSurface?.enums?.[irEnumName];
253
324
  const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
254
325
  const bSrc = (bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile;
255
326
  const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
256
327
  const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
257
- // If defined inline in the same file, just add to importable names
258
328
  if (bSrc === cPath) {
259
329
  importableNames.add(name);
260
330
  continue;
@@ -273,35 +343,56 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
273
343
  continue;
274
344
  }
275
345
 
276
- // Try suffix match: find an importable name ending with this name
277
346
  const candidates = [...importableNames].filter((n) => n.endsWith(name) && n !== name);
278
347
  if (candidates.length === 1) {
279
- // Create local type alias (e.g., type RoleResponse = ProfileRoleResponse)
280
348
  typeDecls.set(name, candidates[0]);
281
349
  importableNames.add(name);
282
350
  } else {
283
- // Cannot resolve this baseline type name — mark it so the field
284
- // falls back to the IR-generated type instead of the baseline.
285
- // This avoids creating type aliases that reference undefined types.
286
351
  unresolvableNames.add(name);
287
352
  }
288
353
  }
289
354
  }
290
355
  }
291
356
 
292
- // Import referenced models (domain + response) and enums with correct cross-service paths
293
357
  for (const dep of deps.models) {
294
358
  const depName = resolveInterfaceName(dep, ctx);
295
359
  const depService = modelToService.get(dep);
296
360
  const depDir = resolveDir(depService);
297
- const relPath =
298
- depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
299
- lines.push(`import type { ${depName}, ${wireInterfaceName(depName)} } from '${relPath}';`);
361
+
362
+ // When the resolver maps the IR name to a different baseline interface
363
+ // (via `overlayLookup.modelNameByIR` structural match), the import
364
+ // path must follow the baseline's `sourceFile`. Otherwise we'd point
365
+ // at the IR-named file (e.g. `audit-log-event.interface`) that the
366
+ // emitter never generates — the canonical baseline file is at a
367
+ // different stem (e.g. `create-audit-log-event-options.interface`).
368
+ const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
369
+ const baselineSrc = (ctx.apiSurface?.interfaces?.[depName] as { sourceFile?: string } | undefined)?.sourceFile;
370
+
371
+ // Self-reference: the dependency lives in the file we're currently
372
+ // emitting. Skip the import — it's already in scope.
373
+ if (baselineSrc === currentFilePath) continue;
374
+
375
+ let relPath: string;
376
+ if (baselineSrc) {
377
+ relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
378
+ } else {
379
+ relPath =
380
+ depDir === dirName ? `./${fileName(dep)}.interface` : `../../${depDir}/interfaces/${fileName(dep)}.interface`;
381
+ }
382
+
383
+ // `wireInterfaceName` consults the baseline interface set so it
384
+ // returns the bare `depName` when the resolver mapped to a
385
+ // single-form interface (no separate `*Wire`). That keeps the
386
+ // import statement requesting only what the baseline file exports.
387
+ const wireName = wireInterfaceName(depName);
388
+ const importNames = wireName === depName ? depName : `${depName}, ${wireName}`;
389
+ lines.push(`import type { ${importNames} } from '${relPath}';`);
300
390
  }
301
391
  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.
392
+ // Inlined enums are emitted as literal unions at the usage site
393
+ // (handled by type-map). Skip the import the file does not exist.
394
+ if (isInlineEnum(dep)) continue;
395
+
305
396
  const baselineEnum = ctx.apiSurface?.enums?.[dep];
306
397
  const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
307
398
  const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
@@ -310,8 +401,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
310
401
  const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
311
402
  const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
312
403
 
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
404
  if (baselineSrc === currentFilePath) {
316
405
  importableNames.add(dep);
317
406
  continue;
@@ -319,7 +408,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
319
408
 
320
409
  let relPath: string;
321
410
  if (baselineSrc && baselineSrc !== generatedPath) {
322
- // Baseline provides the enum from a different file — import from there.
323
411
  relPath = relativeImport(currentFilePath, baselineSrc).replace(/\.ts$/, '');
324
412
  } else {
325
413
  relPath =
@@ -333,17 +421,16 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
333
421
 
334
422
  if (lines.length > 0) lines.push('');
335
423
 
336
- // Add local type declarations for unresolvable baseline type names
337
- for (const [alias, typeExpr] of typeDecls) {
338
- lines.push(`type ${alias} = ${typeExpr};`);
339
- }
340
- if (typeDecls.size > 0) lines.push('');
341
-
342
- // Type params (generics) — pass genericDefaults so baseline-detected generics
343
- // also get type parameter declarations on the interface itself.
424
+ // Type-alias declarations are pre-collected from baseline field types.
425
+ // The IR-driven body may end up not using them (the body uses the
426
+ // resolved interface names, while aliases serve only as bridges from
427
+ // baseline-name references inside `baselineField.type`). Defer their
428
+ // emission, then filter to only those names actually referenced in
429
+ // the body or wire interface lines.
430
+ const typeDeclInsertIdx = lines.length;
344
431
  const typeParams = renderTypeParams(model, genericDefaults);
345
432
 
346
- // Domain interface (camelCase fields) — deduplicate by camelCase name
433
+ // Domain interface
347
434
  const seenDomainFields = new Set<string>();
348
435
  if (model.description) {
349
436
  lines.push(...docComment(model.description));
@@ -366,10 +453,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
366
453
  lines.push(...docComment(parts.join('\n'), 2));
367
454
  }
368
455
  const baselineField = baselineDomain?.fields?.[domainFieldName];
369
- // For the domain interface, also check that the response baseline's optionality
370
- // is compatible — the serializer reads from the response type and assigns to the domain type.
371
- // If the domain baseline says required but the response baseline says optional,
372
- // the serializer would produce T | undefined for a field expecting T.
373
456
  const domainWireField = wireFieldName(field.name);
374
457
  const responseBaselineField = baselineResponse?.fields?.[domainWireField];
375
458
  const domainResponseOptionalMismatch =
@@ -385,23 +468,17 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
385
468
  const opt = baselineField.optional ? '?' : '';
386
469
  lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
387
470
  } else {
388
- // When a baseline exists for this model, new fields (not present in the
389
- // baseline) are generated as optional. The merger can deep-merge new
390
- // fields into existing interfaces, but it cannot update existing
391
- // deserializer function bodies. Making the field optional prevents a
392
- // type error where the interface requires a field that the preserved
393
- // deserializer never populates.
394
471
  const isNewFieldOnExistingModel = baselineDomain && !baselineField;
395
- // Also make the field optional when the response baseline has it as optional
396
- // but the domain baseline has it as required — the deserializer reads from
397
- // the response type, so if the response field is optional, the domain value
398
- // may be undefined.
399
- // Additionally, when a baseline exists for the RESPONSE interface but NOT the
400
- // domain interface, fields that are new on the response baseline become optional
401
- // in the wire type. The domain type must also be optional to match, otherwise
402
- // the deserializer produces T | undefined for a field typed as T.
403
472
  const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
473
+ // Preserve baseline-declared optionality even when we're emitting
474
+ // the IR-derived type (e.g. baseline `Date` for an IR string with
475
+ // `format: date-time`). Without this, regenerating an existing
476
+ // interface flips `external_id?: string` into `external_id: string
477
+ // | null`, which silently breaks every hand-written test fixture
478
+ // missing that field.
479
+ const baselineSaysOptional = baselineField?.optional === true;
404
480
  const opt =
481
+ baselineSaysOptional ||
405
482
  !field.required ||
406
483
  isNewFieldOnExistingModel ||
407
484
  domainResponseOptionalMismatch ||
@@ -412,10 +489,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
412
489
  }
413
490
  }
414
491
  lines.push('}');
415
- } // close else for non-empty domain interface
492
+ }
416
493
  lines.push('');
417
494
 
418
- // Wire/response interface (snake_case fields) — deduplicate by snake_case name
495
+ // Wire/response interface
419
496
  const seenWireFields = new Set<string>();
420
497
  if (model.fields.length === 0) {
421
498
  lines.push(`export type ${responseName}${typeParams} = object;`);
@@ -435,100 +512,107 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
435
512
  lines.push(` ${wireField}${opt}: ${baselineField.type};`);
436
513
  } else {
437
514
  const isNewFieldOnExistingModel = baselineResponse && !baselineField;
438
- const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
515
+ // Same baseline-optional preservation as the domain side. The
516
+ // wire interface's optional flag drives test-fixture shape, so
517
+ // flipping it on regen breaks every fixture that omitted the
518
+ // field assuming it was optional.
519
+ const baselineSaysOptional = baselineField?.optional === true;
520
+ const opt = baselineSaysOptional || !field.required || isNewFieldOnExistingModel ? '?' : '';
439
521
  lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
440
522
  }
441
523
  }
442
524
  lines.push('}');
443
- } // close else for non-empty wire interface
525
+ }
444
526
 
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.
527
+ // Preserve inline types from existing file
448
528
  const filePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
449
- if (ctx.apiSurface) {
529
+ if (ctx.apiSurface && ctx.targetDir) {
450
530
  const generatedNames = new Set<string>();
451
531
  for (const line of lines) {
452
532
  const m = line.match(/^export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/);
453
533
  if (m) generatedNames.add(m[1]);
454
534
  }
455
535
 
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);
536
+ try {
537
+ const existingContent = fs.readFileSync(path.join(ctx.targetDir, filePath), 'utf-8');
538
+ const inlineNames = new Set<string>();
539
+ const checkSurface = (items: Record<string, any> | undefined) => {
540
+ if (!items) return;
541
+ for (const [name, item] of Object.entries(items)) {
542
+ const src = (item as any).sourceFile as string | undefined;
543
+ if (src !== filePath) continue;
544
+ if (generatedNames.has(name)) continue;
545
+ const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
546
+ if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
547
+ inlineNames.add(name);
548
+ }
549
+ };
550
+ checkSurface(ctx.apiSurface.interfaces);
551
+ checkSurface(ctx.apiSurface.typeAliases);
552
+ checkSurface(ctx.apiSurface.enums);
553
+
554
+ if (inlineNames.size > 0) {
555
+ const existingLines = existingContent.split('\n');
556
+ let ei = 0;
557
+ while (ei < existingLines.length) {
558
+ const eline = existingLines[ei];
559
+ const dm = eline.match(/^(export\s+)?(?:interface|type|enum|class|const|function)\s+(\w+)/);
560
+ if (!dm || !inlineNames.has(dm[2])) {
561
+ ei++;
562
+ continue;
472
563
  }
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
564
 
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
565
+ const block: string[] = [eline];
566
+ let braces = (eline.match(/\{/g) || []).length - (eline.match(/\}/g) || []).length;
567
+ if (braces === 0 && eline.includes(';')) {
568
+ lines.push('');
569
+ lines.push(block.join('\n'));
518
570
  ei++;
519
- while (ei < existingLines.length && braces > 0) {
571
+ continue;
572
+ }
573
+ if (braces === 0) {
574
+ ei++;
575
+ while (ei < existingLines.length) {
520
576
  const nl = existingLines[ei];
521
577
  block.push(nl);
522
- braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
523
578
  ei++;
579
+ if (
580
+ nl.trimEnd().endsWith(';') ||
581
+ (nl.trim() !== '' && !nl.trim().startsWith('|') && !nl.trim().startsWith('&'))
582
+ )
583
+ break;
524
584
  }
525
585
  lines.push('');
526
586
  lines.push(block.join('\n'));
587
+ continue;
527
588
  }
589
+ ei++;
590
+ while (ei < existingLines.length && braces > 0) {
591
+ const nl = existingLines[ei];
592
+ block.push(nl);
593
+ braces += (nl.match(/\{/g) || []).length - (nl.match(/\}/g) || []).length;
594
+ ei++;
595
+ }
596
+ lines.push('');
597
+ lines.push(block.join('\n'));
528
598
  }
529
- } catch {
530
- // No existing file — nothing to preserve
531
599
  }
600
+ } catch {
601
+ // No existing file
602
+ }
603
+ }
604
+
605
+ // Splice in only the type aliases referenced by the body or wire lines.
606
+ if (typeDecls.size > 0) {
607
+ const bodyText = lines.slice(typeDeclInsertIdx).join('\n');
608
+ const usedDecls: string[] = [];
609
+ for (const [alias, typeExpr] of typeDecls) {
610
+ if (new RegExp(`\\b${alias}\\b`).test(bodyText)) {
611
+ usedDecls.push(`type ${alias} = ${typeExpr};`);
612
+ }
613
+ }
614
+ if (usedDecls.length > 0) {
615
+ lines.splice(typeDeclInsertIdx, 0, ...usedDecls, '');
532
616
  }
533
617
  }
534
618
 
@@ -542,129 +626,10 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
542
626
  return files;
543
627
  }
544
628
 
545
- /**
546
- * Check if all PascalCase type references in a baseline type string
547
- * can be resolved to types that are actually importable in the generated file.
548
- * A type is importable if it's a builtin, or if it's among the set of names
549
- * that will be imported (the model's own name/response, or its IR deps).
550
- * Returns false if any reference is unresolvable (e.g., hand-written types
551
- * from the live SDK, or spec types from other services not in IR deps).
552
- */
553
- function baselineTypeResolvable(typeStr: string, importableNames: Set<string>): boolean {
554
- const matches = typeStr.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
555
- if (!matches) return true;
556
-
557
- for (const name of matches) {
558
- if (TS_BUILTINS.has(name)) continue;
559
- if (importableNames.has(name)) continue;
560
- return false;
561
- }
562
- return true;
563
- }
564
-
565
- /**
566
- * Check if a baseline field type is compatible with the IR field for use
567
- * in the generated interface. The serializer generates expressions based on
568
- * the IR type, so the interface type must be assignable from the serializer output.
569
- *
570
- * Rejects baseline types when:
571
- * - IR field is nullable but baseline type doesn't include `null`
572
- * - IR field is optional but baseline says required (and vice versa)
573
- * - IR field is required but baseline says optional
574
- */
575
- function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
576
- const irNullable = irField.type.kind === 'nullable';
577
- const baselineHasNull = baselineField.type.includes('null');
578
-
579
- // If the IR field is nullable, the serializer produces `expr ?? null`,
580
- // so the baseline type must include null to be assignable.
581
- // Exception: for optional fields, the serializer's null guard converts
582
- // null to undefined (`wireAccess != null ? expr : undefined`), so the
583
- // result type is `T | undefined` which is compatible with `field?: T`.
584
- if (irNullable && !baselineHasNull && irField.required) {
585
- return false;
586
- }
587
-
588
- // If the IR field is optional, the serializer may produce undefined,
589
- // so the baseline should also be optional (or include undefined)
590
- if (!irField.required && !baselineField.optional && !baselineField.type.includes('undefined')) {
591
- return false;
592
- }
593
-
594
- // If the IR field is required but the baseline says optional,
595
- // the serializer produces a definite value but the interface is looser — that's OK
596
- // (the domain type is wider than the serializer output)
597
-
598
- // If the baseline type is Record<string, unknown> but the IR field has a more specific
599
- // type (model, enum, or union with named variants), prefer the IR type for better type safety
600
- if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
601
- return false;
602
- }
603
-
604
- return true;
605
- }
606
-
607
- /** Check if an IR type is more specific than Record<string, unknown>. */
608
- function hasSpecificIRType(ref: TypeRef): boolean {
609
- switch (ref.kind) {
610
- case 'model':
611
- case 'enum':
612
- return true;
613
- case 'union':
614
- // A union with named model/enum variants is more specific
615
- return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
616
- case 'nullable':
617
- return hasSpecificIRType(ref.inner);
618
- default:
619
- return false;
620
- }
621
- }
622
-
623
- function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
624
- if (!model.typeParams?.length) {
625
- // Fallback: if genericDefaults indicates this model is generic (detected
626
- // from the baseline), generate a default generic type parameter declaration.
627
- if (genericDefaults?.has(model.name)) {
628
- return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
629
- }
630
- return '';
631
- }
632
- const params = model.typeParams.map((tp) => {
633
- const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
634
- return `${tp.name}${def}`;
635
- });
636
- return `<${params.join(', ')}>`;
637
- }
638
-
639
- // ---------------------------------------------------------------------------
640
- // Shared context — computed once and reused by interface + serializer passes
641
- // ---------------------------------------------------------------------------
642
-
643
- interface SharedModelContext {
644
- modelToService: Map<string, string>;
645
- resolveDir: (irService: string | undefined) => string;
646
- dedup: Map<string, string>;
647
- genericDefaults: Map<string, string>;
648
- }
649
-
650
- function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
651
- const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
652
- const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
653
- enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
654
- const nonEventReachable = computeNonEventReachable(ctx.spec.services, models);
655
- const dedup = buildDeduplicationMap(models, ctx, nonEventReachable);
656
- return { modelToService, resolveDir, dedup, genericDefaults };
657
- }
658
-
659
629
  // ---------------------------------------------------------------------------
660
- // Serializer file generation (moved from serializers.ts)
630
+ // Serializer generation
661
631
  // ---------------------------------------------------------------------------
662
632
 
663
- /**
664
- * Generate serializer files for all models.
665
- * Can accept pre-computed shared context to avoid duplicating work
666
- * when called alongside generateModels.
667
- */
668
633
  export function generateSerializers(
669
634
  models: Model[],
670
635
  ctx: EmitterContext,
@@ -675,42 +640,55 @@ export function generateSerializers(
675
640
  const { modelToService, resolveDir, dedup } = shared ?? buildSharedContext(models, ctx);
676
641
  const files: GeneratedFile[] = [];
677
642
  const skippedSerializeModels = new Set<string>();
643
+ const projectedModels = models.map((model) =>
644
+ projectModelToManagedSurface(model, { modelToService, resolveDir, dedup, genericDefaults: new Map() }, ctx),
645
+ );
646
+ const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
647
+ const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
648
+ const serializerEligibleModels = resourceUsage
649
+ ? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
650
+ : undefined;
678
651
 
679
- // Reuse the same reachability set from generateModels to skip serializers
680
- // for unreachable models (e.g., event/webhook payload types).
681
652
  const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
682
653
 
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) {
654
+ // Detect models whose serializer file already exists in the live SDK but
655
+ // does not export a `serialize<Domain>` function. Generated serializers
656
+ // that reference such a model must skip their `serialize` half — calling
657
+ // a missing function would leave the SDK unable to compile.
658
+ const liveRoot = ctx.targetDir ?? ctx.outputDir;
659
+ if (liveRoot) {
660
+ for (const originalModel of models) {
661
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
687
662
  if (!serializerReachable.has(model.name)) continue;
688
- if (modelHasNewFields(model, ctx)) continue; // will be regenerated
663
+ if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
689
664
  const service = modelToService.get(model.name);
690
665
  const dirName = resolveDir(service);
691
666
  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
- );
667
+ const baselineSource = (ctx.apiSurface?.interfaces?.[domainName] as { sourceFile?: string } | undefined)
668
+ ?.sourceFile;
669
+ const serializerRelPath = baselineSource
670
+ ? baselineSource.replace('/interfaces/', '/serializers/').replace('.interface.ts', '.serializer.ts')
671
+ : `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
672
+ const serializerFile = path.join(liveRoot, serializerRelPath);
699
673
  try {
700
674
  const content = fs.readFileSync(serializerFile, 'utf-8');
701
- if (!new RegExp(`\\bserialize${domainName}\\b`).test(content)) {
675
+ const isGeneratedFile =
676
+ ctx.priorTargetManifestPaths?.has(serializerRelPath) ||
677
+ /auto-generated by oagen/i.test(content.slice(0, 400));
678
+ if (!isGeneratedFile && !new RegExp(`\\bserialize${domainName}\\b`).test(content)) {
702
679
  skippedSerializeModels.add(model.name);
703
680
  }
704
681
  } catch {
705
- // Serializer doesn't exist model may be new or generated differently
682
+ // Serializer doesn't exist on disk yet fine, we'll generate one.
706
683
  }
707
684
  }
708
685
  }
709
686
 
710
- // Force-generate serializers for inline dependency models (same logic as interfaces).
711
687
  const forceGenerateSerializer = new Set<string>();
712
- for (const model of models) {
688
+ for (const originalModel of models) {
689
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
713
690
  if (!serializerReachable.has(model.name)) continue;
691
+ if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
714
692
  if (!modelHasNewFields(model, ctx)) continue;
715
693
  const service = modelToService.get(model.name);
716
694
  const dirName = resolveDir(service);
@@ -726,21 +704,23 @@ export function generateSerializers(
726
704
  }
727
705
  }
728
706
 
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
707
  const eligibleModels: Model[] = [];
733
- for (const model of models) {
708
+ for (const originalModel of models) {
709
+ const model = projectedByName.get(originalModel.name) ?? originalModel;
734
710
  if (!serializerReachable.has(model.name)) continue;
711
+ if (serializerEligibleModels && !serializerEligibleModels.has(model.name)) continue;
735
712
  if (isListMetadataModel(model)) continue;
736
713
  if (isListWrapperModel(model)) continue;
737
- if (!modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
714
+ const service = modelToService.get(model.name);
715
+ const isOwnedModel = isNodeOwnedService(ctx, service);
716
+ if (!isOwnedModel && !modelHasNewFields(model, ctx) && !forceGenerateSerializer.has(model.name)) continue;
738
717
  eligibleModels.push(model);
739
718
  }
719
+ (ctx as any)._generatedSerializerModels = new Set(eligibleModels.map((model) => model.name));
740
720
 
741
- // First pass: determine shouldSkipSerialize for every eligible model
721
+ // Pass 1: determine shouldSkipSerialize
742
722
  for (const model of eligibleModels) {
743
- if (dedup.has(model.name)) continue; // dedup aliases don't get their own serialize
723
+ if (dedup.has(model.name)) continue;
744
724
  const domainName = resolveInterfaceName(model.name, ctx);
745
725
  const responseName = wireInterfaceName(domainName);
746
726
  const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
@@ -758,14 +738,13 @@ export function generateSerializers(
758
738
  }
759
739
  }
760
740
 
761
- // --- Pass 2: generate serializer files using fully-populated skip set ---
741
+ // Pass 2: generate serializer files
762
742
  for (const model of eligibleModels) {
763
- // Deduplication: for structurally identical models, re-export the canonical serializer
743
+ const service = modelToService.get(model.name);
744
+ const isOwnedModel = isNodeOwnedService(ctx, service);
764
745
  const canonicalName = dedup.get(model.name);
765
- if (canonicalName) {
766
- const service = modelToService.get(model.name);
746
+ if (canonicalName && !isOwnedModel) {
767
747
  const dirName = resolveDir(service);
768
- // Skip typeAlias resolution for dedup serializers (same reason as interfaces).
769
748
  const skipTA = { skipTypeAlias: true };
770
749
  const domainName = resolveInterfaceName(model.name, ctx, skipTA);
771
750
  const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
@@ -775,9 +754,6 @@ export function generateSerializers(
775
754
  const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
776
755
  const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
777
756
 
778
- // After noise suffix stripping, alias and canonical may resolve to the
779
- // same serializer path or the same function names. Skip — the canonical
780
- // serializer already provides the functions.
781
757
  if (serializerPath === canonSerializerPath) continue;
782
758
  if (domainName === canonDomainName) continue;
783
759
  const rel = relativeImport(serializerPath, canonSerializerPath);
@@ -793,7 +769,6 @@ export function generateSerializers(
793
769
  continue;
794
770
  }
795
771
 
796
- const service = modelToService.get(model.name);
797
772
  const dirName = resolveDir(service);
798
773
  const isDedupCanonical = [...dedup.values()].includes(model.name);
799
774
  const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
@@ -829,23 +804,237 @@ export function generateSerializers(
829
804
  });
830
805
  }
831
806
 
832
- // Stash the fully-computed skip set on the context so the test generator
833
- // can read it without duplicating the detection logic.
834
807
  (ctx as any)._skippedSerializeModels = skippedSerializeModels;
835
808
 
836
809
  return files;
837
810
  }
838
811
 
839
812
  // ---------------------------------------------------------------------------
840
- // Combined generation — single shared context, two output streams
813
+ // Combined generation
841
814
  // ---------------------------------------------------------------------------
842
815
 
843
- /**
844
- * Generate both interface files and serializer files in a single pass
845
- * with shared context computation.
846
- */
847
816
  export function generateModelsAndSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
848
817
  if (models.length === 0) return [];
849
818
  const shared = buildSharedContext(models, ctx);
850
819
  return [...generateModels(models, ctx, shared), ...generateSerializers(models, ctx, shared)];
851
820
  }
821
+
822
+ export function generatedResourceInterfaceModelNames(models: Model[], ctx: EmitterContext): Set<string> | undefined {
823
+ const shared = buildSharedContext(models, ctx);
824
+ const projectedModels = models.map((model) => projectModelToManagedSurface(model, shared, ctx));
825
+ const projectedByName = new Map(projectedModels.map((model) => [model.name, model]));
826
+ const resourceUsage = buildGeneratedResourceModelUsage(models, ctx);
827
+ return resourceUsage ? expandModelRoots(resourceUsage.interfaceRoots, projectedByName) : undefined;
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------
831
+ // Helpers
832
+ // ---------------------------------------------------------------------------
833
+
834
+ function buildGeneratedResourceModelUsage(
835
+ models: Model[],
836
+ ctx: EmitterContext,
837
+ ): GeneratedResourceModelUsage | undefined {
838
+ if (ctx.spec.services.length === 0) return undefined;
839
+
840
+ const modelMap = new Map(models.map((model) => [model.name, model]));
841
+ const interfaceRoots = new Set<string>();
842
+ const serializerRoots = new Set<string>();
843
+ const resolvedLookup = buildResolvedLookup(ctx);
844
+ const mountGroups = groupByMount(ctx);
845
+ const services: Service[] =
846
+ mountGroups.size > 0
847
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
848
+ : ctx.spec.services;
849
+
850
+ for (const service of services) {
851
+ const resourceClass = resolveResourceClassName(service, ctx);
852
+ const isOwnedService = isNodeOwnedService(ctx, service.name, resourceClass);
853
+ const baselineHasResourceClass = Boolean(ctx.apiSurface?.classes?.[resourceClass]);
854
+
855
+ if (
856
+ !isOwnedService &&
857
+ baselineHasResourceClass &&
858
+ isServiceCoveredByExisting(service, ctx) &&
859
+ !hasMethodsAbsentFromBaseline(service, ctx)
860
+ ) {
861
+ continue;
862
+ }
863
+
864
+ let plans = service.operations.map((op) => ({
865
+ op,
866
+ plan: planOperation(op),
867
+ method: resolveMethodName(op, service, ctx),
868
+ }));
869
+
870
+ const baselineMethodNames = new Set(Object.keys(ctx.apiSurface?.classes?.[resourceClass]?.methods ?? {}));
871
+ if (!isOwnedService && baselineMethodNames.size > 0) {
872
+ plans = plans.filter((p) => !baselineMethodNames.has(p.method));
873
+ }
874
+ if (plans.length === 0) continue;
875
+
876
+ for (const { op, plan } of plans) {
877
+ if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
878
+ let itemName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : undefined;
879
+ if (itemName) {
880
+ const itemModel = modelMap.get(itemName);
881
+ const unwrapped = itemModel ? unwrapListModel(itemModel, modelMap) : null;
882
+ if (unwrapped) itemName = unwrapped.name;
883
+ interfaceRoots.add(itemName);
884
+ serializerRoots.add(itemName);
885
+ }
886
+ } else if (plan.responseModelName) {
887
+ interfaceRoots.add(plan.responseModelName);
888
+ serializerRoots.add(plan.responseModelName);
889
+ }
890
+
891
+ const bodyInfo = extractRequestBodyModels(op, ctx);
892
+ for (const name of bodyInfo) {
893
+ interfaceRoots.add(name);
894
+ serializerRoots.add(name);
895
+ }
896
+
897
+ for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
898
+ collectTypeRefModels(param.type, interfaceRoots);
899
+ }
900
+
901
+ const resolved = lookupResolved(op, resolvedLookup);
902
+ if (resolved) {
903
+ for (const name of collectWrapperResponseModels(resolved)) {
904
+ interfaceRoots.add(name);
905
+ serializerRoots.add(name);
906
+ }
907
+ for (const wrapper of resolved.wrappers ?? []) {
908
+ for (const { field } of resolveWrapperParams(wrapper, ctx)) {
909
+ if (field) collectTypeRefModels(field.type, interfaceRoots);
910
+ }
911
+ }
912
+ }
913
+ }
914
+ }
915
+
916
+ return { interfaceRoots, serializerRoots };
917
+ }
918
+
919
+ function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
920
+ if (!op.requestBody) return [];
921
+ if (op.requestBody.kind === 'model') return [op.requestBody.name];
922
+ if (op.requestBody.kind !== 'union') return [];
923
+
924
+ const names: string[] = [];
925
+ for (const variant of op.requestBody.variants) {
926
+ if (variant.kind === 'model') names.push(variant.name);
927
+ }
928
+
929
+ return names.length > 0 ? names : collectDiscriminatorModelNames(op.requestBody.discriminator, ctx);
930
+ }
931
+
932
+ function collectDiscriminatorModelNames(
933
+ discriminator: { mapping?: Record<string, string> } | undefined,
934
+ ctx: EmitterContext,
935
+ ): string[] {
936
+ const names = new Set<string>();
937
+ for (const mapped of Object.values(discriminator?.mapping ?? {})) {
938
+ const name = mapped.split('/').pop();
939
+ if (name && ctx.spec.models.some((model) => model.name === name)) names.add(name);
940
+ }
941
+ return [...names];
942
+ }
943
+
944
+ function collectTypeRefModels(ref: TypeRef | undefined, out: Set<string>): void {
945
+ if (!ref) return;
946
+ switch (ref.kind) {
947
+ case 'model':
948
+ out.add(ref.name);
949
+ return;
950
+ case 'array':
951
+ collectTypeRefModels(ref.items, out);
952
+ return;
953
+ case 'nullable':
954
+ collectTypeRefModels(ref.inner, out);
955
+ return;
956
+ case 'union':
957
+ for (const variant of ref.variants) collectTypeRefModels(variant, out);
958
+ return;
959
+ default:
960
+ return;
961
+ }
962
+ }
963
+
964
+ function expandModelRoots(roots: Set<string>, modelsByName: Map<string, Model>): Set<string> {
965
+ const out = new Set<string>();
966
+ const queue = [...roots];
967
+
968
+ while (queue.length > 0) {
969
+ const name = queue.pop()!;
970
+ if (out.has(name)) continue;
971
+ const model = modelsByName.get(name);
972
+ if (!model) continue;
973
+ out.add(name);
974
+
975
+ for (const dep of collectFieldDependencies(model).models) {
976
+ if (!out.has(dep)) queue.push(dep);
977
+ }
978
+ }
979
+
980
+ return out;
981
+ }
982
+
983
+ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>): boolean {
984
+ const matches = typeStr.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
985
+ if (!matches) return true;
986
+
987
+ for (const name of matches) {
988
+ if (TS_BUILTINS.has(name)) continue;
989
+ if (importableNames.has(name)) continue;
990
+ return false;
991
+ }
992
+ return true;
993
+ }
994
+
995
+ function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
996
+ const irNullable = irField.type.kind === 'nullable';
997
+ const baselineHasNull = baselineField.type.includes('null');
998
+
999
+ if (irNullable && !baselineHasNull && irField.required) {
1000
+ return false;
1001
+ }
1002
+
1003
+ if (!irField.required && !baselineField.optional && !baselineField.type.includes('undefined')) {
1004
+ return false;
1005
+ }
1006
+
1007
+ if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
1008
+ return false;
1009
+ }
1010
+
1011
+ return true;
1012
+ }
1013
+
1014
+ function hasSpecificIRType(ref: TypeRef): boolean {
1015
+ switch (ref.kind) {
1016
+ case 'model':
1017
+ case 'enum':
1018
+ return true;
1019
+ case 'union':
1020
+ return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
1021
+ case 'nullable':
1022
+ return hasSpecificIRType(ref.inner);
1023
+ default:
1024
+ return false;
1025
+ }
1026
+ }
1027
+
1028
+ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
1029
+ if (!model.typeParams?.length) {
1030
+ if (genericDefaults?.has(model.name)) {
1031
+ return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
1032
+ }
1033
+ return '';
1034
+ }
1035
+ const params = model.typeParams.map((tp) => {
1036
+ const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
1037
+ return `${tp.name}${def}`;
1038
+ });
1039
+ return `<${params.join(', ')}>`;
1040
+ }