@workos/oagen-emitters 0.12.1 → 0.12.3

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