@workos/oagen-emitters 0.0.1 → 0.2.1

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 (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
package/src/node/utils.ts CHANGED
@@ -1,164 +1,477 @@
1
- import type { Model, Service, TypeRef } from '@workos/oagen';
2
- import { walkTypeRef } from '@workos/oagen';
1
+ import type { Model, EmitterContext, Service, Operation, Field } from '@workos/oagen';
2
+ import { toPascalCase } from '@workos/oagen';
3
+ export {
4
+ collectModelRefs,
5
+ collectEnumRefs,
6
+ assignModelsToServices,
7
+ collectFieldDependencies,
8
+ collectRequestBodyModels,
9
+ } from '@workos/oagen';
10
+ import { mapTypeRef } from './type-map.js';
11
+ import {
12
+ resolveInterfaceName,
13
+ fieldName,
14
+ resolveServiceDir,
15
+ resolveMethodName,
16
+ buildServiceNameMap,
17
+ SERVICE_COVERED_BY,
18
+ } from './naming.js';
19
+ import { assignModelsToServices } from '@workos/oagen';
3
20
 
4
21
  /**
5
- * Collect all model names referenced (directly or transitively) by a TypeRef.
22
+ * Compute a relative import path between two files within the generated SDK.
23
+ * Strips .ts extension from the result.
6
24
  */
7
- export function collectModelRefs(ref: TypeRef): string[] {
8
- const names: string[] = [];
9
- walkTypeRef(ref, { model: (r) => names.push(r.name) });
10
- return names;
25
+ export function relativeImport(fromFile: string, toFile: string): string {
26
+ const fromDir = fromFile.split('/').slice(0, -1);
27
+ const toFileParts = toFile.split('/');
28
+ const toDir = toFileParts.slice(0, -1);
29
+ const toFileName = toFileParts[toFileParts.length - 1];
30
+
31
+ let common = 0;
32
+ while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
33
+ common++;
34
+ }
35
+
36
+ const ups = fromDir.length - common;
37
+ const downs = toDir.slice(common);
38
+ const parts = [...Array(ups).fill('..'), ...downs, toFileName];
39
+ let result = parts.join('/');
40
+ result = result.replace(/\.ts$/, '');
41
+ if (!result.startsWith('.')) result = './' + result;
42
+ return result;
11
43
  }
12
44
 
13
45
  /**
14
- * Collect all enum names referenced by a TypeRef.
46
+ * Render a JSDoc comment block from a description string.
47
+ * Handles multiline descriptions by prefixing each line with ` * `.
48
+ * Returns the lines with the given indent (default 0 spaces).
15
49
  */
16
- export function collectEnumRefs(ref: TypeRef): string[] {
17
- const names: string[] = [];
18
- walkTypeRef(ref, { enum: (r) => names.push(r.name) });
19
- return names;
50
+ export function docComment(description: string, indent = 0): string[] {
51
+ const pad = ' '.repeat(indent);
52
+ const descLines = description.split('\n');
53
+ if (descLines.length === 1) {
54
+ return [`${pad}/** ${descLines[0]} */`];
55
+ }
56
+ const lines: string[] = [`${pad}/**`];
57
+ for (const line of descLines) {
58
+ lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
59
+ }
60
+ lines.push(`${pad} */`);
61
+ return lines;
20
62
  }
21
63
 
22
64
  /**
23
- * Assign each model to the service that first references it.
24
- * Models referenced by multiple services are assigned to the first.
25
- * Models not referenced by any service are unassigned (returned as undefined).
65
+ * Build a map from model name default type args string for generic models.
66
+ * E.g., Profile<CustomAttributesType = Record<string, unknown>>
67
+ * Map { 'Profile' '<Record<string, unknown>>' }
68
+ *
69
+ * Non-generic models are not included in the map.
26
70
  */
27
- export function assignModelsToServices(models: Model[], services: Service[]): Map<string, string> {
28
- const modelToService = new Map<string, string>();
29
- const modelNames = new Set(models.map((m) => m.name));
30
-
31
- for (const service of services) {
32
- const referencedModels = new Set<string>();
71
+ export function buildGenericModelDefaults(models: Model[]): Map<string, string> {
72
+ const result = new Map<string, string>();
73
+ for (const model of models) {
74
+ if (!model.typeParams?.length) continue;
75
+ const defaults = model.typeParams.map((tp) => (tp.default ? mapTypeRef(tp.default) : 'unknown'));
76
+ result.set(model.name, `<${defaults.join(', ')}>`);
77
+ }
78
+ return result;
79
+ }
33
80
 
34
- // Collect directly referenced models from all operations
35
- for (const op of service.operations) {
36
- if (op.requestBody) {
37
- for (const name of collectModelRefs(op.requestBody)) {
38
- referencedModels.add(name);
39
- }
40
- }
41
- for (const name of collectModelRefs(op.response)) {
42
- referencedModels.add(name);
43
- }
44
- for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
45
- for (const name of collectModelRefs(param.type)) {
46
- referencedModels.add(name);
47
- }
48
- }
49
- if (op.pagination) {
50
- for (const name of collectModelRefs(op.pagination.itemType)) {
51
- referencedModels.add(name);
52
- }
53
- }
81
+ /**
82
+ * Remove unused imports from generated source code.
83
+ * Scans the non-import body for each imported identifier and drops
84
+ * individual names that are never referenced. Removes entire import
85
+ * statements when no names are used.
86
+ */
87
+ export function pruneUnusedImports(lines: string[]): string[] {
88
+ // Split lines into imports and body
89
+ const importLines: string[] = [];
90
+ const bodyLines: string[] = [];
91
+ let inBody = false;
92
+ for (const line of lines) {
93
+ if (!inBody && (line.startsWith('import ') || line === '')) {
94
+ importLines.push(line);
95
+ } else {
96
+ inBody = true;
97
+ bodyLines.push(line);
54
98
  }
99
+ }
100
+
101
+ const body = bodyLines.join('\n');
102
+ const kept: string[] = [];
55
103
 
56
- // Transitively collect models referenced by the directly-referenced models
57
- const toVisit = [...referencedModels];
58
- while (toVisit.length > 0) {
59
- const name = toVisit.pop()!;
60
- const model = models.find((m) => m.name === name);
61
- if (!model) continue;
62
- for (const field of model.fields) {
63
- for (const ref of collectModelRefs(field.type)) {
64
- if (!referencedModels.has(ref) && modelNames.has(ref)) {
65
- referencedModels.add(ref);
66
- toVisit.push(ref);
67
- }
68
- }
104
+ for (const line of importLines) {
105
+ if (line === '') {
106
+ kept.push(line);
107
+ continue;
108
+ }
109
+ // Extract imported names from the import statement
110
+ const match = line.match(/\{([^}]+)\}/);
111
+ if (!match) {
112
+ // Non-destructured import (e.g., import X from '...') — keep
113
+ kept.push(line);
114
+ continue;
115
+ }
116
+ const names = match[1]
117
+ .split(',')
118
+ .map((n) => n.trim())
119
+ .filter(Boolean);
120
+ // Filter to only names that appear in the body
121
+ const usedNames = names.filter((name) => {
122
+ const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
123
+ return re.test(body);
124
+ });
125
+ if (usedNames.length === 0) {
126
+ // No names used — drop entire import
127
+ continue;
128
+ }
129
+ if (usedNames.length === names.length) {
130
+ // All names used — keep original line
131
+ kept.push(line);
132
+ } else {
133
+ // Some names unused — reconstruct import with only used names
134
+ const isTypeImport = line.startsWith('import type');
135
+ const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
136
+ if (fromMatch) {
137
+ const prefix = isTypeImport ? 'import type' : 'import';
138
+ kept.push(`${prefix} { ${usedNames.join(', ')} } from '${fromMatch[1]}';`);
139
+ } else {
140
+ kept.push(line);
69
141
  }
70
142
  }
143
+ }
144
+
145
+ return [...kept, ...bodyLines];
146
+ }
71
147
 
72
- // Assign models to this service (first-come)
73
- for (const name of referencedModels) {
74
- if (!modelToService.has(name)) {
75
- modelToService.set(name, service.name);
148
+ /** Built-in TypeScript types that are always available (no import needed). */
149
+ export const TS_BUILTINS = new Set([
150
+ 'Record',
151
+ 'Promise',
152
+ 'Array',
153
+ 'Map',
154
+ 'Set',
155
+ 'Date',
156
+ 'string',
157
+ 'number',
158
+ 'boolean',
159
+ 'void',
160
+ 'null',
161
+ 'undefined',
162
+ 'any',
163
+ 'never',
164
+ 'unknown',
165
+ 'true',
166
+ 'false',
167
+ ]);
168
+
169
+ /**
170
+ * Detect whether the existing SDK uses string (ISO 8601) representation for
171
+ * date-time fields. Checks if any baseline interface has a date-time IR field
172
+ * typed as plain `string` (not `Date`).
173
+ */
174
+ export function detectStringDateConvention(models: Model[], ctx: EmitterContext): boolean {
175
+ if (!ctx.apiSurface?.interfaces) return false;
176
+ for (const model of models) {
177
+ const domainName = resolveInterfaceName(model.name, ctx);
178
+ const baseline = ctx.apiSurface.interfaces[domainName];
179
+ if (!baseline?.fields) continue;
180
+ for (const field of model.fields) {
181
+ if (field.type.kind !== 'primitive' || field.type.format !== 'date-time') continue;
182
+ const baselineField = baseline.fields[fieldName(field.name)];
183
+ if (baselineField && !baselineField.type.includes('Date')) {
184
+ return true;
76
185
  }
77
186
  }
78
187
  }
188
+ return false;
189
+ }
79
190
 
80
- return modelToService;
191
+ /**
192
+ * Build a comprehensive set of all known type names from the IR and baseline.
193
+ * Used to identify type parameters by elimination — any PascalCase name not in
194
+ * this set is likely a generic type parameter.
195
+ */
196
+ export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<string> {
197
+ const knownNames = new Set<string>();
198
+ for (const m of models) knownNames.add(resolveInterfaceName(m.name, ctx));
199
+ for (const e of ctx.spec.enums) knownNames.add(e.name);
200
+ if (ctx.apiSurface?.interfaces) {
201
+ for (const name of Object.keys(ctx.apiSurface.interfaces)) knownNames.add(name);
202
+ }
203
+ if (ctx.apiSurface?.typeAliases) {
204
+ for (const name of Object.keys(ctx.apiSurface.typeAliases)) knownNames.add(name);
205
+ }
206
+ if (ctx.apiSurface?.enums) {
207
+ for (const name of Object.keys(ctx.apiSurface.enums)) knownNames.add(name);
208
+ }
209
+ return knownNames;
81
210
  }
82
211
 
83
212
  /**
84
- * Collect all TypeRef-referenced model and enum names from a model's fields.
85
- * Returns { models, enums } sets for generating import statements.
213
+ * Create a service directory resolver bundle.
214
+ * Encapsulates the common pattern of mapping models to services and resolving
215
+ * the output directory for a given IR service name.
86
216
  */
87
- export function collectFieldDependencies(model: Model): {
88
- models: Set<string>;
89
- enums: Set<string>;
217
+ export function createServiceDirResolver(
218
+ models: Model[],
219
+ services: Service[],
220
+ ctx: EmitterContext,
221
+ ): {
222
+ modelToService: Map<string, string>;
223
+ serviceNameMap: Map<string, string>;
224
+ resolveDir: (irService: string | undefined) => string;
90
225
  } {
91
- const models = new Set<string>();
92
- const enums = new Set<string>();
226
+ const modelToService = assignModelsToServices(models, services);
227
+ const serviceNameMap = buildServiceNameMap(services, ctx);
228
+ const resolveDir = (irService: string | undefined) =>
229
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
230
+ return { modelToService, serviceNameMap, resolveDir };
231
+ }
93
232
 
94
- for (const field of model.fields) {
95
- for (const name of collectModelRefs(field.type)) {
96
- if (name !== model.name) models.add(name);
97
- }
98
- for (const name of collectEnumRefs(field.type)) {
99
- enums.add(name);
233
+ /**
234
+ * Check if a set of baseline interface fields appears to contain generic type
235
+ * parameters PascalCase names that aren't known models, enums, or builtins.
236
+ */
237
+ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: Set<string>): boolean {
238
+ for (const [, bf] of Object.entries(fields)) {
239
+ const fieldType = (bf as { type: string }).type;
240
+ const typeNames = fieldType.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
241
+ if (!typeNames) continue;
242
+ for (const tn of typeNames) {
243
+ if (TS_BUILTINS.has(tn)) continue;
244
+ if (knownNames.has(tn)) continue;
245
+ return true;
100
246
  }
101
247
  }
248
+ return false;
249
+ }
250
+
251
+ /**
252
+ * Detect whether a model matches the standard list-metadata shape:
253
+ * exactly 2 fields named `before` and `after`, both nullable string.
254
+ *
255
+ * These models are redundant because the SDK already has a shared
256
+ * `ListMetadata` type in `src/common/utils/pagination.ts`.
257
+ */
258
+ export function isListMetadataModel(model: Model): boolean {
259
+ if (model.fields.length !== 2) return false;
102
260
 
103
- return { models, enums };
261
+ const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
262
+ const before = fieldsByName.get('before');
263
+ const after = fieldsByName.get('after');
264
+
265
+ if (!before || !after) return false;
266
+
267
+ return isNullableString(before) && isNullableString(after);
104
268
  }
105
269
 
106
270
  /**
107
- * Compute a relative import path between two files within the generated SDK.
108
- * Strips .ts extension from the result.
271
+ * Detect whether a model is a list wrapper the standard paginated
272
+ * list envelope with `data` (array), `list_metadata`, and `object: 'list'`.
273
+ *
274
+ * These models are redundant because the SDK already has `List<T>` and
275
+ * `ListResponse<T>` in `src/common/utils/pagination.ts`, and the shared
276
+ * `deserializeList` handles deserialization.
109
277
  */
110
- export function relativeImport(fromFile: string, toFile: string): string {
111
- const fromDir = fromFile.split('/').slice(0, -1);
112
- const toFileParts = toFile.split('/');
113
- const toDir = toFileParts.slice(0, -1);
114
- const toFileName = toFileParts[toFileParts.length - 1];
278
+ export function isListWrapperModel(model: Model): boolean {
279
+ const fieldsByName = new Map(model.fields.map((f) => [f.name, f]));
115
280
 
116
- let common = 0;
117
- while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
118
- common++;
281
+ // Must have a `data` field that is an array type
282
+ const dataField = fieldsByName.get('data');
283
+ if (!dataField) return false;
284
+ if (dataField.type.kind !== 'array') return false;
285
+
286
+ // Must have a `list_metadata` field (the IR uses snake_case names)
287
+ const listMetadataField = fieldsByName.get('list_metadata');
288
+ if (!listMetadataField) return false;
289
+
290
+ // Optionally has an `object` field with literal value 'list'
291
+ const objectField = fieldsByName.get('object');
292
+ if (objectField) {
293
+ if (objectField.type.kind !== 'literal' || objectField.type.value !== 'list') {
294
+ return false;
295
+ }
119
296
  }
120
297
 
121
- const ups = fromDir.length - common;
122
- const downs = toDir.slice(common);
123
- const parts = [...Array(ups).fill('..'), ...downs, toFileName];
124
- let result = parts.join('/');
125
- result = result.replace(/\.ts$/, '');
126
- if (!result.startsWith('.')) result = './' + result;
127
- return result;
298
+ return true;
299
+ }
300
+
301
+ /** Check if a field type is nullable string (nullable<string> or just string). */
302
+ function isNullableString(field: Field): boolean {
303
+ const { type } = field;
304
+ if (type.kind === 'nullable') {
305
+ return type.inner.kind === 'primitive' && type.inner.type === 'string';
306
+ }
307
+ if (type.kind === 'primitive') {
308
+ return type.type === 'string';
309
+ }
310
+ return false;
128
311
  }
129
312
 
130
313
  /**
131
- * Render a JSDoc comment block from a description string.
132
- * Handles multiline descriptions by prefixing each line with ` * `.
133
- * Returns the lines with the given indent (default 0 spaces).
314
+ * Compute a structural fingerprint for a model based on its fields.
315
+ * Two models with identical fingerprints are structurally equivalent.
134
316
  */
135
- export function docComment(description: string, indent = 0): string[] {
136
- const pad = ' '.repeat(indent);
137
- const descLines = description.split('\n');
138
- if (descLines.length === 1) {
139
- return [`${pad}/** ${descLines[0]} */`];
317
+ function modelFingerprint(model: Model): string {
318
+ const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
319
+ return fields.join('|');
320
+ }
321
+
322
+ /**
323
+ * Find structurally identical models and build a deduplication map.
324
+ * Also deduplicates models that resolve to the same interface name across
325
+ * services — when a `$ref` schema is used by multiple tags, the IR may
326
+ * produce per-tag copies that diverge slightly. The version with the most
327
+ * fields is chosen as canonical.
328
+ *
329
+ * Returns a Map from duplicate model name → canonical model name.
330
+ */
331
+ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Map<string, string> {
332
+ const dedup = new Map<string, string>();
333
+
334
+ // Pass 1: structural fingerprint dedup (exact match)
335
+ const fingerprints = new Map<string, string>();
336
+ for (const model of models) {
337
+ if (model.fields.length === 0) continue;
338
+ const fp = modelFingerprint(model);
339
+ const existing = fingerprints.get(fp);
340
+ if (existing) {
341
+ dedup.set(model.name, existing);
342
+ } else {
343
+ fingerprints.set(fp, model.name);
344
+ }
140
345
  }
141
- const lines: string[] = [`${pad}/**`];
142
- for (const line of descLines) {
143
- lines.push(line === '' ? `${pad} *` : `${pad} * ${line}`);
346
+
347
+ // Pass 2: name-based dedup for models that resolve to the same interface
348
+ // name across services. Only applies when context with name resolution is
349
+ // available. Picks the model with the most fields as canonical.
350
+ if (ctx) {
351
+ const byDomainName = new Map<string, Model[]>();
352
+ for (const model of models) {
353
+ if (model.fields.length === 0) continue;
354
+ if (dedup.has(model.name)) continue; // already deduped in pass 1
355
+ const domainName = resolveInterfaceName(model.name, ctx);
356
+ const group = byDomainName.get(domainName);
357
+ if (group) {
358
+ group.push(model);
359
+ } else {
360
+ byDomainName.set(domainName, [model]);
361
+ }
362
+ }
363
+ for (const [, group] of byDomainName) {
364
+ if (group.length < 2) continue;
365
+ // Choose canonical: most fields, then alphabetically by name
366
+ group.sort((a, b) => b.fields.length - a.fields.length || a.name.localeCompare(b.name));
367
+ const canonical = group[0];
368
+ for (let i = 1; i < group.length; i++) {
369
+ dedup.set(group[i].name, canonical.name);
370
+ }
371
+ }
144
372
  }
145
- lines.push(`${pad} */`);
146
- return lines;
373
+
374
+ return dedup;
147
375
  }
148
376
 
149
377
  /**
150
- * Collect all model names referenced as request bodies across all services.
378
+ * Check whether a service's endpoints are already fully covered by existing
379
+ * hand-written service classes.
380
+ *
381
+ * A service is considered "covered" when:
382
+ * 1. **Every** operation in it appears in `overlayLookup.methodByOperation`
383
+ * 2. The overlay maps those operations to a class that exists in the baseline
384
+ * `apiSurface` (confirming the hand-written class is actually present)
385
+ *
386
+ * Services with zero operations are never considered covered (nothing to
387
+ * deduplicate). When no `apiSurface` is available, the overlay alone is
388
+ * used as the coverage signal (the overlay is only built from existing code).
389
+ *
390
+ * This prevents the emitter from generating resource classes like `Connections`
391
+ * that would duplicate hand-written modules like `SSO` for the same API
392
+ * endpoints (e.g., `GET /connections`).
151
393
  */
152
- export function collectRequestBodyModels(services: Service[]): Set<string> {
153
- const result = new Set<string>();
154
- for (const service of services) {
394
+ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
395
+ // Explicit override: services known to be covered by existing hand-written classes
396
+ if (SERVICE_COVERED_BY[toPascalCase(service.name)]) return true;
397
+
398
+ const overlay = ctx.overlayLookup?.methodByOperation;
399
+ if (!overlay || overlay.size === 0) return false;
400
+ if (service.operations.length === 0) return false;
401
+
402
+ // Collect the set of existing class names from the baseline surface.
403
+ // When no apiSurface is available, the overlay alone cannot confirm that
404
+ // a hand-written class exists — it may only carry naming hints.
405
+ const baselineClasses = ctx.apiSurface?.classes;
406
+ if (!baselineClasses) return false;
407
+ const existingClassNames = new Set(Object.keys(baselineClasses));
408
+
409
+ // Check that every operation is in the overlay AND the overlay's target class
410
+ // exists in the baseline.
411
+ return service.operations.every((op: Operation) => {
412
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
413
+ const match = overlay.get(httpKey);
414
+ if (!match) return false;
415
+ return existingClassNames.has(match.className);
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Check whether a fully-covered service has operations whose overlay-mapped
421
+ * methods are missing from the baseline class. Returns true when at least
422
+ * one operation maps to a method name that the baseline class does not have,
423
+ * meaning the merger needs to add new methods (skipIfExists must be removed).
424
+ */
425
+ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
426
+ const baselineClasses = ctx.apiSurface?.classes;
427
+ if (!baselineClasses) return false;
428
+
429
+ // For services explicitly mapped to an existing class via SERVICE_COVERED_BY,
430
+ // check each operation's resolved method name against the target class directly.
431
+ // This avoids the overlay gap where new endpoints are silently skipped.
432
+ const targetClassName = SERVICE_COVERED_BY[toPascalCase(service.name)];
433
+ if (targetClassName) {
434
+ const cls = baselineClasses[targetClassName];
435
+ if (!cls) return true; // Target class missing from baseline — treat as absent
155
436
  for (const op of service.operations) {
156
- if (op.requestBody) {
157
- for (const name of collectModelRefs(op.requestBody)) {
158
- result.add(name);
159
- }
160
- }
437
+ const method = resolveMethodName(op, service, ctx);
438
+ if (!cls.methods?.[method]) return true;
161
439
  }
440
+ return false;
162
441
  }
163
- return result;
442
+
443
+ // Default overlay-based detection
444
+ const overlay = ctx.overlayLookup?.methodByOperation;
445
+ if (!overlay) return false;
446
+
447
+ for (const op of service.operations) {
448
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
449
+ const match = overlay.get(httpKey);
450
+ if (!match) continue;
451
+ const cls = baselineClasses[match.className];
452
+ if (!cls) continue;
453
+ if (!cls.methods?.[match.methodName]) return true;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ /**
459
+ * Return operations in a service that are NOT covered by existing hand-written
460
+ * service classes. For fully uncovered services, returns all operations.
461
+ * For partially covered services, returns only the uncovered operations.
462
+ */
463
+ export function uncoveredOperations(service: Service, ctx: EmitterContext): Operation[] {
464
+ const overlay = ctx.overlayLookup?.methodByOperation;
465
+ if (!overlay || overlay.size === 0) return service.operations;
466
+
467
+ const baselineClasses = ctx.apiSurface?.classes;
468
+ if (!baselineClasses) return service.operations;
469
+ const existingClassNames = new Set(Object.keys(baselineClasses));
470
+
471
+ return service.operations.filter((op: Operation) => {
472
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
473
+ const match = overlay.get(httpKey);
474
+ if (!match) return true; // Not in overlay → uncovered
475
+ return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
476
+ });
164
477
  }