@workos/oagen-emitters 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. package/scripts/git-push-with-published-oagen.sh +0 -21
package/smoke/sdk-php.ts CHANGED
@@ -166,14 +166,19 @@ class CaptureProxy {
166
166
  // ---------------------------------------------------------------------------
167
167
 
168
168
  function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
169
- const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
169
+ const manifestPath = resolve(sdkPath, '.oagen-manifest.json');
170
170
  if (!existsSync(manifestPath)) {
171
- console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
171
+ console.warn(`Warning: No .oagen-manifest.json found at ${manifestPath}`);
172
+ return null;
173
+ }
174
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
175
+ const operations = parsed?.operations;
176
+ if (!operations || typeof operations !== 'object') {
177
+ console.warn('Warning: .oagen-manifest.json has no operations field');
172
178
  return null;
173
179
  }
174
- const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
175
180
  const manifest = new Map<string, ManifestEntry>();
176
- for (const [httpKey, entry] of Object.entries(raw)) {
181
+ for (const [httpKey, entry] of Object.entries(operations)) {
177
182
  manifest.set(httpKey, entry as ManifestEntry);
178
183
  }
179
184
  return manifest;
@@ -191,15 +191,21 @@ function createProxyServer(
191
191
  // ---------------------------------------------------------------------------
192
192
 
193
193
  function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
194
- const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
194
+ const manifestPath = resolve(sdkPath, '.oagen-manifest.json');
195
195
  if (!existsSync(manifestPath)) {
196
- console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
196
+ console.warn(`Warning: No .oagen-manifest.json found at ${manifestPath}`);
197
+ console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
198
+ return null;
199
+ }
200
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
201
+ const operations = parsed?.operations;
202
+ if (!operations || typeof operations !== 'object') {
203
+ console.warn('Warning: .oagen-manifest.json has no operations field');
197
204
  console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
198
205
  return null;
199
206
  }
200
- const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
201
207
  const manifest = new Map<string, ManifestEntry>();
202
- for (const [httpKey, entry] of Object.entries(raw)) {
208
+ for (const [httpKey, entry] of Object.entries(operations)) {
203
209
  manifest.set(httpKey, entry as ManifestEntry);
204
210
  }
205
211
  return manifest;
package/smoke/sdk-ruby.ts CHANGED
@@ -78,15 +78,21 @@ interface MethodResolution {
78
78
  // ---------------------------------------------------------------------------
79
79
 
80
80
  function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
81
- const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
81
+ const manifestPath = resolve(sdkPath, '.oagen-manifest.json');
82
82
  if (!existsSync(manifestPath)) {
83
- console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
83
+ console.warn(`Warning: No .oagen-manifest.json found at ${manifestPath}`);
84
+ console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
85
+ return null;
86
+ }
87
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
88
+ const operations = parsed?.operations;
89
+ if (!operations || typeof operations !== 'object') {
90
+ console.warn('Warning: .oagen-manifest.json has no operations field');
84
91
  console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
85
92
  return null;
86
93
  }
87
- const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
88
94
  const manifest = new Map<string, ManifestEntry>();
89
- for (const [httpKey, entry] of Object.entries(raw)) {
95
+ for (const [httpKey, entry] of Object.entries(operations)) {
90
96
  manifest.set(httpKey, entry as ManifestEntry);
91
97
  }
92
98
  return manifest;
package/smoke/sdk-rust.ts CHANGED
@@ -167,15 +167,21 @@ function createProxyServer(
167
167
  // ---------------------------------------------------------------------------
168
168
 
169
169
  function loadManifest(sdkPath: string): Map<string, ManifestEntry> | null {
170
- const manifestPath = resolve(sdkPath, 'smoke-manifest.json');
170
+ const manifestPath = resolve(sdkPath, '.oagen-manifest.json');
171
171
  if (!existsSync(manifestPath)) {
172
- console.warn(`Warning: No smoke-manifest.json found at ${manifestPath}`);
173
- console.warn(' Method resolution will rely on heuristic tiers most operations may be skipped.');
172
+ console.warn(`Warning: No .oagen-manifest.json found at ${manifestPath}`);
173
+ console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
174
+ return null;
175
+ }
176
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
177
+ const operations = parsed?.operations;
178
+ if (!operations || typeof operations !== 'object') {
179
+ console.warn('Warning: .oagen-manifest.json has no operations field');
180
+ console.warn(' Method resolution will rely on heuristic tiers -- most operations may be skipped.');
174
181
  return null;
175
182
  }
176
- const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
177
183
  const manifest = new Map<string, ManifestEntry>();
178
- for (const [httpKey, entry] of Object.entries(raw)) {
184
+ for (const [httpKey, entry] of Object.entries(operations)) {
179
185
  manifest.set(httpKey, entry as ManifestEntry);
180
186
  }
181
187
  return manifest;
@@ -11,13 +11,13 @@ import type {
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
- import { generateModels } from './models.js';
14
+ import { generateModels, primeModelAliases } from './models.js';
15
15
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
16
16
  import { generateEnums, primeEnumAliases } from './enums.js';
17
17
  import { generateResources } from './resources.js';
18
18
  import { generateClient } from './client.js';
19
19
  import { generateTests } from './tests.js';
20
- import { generateManifest } from './manifest.js';
20
+ import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
22
  import { groupByMount } from '../shared/resolved-ops.js';
23
23
  import { discriminatedUnions } from './type-map.js';
@@ -151,6 +151,7 @@ export const dotnetEmitter: Emitter = {
151
151
  const c = fixNamespace(ctx);
152
152
  const synEnums = getSyntheticEnums();
153
153
  primeEnumAliases(synEnums.length > 0 ? [...c.spec.enums, ...synEnums] : c.spec.enums);
154
+ primeModelAliases(enrichModelsFromSpec(c.spec.models));
154
155
  const files = generateResources(services, c);
155
156
 
156
157
  // Also generate wrapper options classes
@@ -201,11 +202,12 @@ export const dotnetEmitter: Emitter = {
201
202
  const c = fixNamespace(ctx);
202
203
  const synEnumsForTests = getSyntheticEnums();
203
204
  primeEnumAliases(synEnumsForTests.length > 0 ? [...spec.enums, ...synEnumsForTests] : spec.enums);
205
+ primeModelAliases(enrichModelsFromSpec(c.spec.models));
204
206
  return prefixTestPaths(ensureTrailingNewlines(generateTests(spec, c)));
205
207
  },
206
208
 
207
- generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
208
- return ensureTrailingNewlines(generateManifest(spec, fixNamespace(ctx)));
209
+ buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
210
+ return buildOperationsMap(spec, fixNamespace(ctx));
209
211
  },
210
212
 
211
213
  fileHeader(): string {
@@ -229,7 +231,7 @@ export const dotnetEmitter: Emitter = {
229
231
  args: ['format', workspace, '--no-restore', '--include'],
230
232
  // Keep batches small enough to stay under argv length limits while
231
233
  // still amortizing MSBuild startup across many files.
232
- batchSize: 50,
234
+ batchSize: 500,
233
235
  };
234
236
  },
235
237
  };
@@ -239,8 +241,8 @@ function findDotnetWorkspace(targetDir: string): string | null {
239
241
  if (!fs.existsSync(targetDir)) return null;
240
242
  const entries = fs.readdirSync(targetDir);
241
243
  const sln = entries.find((e) => e.endsWith('.sln') || e.endsWith('.slnx'));
242
- if (sln) return path.join(targetDir, sln);
244
+ if (sln) return path.resolve(targetDir, sln);
243
245
  const csproj = entries.find((e) => e.endsWith('.csproj'));
244
- if (csproj) return path.join(targetDir, csproj);
246
+ if (csproj) return path.resolve(targetDir, csproj);
245
247
  return null;
246
248
  }
@@ -1,13 +1,13 @@
1
- import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
1
+ import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
2
  import { resolveMethodName } from './naming.js';
3
3
  import { buildServiceAccessPaths } from './client.js';
4
4
  import { getMountTarget } from '../shared/resolved-ops.js';
5
5
 
6
6
  /**
7
- * Generate smoke test manifest mapping HTTP operations to SDK methods.
7
+ * Build operation-to-SDK-method mapping for the manifest.
8
8
  */
9
- export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
- const manifest: Record<string, { sdkMethod: string; service: string }> = {};
9
+ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
10
+ const manifest: OperationsMap = {};
11
11
  const accessPaths = buildServiceAccessPaths(spec.services, ctx);
12
12
 
13
13
  for (const service of spec.services) {
@@ -26,11 +26,5 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
26
26
  }
27
27
  }
28
28
 
29
- return [
30
- {
31
- path: 'smoke-manifest.json',
32
- content: JSON.stringify(manifest, null, 2),
33
- integrateTarget: false,
34
- },
35
- ];
29
+ return manifest;
36
30
  }
@@ -1,14 +1,20 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef } from '@workos/oagen';
2
- import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
2
+ import {
3
+ mapTypeRef,
4
+ isValueTypeRef,
5
+ isEnumRef,
6
+ emitJsonPropertyAttributes,
7
+ setModelAliases,
8
+ isModelAlias,
9
+ } from './type-map.js';
3
10
  import {
4
11
  articleFor,
5
- className,
6
- escapeXml,
7
12
  fieldName,
8
13
  humanize,
9
14
  emitXmlDoc,
10
15
  deprecationMessage,
11
16
  escapeCsAttributeString,
17
+ modelClassName,
12
18
  } from './naming.js';
13
19
 
14
20
  // Import and re-export shared model detection utilities
@@ -35,90 +41,23 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
35
41
 
36
42
  const files: GeneratedFile[] = [];
37
43
 
38
- // Build structural hash for deduplication. Run the hash → canonical pass
39
- // iteratively so that parent classes whose only structural difference is
40
- // an already-aliased child type also collapse. Terminates when a full
41
- // round produces no new aliases.
42
- const eligibleModels = models.filter((m) => !isListWrapperModel(m) && !isListMetadataModel(m));
43
- const aliasOf = new Map<string, string>();
44
- while (true) {
45
- const hashGroups = new Map<string, string[]>();
46
- for (const model of eligibleModels) {
47
- const hash = structuralHash(model, aliasOf);
48
- if (!hashGroups.has(hash)) hashGroups.set(hash, []);
49
- hashGroups.get(hash)!.push(model.name);
50
- }
51
-
52
- let added = false;
53
- for (const [hash, names] of hashGroups) {
54
- if (names.length <= 1) continue;
55
- if (hash === '') continue;
56
- const sorted = [...names].sort();
57
- const canonical = sorted[0];
58
- for (let i = 1; i < sorted.length; i++) {
59
- const name = sorted[i];
60
- if (aliasOf.get(name) !== canonical) {
61
- aliasOf.set(name, canonical);
62
- added = true;
63
- }
64
- }
65
- }
66
- if (!added) break;
67
- }
44
+ // Compute and publish model aliases so mapTypeRef rewrites references.
45
+ primeModelAliases(models);
68
46
 
69
47
  for (const model of models) {
70
48
  if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
71
49
 
72
- const csClassName = className(model.name);
73
- const canonicalName = aliasOf.get(model.name);
74
-
75
- if (canonicalName) {
76
- // Emit alias as subclass of canonical
77
- const canonicalClass = className(canonicalName);
78
- const lines: string[] = [];
79
- lines.push(`namespace ${ctx.namespacePascal}`);
80
- lines.push('{');
81
- if (model.description) {
82
- const descLines = model.description
83
- .split('\n')
84
- .map((l) => l.trim())
85
- .filter((l) => l);
86
- lines.push(` /// <summary>${escapeXml(descLines[0])}</summary>`);
87
- if (descLines.length > 1) {
88
- lines.push(` /// <remarks>`);
89
- for (const remark of descLines.slice(1)) {
90
- lines.push(` /// ${escapeXml(remark)}`);
91
- }
92
- lines.push(` /// Structurally identical to <see cref="${canonicalClass}"/>.`);
93
- lines.push(` /// </remarks>`);
94
- } else {
95
- lines.push(` /// <remarks>Structurally identical to <see cref="${canonicalClass}"/>.</remarks>`);
96
- }
97
- } else {
98
- const human = humanize(model.name);
99
- lines.push(` /// <summary>Represents ${articleFor(human)} ${human}.</summary>`);
100
- lines.push(` /// <remarks>Structurally identical to <see cref="${canonicalClass}"/>.</remarks>`);
101
- }
102
- lines.push(` public class ${csClassName} : ${canonicalClass} { }`);
103
- lines.push('}');
50
+ const csClassName = modelClassName(model.name);
104
51
 
105
- files.push({
106
- path: `Entities/${csClassName}.cs`,
107
- content: lines.join('\n'),
108
- overwriteExisting: true,
109
- });
110
- continue;
111
- }
52
+ // Skip alias models — all references are already rewritten to the
53
+ // canonical type by mapTypeRef, so the alias class would be dead code.
54
+ if (isModelAlias(model.name)) continue;
112
55
 
113
56
  const lines: string[] = [];
114
- const needsCollections = model.fields.some((f) => {
115
- const csType = mapTypeRef(f.type);
116
- return csType.startsWith('List<') || csType.startsWith('Dictionary<');
117
- });
118
- const needsSystem = model.fields.some((f) => {
119
- const csType = mapTypeRef(f.type);
120
- return csType.includes('DateTimeOffset');
121
- });
57
+ const fieldTypes = model.fields.map((f) => mapTypeRef(f.type));
58
+ const needsCollections = fieldTypes.some((t) => t.startsWith('List<') || t.startsWith('Dictionary<'));
59
+ const needsSystem = fieldTypes.some((t) => t.includes('DateTimeOffset'));
60
+ const needsJsonAttrs = model.fields.some((f) => f.required && isEnumRef(f.type));
122
61
 
123
62
  lines.push(`namespace ${ctx.namespacePascal}`);
124
63
  lines.push('{');
@@ -128,8 +67,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
128
67
  if (needsCollections) {
129
68
  lines.push(' using System.Collections.Generic;');
130
69
  }
131
- lines.push(' using Newtonsoft.Json;');
132
- lines.push(' using STJS = System.Text.Json.Serialization;');
70
+ if (needsJsonAttrs) {
71
+ lines.push(' using Newtonsoft.Json;');
72
+ lines.push(' using STJS = System.Text.Json.Serialization;');
73
+ }
133
74
  lines.push('');
134
75
 
135
76
  // XML doc comment
@@ -305,6 +246,41 @@ function singleValueConstInitializer(ref: TypeRef, enumConstByName: Map<string,
305
246
  return JSON.stringify(wire);
306
247
  }
307
248
 
249
+ /**
250
+ * Compute and publish the model alias map. Safe to call multiple times
251
+ * (idempotent for a given set of models). Must be invoked before any emitter
252
+ * phase that calls `mapTypeRef` with model references.
253
+ */
254
+ export function primeModelAliases(models: Model[]): void {
255
+ const eligibleModels = models.filter((m) => !isListWrapperModel(m) && !isListMetadataModel(m));
256
+ const aliasOf = new Map<string, string>();
257
+ while (true) {
258
+ const hashGroups = new Map<string, string[]>();
259
+ for (const model of eligibleModels) {
260
+ const hash = structuralHash(model, aliasOf);
261
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
262
+ hashGroups.get(hash)!.push(model.name);
263
+ }
264
+
265
+ let added = false;
266
+ for (const [hash, names] of hashGroups) {
267
+ if (names.length <= 1) continue;
268
+ if (hash === '') continue;
269
+ const sorted = [...names].sort();
270
+ const canonical = sorted[0];
271
+ for (let i = 1; i < sorted.length; i++) {
272
+ const name = sorted[i];
273
+ if (aliasOf.get(name) !== canonical) {
274
+ aliasOf.set(name, canonical);
275
+ added = true;
276
+ }
277
+ }
278
+ }
279
+ if (!added) break;
280
+ }
281
+ setModelAliases(aliasOf);
282
+ }
283
+
308
284
  /**
309
285
  * Normalize a TypeRef for structural comparison.
310
286
  * Enum references are normalized to their values (not names) so that
@@ -1,6 +1,6 @@
1
1
  import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
- import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
3
+ import { buildResolvedLookup, lookupMethodName, lookupResolved, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix } from '../shared/naming-utils.js';
5
5
 
6
6
  /** PascalCase class/type name. */
@@ -8,6 +8,17 @@ export function className(name: string): string {
8
8
  return toPascalCase(stripUrnPrefix(name));
9
9
  }
10
10
 
11
+ /** Display name for a model type, including consumer-friendly aliases. */
12
+ export function modelClassName(name: string): string {
13
+ switch (name) {
14
+ case 'EmailChangeConfirmationUser':
15
+ case 'UserlandUser':
16
+ return 'User';
17
+ default:
18
+ return className(name);
19
+ }
20
+ }
21
+
11
22
  /** PascalCase file name (without extension). */
12
23
  export function fileName(name: string): string {
13
24
  return toPascalCase(stripUrnPrefix(name));
@@ -57,15 +68,42 @@ export function resolveServiceDir(resolvedServiceName: string): string {
57
68
  return moduleName(resolvedServiceName);
58
69
  }
59
70
 
60
- /** Resolve the SDK method name for an operation. */
61
- export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
71
+ function trimAsyncSuffix(name: string): string {
72
+ return name.endsWith('Async') ? name.slice(0, -5) : name;
73
+ }
74
+
75
+ /** Append Async once for TAP-style method names. */
76
+ export function appendAsyncSuffix(name: string): string {
77
+ return name.endsWith('Async') ? name : `${name}Async`;
78
+ }
79
+
80
+ /** Resolve the stable method stem for an operation (without any Async suffix). */
81
+ export function resolveMethodStem(op: Operation, _service: Service, ctx: EmitterContext): string {
62
82
  const lookup = buildResolvedLookup(ctx);
63
83
  const resolved = lookupMethodName(op, lookup);
64
- if (resolved) return trimMountedResourceFromMethod(methodName(resolved), resolveClassName(_service, ctx));
84
+ if (resolved) {
85
+ return trimMountedResourceFromMethod(methodName(trimAsyncSuffix(resolved)), resolveClassName(_service, ctx));
86
+ }
65
87
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
66
88
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
67
- if (existing) return trimMountedResourceFromMethod(methodName(existing.methodName), resolveClassName(_service, ctx));
68
- return trimMountedResourceFromMethod(methodName(op.name), resolveClassName(_service, ctx));
89
+ if (existing) {
90
+ return trimMountedResourceFromMethod(
91
+ methodName(trimAsyncSuffix(existing.methodName)),
92
+ resolveClassName(_service, ctx),
93
+ );
94
+ }
95
+ return trimMountedResourceFromMethod(methodName(trimAsyncSuffix(op.name)), resolveClassName(_service, ctx));
96
+ }
97
+
98
+ /** Resolve the SDK method name for an operation. */
99
+ export function resolveMethodName(op: Operation, service: Service, ctx: EmitterContext): string {
100
+ const stem = resolveMethodStem(op, service, ctx);
101
+ const resolved = lookupResolved(op, buildResolvedLookup(ctx));
102
+ if (resolved?.urlBuilder ?? false) {
103
+ return stem;
104
+ }
105
+
106
+ return appendAsyncSuffix(stem);
69
107
  }
70
108
 
71
109
  /** Resolve the SDK class name for a service. */