@workos/oagen-emitters 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -324,6 +324,315 @@ const METHOD_KEYWORD_BLOCKLIST = new Set([
324
324
  'constructor', // tracked separately if needed
325
325
  ]);
326
326
 
327
+ // ---------------------------------------------------------------------------
328
+ // Partial-emission merge
329
+ //
330
+ // For a service that is NOT owned and NOT a missing-service adoption,
331
+ // `resources.ts` intentionally emits a PARTIAL resource class: operations
332
+ // already covered by the baseline class are filtered out, leaving only the
333
+ // new methods. Overwriting the on-disk file with that partial class would
334
+ // delete every existing public method (this deleted four methods from
335
+ // workos-node's api-keys.ts when the spec gained one operation). The helpers
336
+ // below let `applyLiveSurface` detect that case and merge instead: keep the
337
+ // existing file text verbatim and append only the generated-only methods
338
+ // (plus any imports they need).
339
+ // ---------------------------------------------------------------------------
340
+
341
+ interface ParsedClassLines {
342
+ name: string;
343
+ /** Line index of the `export class` declaration. */
344
+ declLine: number;
345
+ /** Line index of the line containing the class body's closing brace. */
346
+ closeLine: number;
347
+ /** Method names declared directly on the class body, in source order. */
348
+ methods: string[];
349
+ }
350
+
351
+ // Class members sit at exactly two-space indent in both generated output and
352
+ // prettier-formatted SDK files; deeper-indented lines are method bodies.
353
+ const CLASS_MEMBER_RE =
354
+ /^ {2}(?:(?:public|private|protected|readonly|static)\s+)*(?:async\s+)?([a-zA-Z_$][\w$]*)\s*(?:<[^>]*>)?\s*\(/;
355
+
356
+ interface BraceScan {
357
+ /** Net code-brace balance accumulated so far. */
358
+ depth: number;
359
+ /** Whether any code `{` has been seen — distinguishes "not yet opened" from "balanced and closed". */
360
+ opened: boolean;
361
+ /** Whether we are mid `/* … *​/` block comment (spans lines). */
362
+ inBlockComment: boolean;
363
+ /**
364
+ * Interpolation-brace depth for each active template literal; a non-empty
365
+ * stack means we are inside a template, and the top being 0 means we are in
366
+ * its raw text (vs. inside a `${ … }` interpolation).
367
+ */
368
+ templates: number[];
369
+ }
370
+
371
+ /**
372
+ * Advance a brace-balance scan across one `line`, mutating `s`. Counts only
373
+ * braces that live in *code* — including template `${ … }` interpolation,
374
+ * whose braces are self-balancing — and ignores braces inside string/template
375
+ * text, line comments, and block comments. State threads across lines so block
376
+ * comments and multi-line template literals are tracked correctly.
377
+ *
378
+ * Naive character counting (the prior approach) would clip a method block at a
379
+ * `}` that merely sat inside a comment (`// returns when done }`) or a string
380
+ * (`'closing brace: }'`), appending a truncated, unbalanced body. Regex
381
+ * literals are intentionally not modelled: distinguishing `/` division from a
382
+ * regex needs full tokenization, and a regex whose braces are net-unbalanced
383
+ * does not occur in generated or hand-edited SDK code (`{n,m}` quantifiers are
384
+ * always balanced).
385
+ */
386
+ function advanceBraceScan(line: string, s: BraceScan): void {
387
+ for (let i = 0; i < line.length; i++) {
388
+ const c = line[i];
389
+ if (s.inBlockComment) {
390
+ if (c === '*' && line[i + 1] === '/') {
391
+ s.inBlockComment = false;
392
+ i++;
393
+ }
394
+ continue;
395
+ }
396
+ const tdepth = s.templates.length;
397
+ if (tdepth > 0 && s.templates[tdepth - 1] === 0) {
398
+ // Raw template text: only an escape, a closing backtick, or the start of
399
+ // an interpolation matters; everything else (braces included) is literal.
400
+ if (c === '\\') {
401
+ i++;
402
+ } else if (c === '`') {
403
+ s.templates.pop();
404
+ } else if (c === '$' && line[i + 1] === '{') {
405
+ s.templates[tdepth - 1] = 1;
406
+ s.depth++;
407
+ s.opened = true;
408
+ i++;
409
+ }
410
+ continue;
411
+ }
412
+ // Code context: top level, or inside a template interpolation.
413
+ if (c === '/' && line[i + 1] === '/') break; // line comment runs to EOL
414
+ if (c === '/' && line[i + 1] === '*') {
415
+ s.inBlockComment = true;
416
+ i++;
417
+ continue;
418
+ }
419
+ if (c === "'" || c === '"') {
420
+ const quote = c;
421
+ i++;
422
+ while (i < line.length && line[i] !== quote) {
423
+ if (line[i] === '\\') i++;
424
+ i++;
425
+ }
426
+ continue;
427
+ }
428
+ if (c === '`') {
429
+ s.templates.push(0);
430
+ continue;
431
+ }
432
+ if (c === '{') {
433
+ s.depth++;
434
+ s.opened = true;
435
+ if (tdepth > 0) s.templates[tdepth - 1]++;
436
+ } else if (c === '}') {
437
+ s.depth--;
438
+ if (tdepth > 0) s.templates[tdepth - 1]--;
439
+ }
440
+ }
441
+ }
442
+
443
+ function parseClassesByLine(lines: string[]): ParsedClassLines[] {
444
+ const classes: ParsedClassLines[] = [];
445
+ for (let i = 0; i < lines.length; i++) {
446
+ const decl = lines[i].match(/^export\s+(?:abstract\s+)?class\s+([A-Z][\w$]*)/);
447
+ if (!decl) continue;
448
+
449
+ const scan: BraceScan = { depth: 0, opened: false, inBlockComment: false, templates: [] };
450
+ let closeLine = -1;
451
+ for (let j = i; j < lines.length; j++) {
452
+ advanceBraceScan(lines[j], scan);
453
+ if (scan.opened && scan.depth <= 0) {
454
+ closeLine = j;
455
+ break;
456
+ }
457
+ }
458
+ if (closeLine < 0) continue;
459
+
460
+ const methods: string[] = [];
461
+ for (let j = i + 1; j < closeLine; j++) {
462
+ const member = lines[j].match(CLASS_MEMBER_RE);
463
+ if (!member) continue;
464
+ const name = member[1];
465
+ if (name === 'constructor' || METHOD_KEYWORD_BLOCKLIST.has(name)) continue;
466
+ if (!methods.includes(name)) methods.push(name);
467
+ }
468
+ classes.push({ name: decl[1], declLine: i, closeLine, methods });
469
+ i = closeLine;
470
+ }
471
+ return classes;
472
+ }
473
+
474
+ /**
475
+ * Extract the full source block of `method` (attached comments included) from
476
+ * a parsed class. Returns null if the block cannot be delimited.
477
+ */
478
+ function extractMethodBlock(lines: string[], cls: ParsedClassLines, method: string): string[] | null {
479
+ for (let j = cls.declLine + 1; j < cls.closeLine; j++) {
480
+ const member = lines[j].match(CLASS_MEMBER_RE);
481
+ if (!member || member[1] !== method) continue;
482
+
483
+ // Pull in the contiguous comment block directly above the signature.
484
+ let start = j;
485
+ while (start > cls.declLine + 1) {
486
+ const above = lines[start - 1].trim();
487
+ if (above.startsWith('//') || above.startsWith('/*') || above.startsWith('*')) start--;
488
+ else break;
489
+ }
490
+
491
+ // The member ends where the brace depth opened by its signature returns to
492
+ // zero. `advanceBraceScan` counts only code braces — ignoring those inside
493
+ // strings, template text, and comments — so it is robust to inner closures,
494
+ // object literals, and template interpolation nested at any indentation, as
495
+ // well as a stray `}` in a comment or string. The previous "first
496
+ // two-space-indented `}`" heuristic clipped the block at a nested brace at
497
+ // method-close indentation; raw character counting clipped it at a `}` that
498
+ // merely sat in a comment or string. Either appends a truncated body and
499
+ // orphans the real closing brace.
500
+ const scan: BraceScan = { depth: 0, opened: false, inBlockComment: false, templates: [] };
501
+ for (let end = j; end < cls.closeLine; end++) {
502
+ advanceBraceScan(lines[end], scan);
503
+ if (scan.opened && scan.depth <= 0) return lines.slice(start, end + 1);
504
+ }
505
+ return null;
506
+ }
507
+ return null;
508
+ }
509
+
510
+ // Multi-line tolerant: prettier wraps long named-import lists across lines.
511
+ const IMPORT_STMT_RE =
512
+ /^import\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([\s\S]*?)\}\s*)?(?:\*\s+as\s+([A-Za-z_$][\w$]*)\s+)?from\s+['"]([^'"]+)['"];?/gm;
513
+
514
+ function importLocalName(entry: string): string {
515
+ const asMatch = entry.match(/\s+as\s+([A-Za-z_$][\w$]*)\s*$/);
516
+ if (asMatch) return asMatch[1];
517
+ return entry.replace(/^type\s+/, '').trim();
518
+ }
519
+
520
+ function collectImportedLocalNames(text: string): Set<string> {
521
+ const names = new Set<string>();
522
+ for (const m of text.matchAll(IMPORT_STMT_RE)) {
523
+ if (m[2]) names.add(m[2]);
524
+ if (m[4]) names.add(m[4]);
525
+ for (const entry of (m[3] ?? '').split(',')) {
526
+ const trimmed = entry.trim();
527
+ if (trimmed) names.add(importLocalName(trimmed));
528
+ }
529
+ }
530
+ return names;
531
+ }
532
+
533
+ /** Import statements from `generatedText` whose names `existingText` lacks. */
534
+ function missingImportStatements(existingText: string, generatedText: string): string[] {
535
+ const existingNames = collectImportedLocalNames(existingText);
536
+ const statements: string[] = [];
537
+ for (const m of generatedText.matchAll(IMPORT_STMT_RE)) {
538
+ const [stmt, typeOnly, defaultName, named, namespaceName, moduleSpec] = m;
539
+ if (defaultName || namespaceName) {
540
+ // Default/namespace imports: append verbatim when the local is missing.
541
+ const local = defaultName ?? namespaceName;
542
+ if (local && !existingNames.has(local)) statements.push(stmt.endsWith(';') ? stmt : `${stmt};`);
543
+ continue;
544
+ }
545
+ const missing = (named ?? '')
546
+ .split(',')
547
+ .map((entry) => entry.trim())
548
+ .filter((entry) => entry && !existingNames.has(importLocalName(entry)));
549
+ if (missing.length === 0) continue;
550
+ statements.push(`import ${typeOnly ? 'type ' : ''}{ ${missing.join(', ')} } from '${moduleSpec}';`);
551
+ }
552
+ return statements;
553
+ }
554
+
555
+ /**
556
+ * Merge a partial class emission into the existing on-disk file content.
557
+ *
558
+ * Acts only when a class declared in BOTH texts has methods on disk that the
559
+ * generated content drops — the signature of a "new operations only" partial
560
+ * emission. Returns the existing text with the generated-only methods appended
561
+ * to the class body (and missing imports added), preserving every existing
562
+ * method and import verbatim.
563
+ *
564
+ * Returns null when the generated content does not drop any existing method;
565
+ * callers should then proceed with their normal (overwrite) path so full
566
+ * regenerations keep propagating emitter improvements and spec renames.
567
+ */
568
+ export function mergeGeneratedClassMethodsIntoExisting(existingText: string, generatedText: string): string | null {
569
+ const existingLines = existingText.split('\n');
570
+ const generatedLines = generatedText.split('\n');
571
+ const existingClasses = parseClassesByLine(existingLines);
572
+ if (existingClasses.length === 0) return null;
573
+ const generatedClasses = new Map(parseClassesByLine(generatedLines).map((cls) => [cls.name, cls]));
574
+
575
+ let dropsExistingMethod = false;
576
+ const insertions: Array<{ atLine: number; blocks: string[][] }> = [];
577
+ for (const existing of existingClasses) {
578
+ const generated = generatedClasses.get(existing.name);
579
+ if (!generated) continue;
580
+ const generatedMethods = new Set(generated.methods);
581
+ if (!existing.methods.some((method) => !generatedMethods.has(method))) continue;
582
+ dropsExistingMethod = true;
583
+
584
+ const existingMethods = new Set(existing.methods);
585
+ const blocks: string[][] = [];
586
+ for (const method of generated.methods) {
587
+ if (existingMethods.has(method)) continue;
588
+ const block = extractMethodBlock(generatedLines, generated, method);
589
+ if (block) blocks.push(block);
590
+ }
591
+ if (blocks.length > 0) insertions.push({ atLine: existing.closeLine, blocks });
592
+ }
593
+ if (!dropsExistingMethod) return null;
594
+
595
+ const merged = [...existingLines];
596
+ // Bottom-up so earlier insertions don't shift later line indices. Imports
597
+ // sit above every class body, so the import insertion point (computed on
598
+ // the original text) stays valid after these splices.
599
+ insertions.sort((a, b) => b.atLine - a.atLine);
600
+ for (const insertion of insertions) {
601
+ const blockLines: string[] = [];
602
+ for (const block of insertion.blocks) {
603
+ blockLines.push('', ...block);
604
+ }
605
+ merged.splice(insertion.atLine, 0, ...blockLines);
606
+ }
607
+
608
+ const importStatements = missingImportStatements(existingText, generatedText);
609
+ if (importStatements.length > 0) {
610
+ let lastImportLine = -1;
611
+ for (let i = 0; i < existingLines.length; i++) {
612
+ if (
613
+ /from\s+['"][^'"]+['"];?\s*$/.test(existingLines[i]) ||
614
+ /^import\s+['"][^'"]+['"];?\s*$/.test(existingLines[i])
615
+ ) {
616
+ lastImportLine = i;
617
+ }
618
+ }
619
+ if (lastImportLine >= 0) {
620
+ merged.splice(lastImportLine + 1, 0, ...importStatements);
621
+ } else {
622
+ // No imports yet: place them after the leading comment block.
623
+ let insertAt = 0;
624
+ while (insertAt < existingLines.length) {
625
+ const line = existingLines[insertAt].trim();
626
+ if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) insertAt++;
627
+ else break;
628
+ }
629
+ merged.splice(insertAt, 0, ...importStatements, '');
630
+ }
631
+ }
632
+
633
+ return merged.join('\n');
634
+ }
635
+
327
636
  /**
328
637
  * Given the start index of a class declaration, return the brace-delimited body
329
638
  * as a substring. Returns '' if the body cannot be located.
@@ -41,7 +41,7 @@ import {
41
41
  emitSerializerBody,
42
42
  hasDateTimeConversion,
43
43
  } from './field-plan.js';
44
- import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile } from './live-surface.js';
44
+ import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
45
45
  import { isNodeOwnedService } from './options.js';
46
46
  import { unwrapListModel } from './fixtures.js';
47
47
  import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
@@ -370,7 +370,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
370
370
  const eDir = resolveDir(eService);
371
371
  const bEnum = ctx.apiSurface?.enums?.[irEnumName];
372
372
  const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
373
- const bSrc = (bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile;
373
+ // Same owned-service exception as the `deps.enums` loop below:
374
+ // `generateEnums` emits the canonical module for owned services
375
+ // even when the baseline declares the name elsewhere (usually in
376
+ // the very file being overwritten), so import planning must
377
+ // target the canonical path to agree with that emission.
378
+ const eEnumIsOwned = isNodeOwnedService(ctx, eService);
379
+ const bSrc = eEnumIsOwned ? undefined : ((bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile);
374
380
  const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
375
381
  const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
376
382
  if (bSrc === cPath) {
@@ -446,8 +452,21 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
446
452
 
447
453
  const baselineEnum = ctx.apiSurface?.enums?.[dep];
448
454
  const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
449
- const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
450
455
  const depService = enumToService.get(dep);
456
+ const depEnumIsOwned = isNodeOwnedService(ctx, depService);
457
+ // Fall back to the live-surface declaration path: `generateEnums` skips
458
+ // emission when the enum is already declared elsewhere in the SDK (same
459
+ // fallback, see enums.ts), so the import must follow that declaration —
460
+ // the canonical per-service file will never exist.
461
+ //
462
+ // OWNED services are the exception, mirroring enums.ts: the on-disk
463
+ // declaration usually lives in a file this very regeneration
464
+ // overwrites, `generateEnums` emits the canonical module anyway, and
465
+ // the import must agree with that emission — otherwise the generated
466
+ // interface references a name that is declared nowhere.
467
+ const baselineSrc = depEnumIsOwned
468
+ ? undefined
469
+ : ((baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile ?? liveSurfaceInterfacePath(dep));
451
470
  const depDir = resolveDir(depService);
452
471
  const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
453
472
  const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
@@ -602,6 +621,17 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
602
621
  if (generatedNames.has(name)) continue;
603
622
  const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
604
623
  if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
624
+ // Owned-service enums get their canonical per-service module
625
+ // emitted by `generateEnums` this run, and this file imports the
626
+ // name (see the deps.enums loop above). Preserving the legacy
627
+ // inline declaration would collide with that import (TS2440).
628
+ if (
629
+ !isInlineEnum(name) &&
630
+ ctx.spec.enums.some((e) => e.name === name) &&
631
+ isNodeOwnedService(ctx, enumToService.get(name))
632
+ ) {
633
+ continue;
634
+ }
605
635
  inlineNames.add(name);
606
636
  }
607
637
  };
@@ -1164,6 +1194,16 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
1164
1194
  }
1165
1195
 
1166
1196
  function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
1197
+ // A baseline `any` is the footprint of a previously-broken generation:
1198
+ // api-surface extraction types a field as `any` when its import failed to
1199
+ // resolve (e.g. the enum file hadn't been emitted yet in that run). Copying
1200
+ // it forward would freeze the degradation — once `state: any` lands in the
1201
+ // SDK, every later regen sees it as the baseline and re-emits it. When the
1202
+ // IR knows the real model/enum name, always re-derive from the IR instead.
1203
+ if (baselineTypeIsDegradedAny(baselineField.type) && hasNamedTypeReference(irField.type)) {
1204
+ return false;
1205
+ }
1206
+
1167
1207
  const irNullable = irField.type.kind === 'nullable';
1168
1208
  const baselineHasNull = baselineField.type.includes('null');
1169
1209
 
@@ -1182,6 +1222,32 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
1182
1222
  return true;
1183
1223
  }
1184
1224
 
1225
+ /** `any`, `any[]`, `any | null`, … — shapes api-surface extraction degrades to. */
1226
+ function baselineTypeIsDegradedAny(typeStr: string): boolean {
1227
+ const stripped = typeStr
1228
+ .replace(/\s*\|\s*(?:null|undefined)\b/g, '')
1229
+ .replace(/\[\]$/, '')
1230
+ .trim();
1231
+ return stripped === 'any';
1232
+ }
1233
+
1234
+ /** Does the IR type reference a named model/enum anywhere (incl. arrays)? */
1235
+ function hasNamedTypeReference(ref: TypeRef): boolean {
1236
+ switch (ref.kind) {
1237
+ case 'model':
1238
+ case 'enum':
1239
+ return true;
1240
+ case 'array':
1241
+ return hasNamedTypeReference(ref.items);
1242
+ case 'nullable':
1243
+ return hasNamedTypeReference(ref.inner);
1244
+ case 'union':
1245
+ return ref.variants.some((v) => hasNamedTypeReference(v));
1246
+ default:
1247
+ return false;
1248
+ }
1249
+ }
1250
+
1185
1251
  function hasSpecificIRType(ref: TypeRef): boolean {
1186
1252
  switch (ref.kind) {
1187
1253
  case 'model':
@@ -61,6 +61,26 @@ export function setBaselineInterfaceNames(names: Set<string>): void {
61
61
  baselineInterfaceNames = names;
62
62
  }
63
63
 
64
+ /**
65
+ * Every name DECLARED by the live SDK or baseline api-surface — interfaces
66
+ * AND type aliases. Exact-name declarations preempt structural renames in
67
+ * `resolveInterfaceName`: when the IR model's own name is already declared,
68
+ * the structural matcher must not re-point it at a different declaration.
69
+ *
70
+ * This matters for alias-form files (`export type X = Y;`): the engine's
71
+ * api-surface records X under `typeAliases` with no fields, so its
72
+ * exact-name pass cannot claim X and the Jaccard fallback "renames" IR
73
+ * model X to whatever interface looks similar. Propagating that rename
74
+ * emitted duplicate, renamed declarations whose form flip-flopped on every
75
+ * regeneration (workos-node ApiKeyOwner / UserManagement model files).
76
+ *
77
+ * Set by `index.ts` immediately after `getSurface(ctx)` runs.
78
+ */
79
+ let baselineDeclaredNames: Set<string> = new Set();
80
+ export function setBaselineDeclaredNames(names: Set<string>): void {
81
+ baselineDeclaredNames = names;
82
+ }
83
+
64
84
  /**
65
85
  * IR models that belong to newly-adopted services should not be renamed by
66
86
  * structural baseline matches from unrelated hand-written services.
@@ -100,6 +120,178 @@ export function setStructurallyRenamedDomainNames(names: Set<string>): void {
100
120
  structurallyRenamedDomainNames = names;
101
121
  }
102
122
 
123
+ /**
124
+ * The structural half of `resolveInterfaceName`, pre-injectivity: look up the
125
+ * engine's structurally-inferred match (`overlayLookup.modelNameByIR`), apply
126
+ * the adopted/discriminated/declared-name guards, and normalize legacy
127
+ * `Serialized*` / wire-shaped `*Response` matches down to the baseline domain
128
+ * name. Returns the candidate live name, or undefined when no structural
129
+ * match applies. Shared by `resolveInterfaceName` and the claims registry so
130
+ * both see the exact same candidate for every IR model.
131
+ */
132
+ function inferStructuralRename(name: string, ctx: EmitterContext): string | undefined {
133
+ let inferred =
134
+ adoptedModelNames.has(name) || discriminatedModelNames.has(name)
135
+ ? undefined
136
+ : ctx.overlayLookup?.modelNameByIR?.get(name);
137
+ // Exact-name declarations preempt structural renames: when the live SDK
138
+ // already declares `name` (interface or type alias), a non-identity
139
+ // structural match is a misfire — the alias/typeAlias resolution in
140
+ // `resolveInterfaceName` (or the name itself) is the canonical answer. See
141
+ // `setBaselineDeclaredNames` for the alias-form feedback loop this breaks.
142
+ if (inferred && inferred !== name && baselineDeclaredNames.has(name)) {
143
+ return undefined;
144
+ }
145
+ if (!inferred) return undefined;
146
+ if (inferred.startsWith('Serialized')) {
147
+ const stripped = inferred.slice('Serialized'.length);
148
+ if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
149
+ inferred = stripped;
150
+ }
151
+ }
152
+ // Structural matchers tend to lock onto the wire-shaped baseline
153
+ // interface (`AuditLogSchemaResponse`) because the IR carries
154
+ // snake_case fields. Prefer the corresponding domain name (without
155
+ // the `Response` suffix) when both exist in baseline — domain refs
156
+ // belong on the domain side, the wire/serialize path picks up the
157
+ // `*Response` variant via `wireInterfaceName`.
158
+ if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
159
+ const stripped = inferred.slice(0, -'Response'.length);
160
+ if (stripped && ctx.apiSurface.interfaces[stripped]) {
161
+ inferred = stripped;
162
+ }
163
+ }
164
+ return inferred;
165
+ }
166
+
167
+ /**
168
+ * Per-run registry making structural name resolution INJECTIVE: live-surface
169
+ * name → the single IR model name allowed to claim it. Built lazily from
170
+ * `ctx.spec.models` on first use and cached per ctx.
171
+ *
172
+ * Cache-correctness invariant (relied on, not assumed): the `ctx` reaching
173
+ * this function is always the memoized `nodeCtx` from `withNodeOperationOverrides`
174
+ * — every emitter hook derives it as its first step and threads it through
175
+ * `getSurface`/`resolveInterfaceName`, and that helper returns one stable
176
+ * object per run. `nodeCtx.spec` is built once via spread and `spec.models` is
177
+ * never reassigned or mutated in place anywhere (enrichment pushes only onto a
178
+ * pre-enrichment local collector). So the cached value can never drift from
179
+ * the `spec.models` it was computed from. Do not begin mutating `spec.models`
180
+ * under a live ctx without invalidating this cache.
181
+ *
182
+ * Without it, the structural fallback could map two distinct IR models onto
183
+ * one live declaration. workos-node AuditLogs evidence: IR
184
+ * `AuditLogEventActor` and `AuditLogEventTarget` (near-identical shapes) both
185
+ * resolved to the hand-written `AuditLogActor`, so
186
+ * `audit-log-event-target.interface.ts` was emitted declaring
187
+ * `export interface AuditLogActor` (file stem and declaration disagree),
188
+ * with duplicate imports/`describe` blocks and two `serializeAuditLogActor`
189
+ * definitions downstream. The raw engine overlay is injective on its own
190
+ * names, but the resolver's `Serialized*`/`*Response` normalization below can
191
+ * collapse two distinct raw matches onto one bare name — the claims registry
192
+ * gates the final, post-normalization answer.
193
+ *
194
+ * Claim order:
195
+ * 1. Exact-name overrides (`overlayLookup.interfaceByName`) claim first.
196
+ * 2. Identity structural matches (IR name === live name) claim their own name.
197
+ * 3. Contested renames go to the claimant with the higher field-overlap
198
+ * similarity; ties break toward the closer name (edit distance), then
199
+ * lexicographically for determinism. Losers are NEVER unified — they keep
200
+ * their canonical IR-derived names.
201
+ */
202
+ const structuralClaimsCache = new WeakMap<EmitterContext, Map<string, string>>();
203
+
204
+ /** Jaccard similarity between two normalized field-name sets. */
205
+ function fieldJaccard(a: Set<string>, b: Set<string>): number {
206
+ if (a.size === 0 && b.size === 0) return 0;
207
+ let intersection = 0;
208
+ for (const item of a) if (b.has(item)) intersection++;
209
+ const union = a.size + b.size - intersection;
210
+ return union === 0 ? 0 : intersection / union;
211
+ }
212
+
213
+ /** Levenshtein distance over lowercased names (tie-break for contested claims). */
214
+ function nameDistance(a: string, b: string): number {
215
+ const s = a.toLowerCase();
216
+ const t = b.toLowerCase();
217
+ if (s === t) return 0;
218
+ let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
219
+ for (let i = 1; i <= s.length; i++) {
220
+ const curr = [i];
221
+ for (let j = 1; j <= t.length; j++) {
222
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
223
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
224
+ }
225
+ prev = curr;
226
+ }
227
+ return prev[t.length];
228
+ }
229
+
230
+ /** Normalized field-name signature of an IR model. */
231
+ function irFieldSignature(model: { fields: { name: string }[] }): Set<string> {
232
+ return new Set(model.fields.map((f) => toSnakeCase(f.name)));
233
+ }
234
+
235
+ /** Normalized field-name signature of a live-surface interface, if known. */
236
+ function liveFieldSignature(ctx: EmitterContext, liveName: string): Set<string> | undefined {
237
+ const iface = ctx.apiSurface?.interfaces?.[liveName] as { fields?: Record<string, unknown> } | undefined;
238
+ if (!iface?.fields) return undefined;
239
+ return new Set(Object.keys(iface.fields).map((f) => toSnakeCase(f)));
240
+ }
241
+
242
+ function getStructuralNameClaims(ctx: EmitterContext): Map<string, string> {
243
+ const cached = structuralClaimsCache.get(ctx);
244
+ if (cached) return cached;
245
+ const claims = new Map<string, string>();
246
+
247
+ // 1. Exact-name overrides claim first (mirrors resolveInterfaceName step 1).
248
+ for (const [irName, liveName] of ctx.overlayLookup?.interfaceByName ?? new Map<string, string>()) {
249
+ if (!claims.has(liveName)) claims.set(liveName, irName);
250
+ }
251
+
252
+ // 2. Identity matches claim their own name; renames queue up per live name.
253
+ const contested = new Map<string, string[]>();
254
+ const modelsByName = new Map<string, { fields: { name: string }[] }>();
255
+ for (const model of ctx.spec.models) {
256
+ modelsByName.set(model.name, model);
257
+ if (ctx.overlayLookup?.interfaceByName?.has(model.name)) continue;
258
+ const inferred = inferStructuralRename(model.name, ctx);
259
+ if (!inferred) continue;
260
+ if (inferred === model.name) {
261
+ if (!claims.has(inferred)) claims.set(inferred, model.name);
262
+ continue;
263
+ }
264
+ const group = contested.get(inferred);
265
+ if (group) group.push(model.name);
266
+ else contested.set(inferred, [model.name]);
267
+ }
268
+
269
+ // 3. Award each contested live name to exactly one structural claimant.
270
+ for (const [liveName, irNames] of contested) {
271
+ if (claims.has(liveName)) continue; // exact/identity claims preempt renames
272
+ let winner = irNames[0];
273
+ if (irNames.length > 1) {
274
+ const liveFields = liveFieldSignature(ctx, liveName);
275
+ const scoreOf = (irName: string): number => {
276
+ const model = modelsByName.get(irName);
277
+ if (!model || !liveFields) return 0;
278
+ return fieldJaccard(irFieldSignature(model), liveFields);
279
+ };
280
+ winner = [...irNames].sort((a, b) => {
281
+ const scoreDiff = scoreOf(b) - scoreOf(a);
282
+ if (scoreDiff !== 0) return scoreDiff;
283
+ const distDiff = nameDistance(a, liveName) - nameDistance(b, liveName);
284
+ if (distDiff !== 0) return distDiff;
285
+ return a < b ? -1 : a > b ? 1 : 0;
286
+ })[0];
287
+ }
288
+ claims.set(liveName, winner);
289
+ }
290
+
291
+ structuralClaimsCache.set(ctx, claims);
292
+ return claims;
293
+ }
294
+
103
295
  /**
104
296
  * Wire/response interface name.
105
297
  *
@@ -224,7 +416,8 @@ export function resolveClassName(service: Service, ctx: EmitterContext): string
224
416
  * 1. `overlayLookup.interfaceByName` — exact-name overrides from the live SDK.
225
417
  * 2. `overlayLookup.modelNameByIR` — structurally-inferred matches (e.g., IR
226
418
  * `ValidateApiKey` with one field `value: string` → live SDK interface
227
- * `ValidateApiKeyOptions`).
419
+ * `ValidateApiKeyOptions`), gated by the injective claims registry: each
420
+ * live name goes to at most one IR model (see `getStructuralNameClaims`).
228
421
  * 3. Type-alias resolution (when an alias points to an interface).
229
422
  * 4. Suffix-fallback heuristic for the workos-node `*Options` convention:
230
423
  * when the IR name `X` has no baseline match but `XOptions` does, use
@@ -236,30 +429,18 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
236
429
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
237
430
  if (existing) return existing;
238
431
 
239
- let inferred =
240
- adoptedModelNames.has(name) || discriminatedModelNames.has(name)
241
- ? undefined
242
- : ctx.overlayLookup?.modelNameByIR?.get(name);
243
- if (inferred) {
244
- if (inferred.startsWith('Serialized')) {
245
- const stripped = inferred.slice('Serialized'.length);
246
- if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
247
- inferred = stripped;
248
- }
249
- }
250
- // Structural matchers tend to lock onto the wire-shaped baseline
251
- // interface (`AuditLogSchemaResponse`) because the IR carries
252
- // snake_case fields. Prefer the corresponding domain name (without
253
- // the `Response` suffix) when both exist in baseline — domain refs
254
- // belong on the domain side, the wire/serialize path picks up the
255
- // `*Response` variant via `wireInterfaceName`.
256
- if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
257
- const stripped = inferred.slice(0, -'Response'.length);
258
- if (stripped && ctx.apiSurface.interfaces[stripped]) {
259
- inferred = stripped;
432
+ let inferred = inferStructuralRename(name, ctx);
433
+ if (inferred !== undefined) {
434
+ // Injectivity gate: a structurally-renamed live name may be claimed by
435
+ // at most one IR model per run. When another model holds the claim, the
436
+ // rename is dropped and this model keeps its canonical IR-derived name.
437
+ if (inferred !== name) {
438
+ const claimant = getStructuralNameClaims(ctx).get(inferred);
439
+ if (claimant !== undefined && claimant !== name) {
440
+ inferred = undefined;
260
441
  }
261
442
  }
262
- return inferred;
443
+ if (inferred !== undefined) return inferred;
263
444
  }
264
445
 
265
446
  if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {