@workos/oagen-emitters 0.18.2 → 0.18.4

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 (52) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-bqfwowQ3.mjs → plugin-Cciic50q.mjs} +457 -101
  6. package/dist/plugin-Cciic50q.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +3 -3
  10. package/src/dotnet/fixtures.ts +17 -3
  11. package/src/dotnet/index.ts +2 -1
  12. package/src/dotnet/models.ts +30 -5
  13. package/src/dotnet/naming.ts +10 -0
  14. package/src/dotnet/tests.ts +5 -1
  15. package/src/go/fixtures.ts +4 -2
  16. package/src/go/index.ts +4 -0
  17. package/src/go/models.ts +4 -2
  18. package/src/go/naming.ts +10 -0
  19. package/src/go/resources.ts +19 -6
  20. package/src/kotlin/index.ts +2 -1
  21. package/src/kotlin/models.ts +5 -2
  22. package/src/kotlin/naming.ts +11 -0
  23. package/src/kotlin/tests.ts +5 -1
  24. package/src/node/field-plan.ts +3 -3
  25. package/src/node/index.ts +2 -1
  26. package/src/node/models.ts +40 -1
  27. package/src/node/naming.ts +10 -0
  28. package/src/node/options.ts +45 -1
  29. package/src/node/resources.ts +76 -19
  30. package/src/node/tests.ts +296 -30
  31. package/src/php/index.ts +2 -1
  32. package/src/php/models.ts +11 -5
  33. package/src/php/naming.ts +10 -0
  34. package/src/php/tests.ts +11 -2
  35. package/src/python/fixtures.ts +4 -3
  36. package/src/python/index.ts +2 -1
  37. package/src/python/models.ts +12 -6
  38. package/src/python/naming.ts +10 -0
  39. package/src/python/tests.ts +11 -6
  40. package/src/ruby/index.ts +2 -1
  41. package/src/ruby/models.ts +10 -7
  42. package/src/ruby/naming.ts +10 -0
  43. package/src/ruby/rbi.ts +3 -1
  44. package/src/ruby/tests.ts +4 -1
  45. package/src/rust/index.ts +2 -1
  46. package/src/rust/models.ts +87 -15
  47. package/src/rust/naming.ts +10 -0
  48. package/src/rust/resources.ts +6 -2
  49. package/src/shared/file-header.ts +13 -0
  50. package/test/node/resources.test.ts +31 -2
  51. package/test/rust/models.test.ts +49 -0
  52. package/dist/plugin-bqfwowQ3.mjs.map +0 -1
@@ -11,7 +11,7 @@ import type {
11
11
  Model,
12
12
  ResolvedOperation,
13
13
  } from '@workos/oagen';
14
- import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
14
+ import { toPascalCase, toCamelCase } from '@workos/oagen';
15
15
  import type { OperationPlan } from '@workos/oagen';
16
16
  import { mapTypeRef, isInlineEnum } from './type-map.js';
17
17
  import {
@@ -84,7 +84,7 @@ import {
84
84
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
85
85
  import { buildNodePathExpression } from './path-expression.js';
86
86
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
87
- import { isNodeOwnedService, nodeOptions } from './options.js';
87
+ import { isNodeOwnedService, operationOverrideFor, planOperationFor } from './options.js';
88
88
 
89
89
  /**
90
90
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -202,16 +202,12 @@ function existingInterfaceBarrelExports(ctx: EmitterContext, serviceDir: string,
202
202
  return new RegExp(`export\\s+(?:type\\s+)?(?:\\*|\\{[^}]+\\})\\s+from\\s+['"]\\./${escapedStem}['"]`).test(content);
203
203
  }
204
204
 
205
- function operationOverrideFor(ctx: EmitterContext, op: Operation) {
206
- return nodeOptions(ctx).operationOverrides?.[`${op.httpMethod.toUpperCase()} ${op.path}`];
207
- }
208
-
209
205
  function baselineMethodFor(service: Service, method: string, ctx: EmitterContext): BaselineMethod | undefined {
210
206
  const serviceClass = resolveResourceClassName(service, ctx);
211
207
  return ctx.apiSurface?.classes?.[serviceClass]?.methods?.[method]?.[0] as BaselineMethod | undefined;
212
208
  }
213
209
 
214
- function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
210
+ export function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string): Set<string> {
215
211
  const root = ctx.outputDir ?? ctx.targetDir;
216
212
  if (!root) return new Set();
217
213
 
@@ -242,8 +238,13 @@ function optionsObjectParam(method: BaselineMethod | undefined): OptionsObjectPa
242
238
  const [param] = method.params;
243
239
  if (param.name !== 'options') return undefined;
244
240
  if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
245
- if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
246
- return { name: 'options', type: param.type, optional: param.optional === true, generated: false };
241
+ // An optional param's surface type is `Options | undefined`; the optionality
242
+ // is carried by `param.optional`, so strip the nullable arm to recover the
243
+ // bare type NAME (used for `serialize${type}` + imports). Leaving it in emits
244
+ // `serializeOptions | undefined` — a syntax error.
245
+ const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
246
+ if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
247
+ return { name: 'options', type, optional: param.optional === true, generated: false };
247
248
  }
248
249
 
249
250
  function methodOptionsName(method: string, resolvedServiceName: string): string {
@@ -279,6 +280,24 @@ function operationHasOptionsInput(op: Operation, plan: OperationPlan, resolvedOp
279
280
  );
280
281
  }
281
282
 
283
+ // True only when the type is a SINGLE closed object literal (`{ ... }`), not
284
+ // the head of a compound type such as `{ ... } & X` or `{ ... } | Y`. Counting
285
+ // brace depth locates the literal's matching close brace (handling nesting like
286
+ // `{ a: { b: string } }`); any non-whitespace after it means the type is not a
287
+ // pure literal and must be preserved verbatim rather than replaced by a named
288
+ // request interface.
289
+ function isClosedObjectLiteral(type: string): boolean {
290
+ const t = type.trim();
291
+ if (!t.startsWith('{')) return false;
292
+ let depth = 0;
293
+ for (let i = 0; i < t.length; i++) {
294
+ const ch = t[i];
295
+ if (ch === '{') depth++;
296
+ else if (ch === '}' && --depth === 0) return i === t.length - 1;
297
+ }
298
+ return false;
299
+ }
300
+
282
301
  function optionsObjectInfo(
283
302
  service: Service,
284
303
  method: string,
@@ -300,9 +319,10 @@ function optionsObjectInfo(
300
319
  // operation owns a named request-body model, adopt that interface so the
301
320
  // method signature, the serializer, and the request model all agree.
302
321
  // Named baseline types (`CreateOrganizationApiKeyOptions`) and compound
303
- // intersections (`X & { ... }`) are still preserved verbatim.
322
+ // intersections (`X & { ... }` or `{ ... } & X`) are still preserved
323
+ // verbatim — only a single, closed object literal is eligible for adoption.
304
324
  if (
305
- baseline.type.trimStart().startsWith('{') &&
325
+ isClosedObjectLiteral(baseline.type) &&
306
326
  isNodeOwnedService(ctx, service.name, resolveResourceClassName(service, ctx))
307
327
  ) {
308
328
  const body = extractRequestBodyType(op, ctx);
@@ -549,7 +569,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
549
569
 
550
570
  const plans = service.operations.map((op) => ({
551
571
  op,
552
- plan: planOperation(op),
572
+ plan: planOperationFor(op, ctx),
553
573
  method: resolveMethodName(op, service, ctx),
554
574
  }));
555
575
 
@@ -558,7 +578,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
558
578
  const baselineMethod = baselineMethodFor(service, method, ctx);
559
579
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
560
580
  if (!optionInfo?.generated) continue;
561
- if (baselineTypeSourceFile(ctx, optionInfo.type)) continue;
581
+ // A baseline type of the same name normally means "hand-owned / preserved —
582
+ // do not regenerate" (guards the alias-feedback loop). But when the op has
583
+ // path params, the modernized resource folds them INTO the options object
584
+ // (`const { organizationId, ...payload } = options`), so a body-only
585
+ // baseline interface (from a legacy `(pathParam, options)` signature) is
586
+ // definitionally incompatible. Regenerate it to include the path params.
587
+ if (op.pathParams.length === 0 && baselineTypeSourceFile(ctx, optionInfo.type)) continue;
562
588
 
563
589
  const optionsName = optionInfo.type;
564
590
  const optionFileStem = `${fileName(optionsName)}.interface`;
@@ -626,9 +652,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
626
652
  headerParts.push(` ${name}${opt}: ${type};`);
627
653
  };
628
654
 
655
+ const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
629
656
  for (const param of op.pathParams) {
630
657
  pushField(
631
- fieldName(param.name),
658
+ optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
632
659
  true,
633
660
  mapParamType(param.type, specEnumNames),
634
661
  param.description,
@@ -760,7 +787,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
760
787
 
761
788
  let plans = service.operations.map((op) => ({
762
789
  op,
763
- plan: planOperation(op),
790
+ plan: planOperationFor(op, ctx),
764
791
  method: resolveMethodName(op, service, ctx),
765
792
  }));
766
793
 
@@ -1019,6 +1046,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
1019
1046
  }
1020
1047
 
1021
1048
  const importedTypeNames = new Set<string>();
1049
+ // `PaginationOptions` is already imported once above when any method
1050
+ // paginates; a method whose options type IS `PaginationOptions` would
1051
+ // otherwise re-import it here (TS2300 duplicate identifier).
1052
+ if (needsPaginationOptionsImport) importedTypeNames.add('PaginationOptions');
1022
1053
  for (const optionType of optionObjectTypes) {
1023
1054
  if (isValidTypeIdentifier(optionType)) {
1024
1055
  if (importedTypeNames.has(optionType)) continue;
@@ -1935,7 +1966,20 @@ function renderOptionsObjectMethod(
1935
1966
  return true;
1936
1967
  }
1937
1968
 
1938
- return false;
1969
+ // Body-less, response-less mutation with path params and/or query folded into
1970
+ // the options object (e.g. POST /feature-flags/{slug}/targets/{targetId} -> 204).
1971
+ // The DELETE equivalent is handled above; this covers POST/PUT/PATCH (and any
1972
+ // other verb) that returns no content. Without this branch such operations
1973
+ // fall through to the positional renderVoidMethod, which is inconsistent with
1974
+ // the options-object surface the rest of an owned service uses.
1975
+ {
1976
+ lines.push(` async ${method}(${renderOptionsParam(optionParam)}): Promise<void> {`);
1977
+ renderOptionsObjectDestructure(lines, pathBindings);
1978
+ const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
1979
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}${emptyBodyArg}${queryOptionsArg});`);
1980
+ lines.push(' }');
1981
+ return true;
1982
+ }
1939
1983
  }
1940
1984
 
1941
1985
  function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
@@ -1969,7 +2013,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
1969
2013
  // Return resolved SDK field names directly — the URL template uses these
1970
2014
  // names too (via the param-name map threaded into buildNodePathExpression),
1971
2015
  // so the destructure no longer needs `optionField: localName` renames.
1972
- return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx));
2016
+ const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
2017
+ return op.pathParams.map((param) => resolveOptionsObjectField(fieldName(param.name), optionType, ctx, pathFieldMap));
1973
2018
  }
1974
2019
 
1975
2020
  /**
@@ -1981,15 +2026,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
1981
2026
  */
1982
2027
  function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
1983
2028
  const map = new Map<string, string>();
2029
+ const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
1984
2030
  for (const param of op.pathParams) {
1985
2031
  const localName = fieldName(param.name);
1986
- const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
2032
+ const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
1987
2033
  if (sdkField !== localName) map.set(param.name, sdkField);
1988
2034
  }
1989
2035
  return map;
1990
2036
  }
1991
2037
 
1992
- function resolveOptionsObjectField(localName: string, optionType: string, ctx: EmitterContext): string {
2038
+ function resolveOptionsObjectField(
2039
+ localName: string,
2040
+ optionType: string,
2041
+ ctx: EmitterContext,
2042
+ pathFieldMap?: Record<string, string>,
2043
+ ): string {
2044
+ // Operation-override rename (Node-scoped) wins unconditionally: an explicit
2045
+ // pathFieldMap is honored even when the (freshly generated) options interface
2046
+ // isn't in the baseline surface yet — generateOptionsInterfaces applies the
2047
+ // same map to the emitted field, so destructure and interface stay in lockstep.
2048
+ const mapped = pathFieldMap?.[localName];
2049
+ if (mapped) return mapped;
1993
2050
  const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
1994
2051
  if (!fields) return localName;
1995
2052
  if (fields[localName]) return localName;
package/src/node/tests.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
10
  EmitterContext,
11
11
  GeneratedFile,
12
12
  } from '@workos/oagen';
13
- import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
13
+ import { toCamelCase, toPascalCase } from '@workos/oagen';
14
14
  import { unwrapListModel, ID_PREFIXES } from './fixtures.js';
15
15
  import {
16
16
  fieldName,
@@ -24,7 +24,7 @@ import {
24
24
  wireInterfaceName,
25
25
  } from './naming.js';
26
26
  import { generateFixtures } from './fixtures.js';
27
- import { resolveResourceClassName, resolveResourceDir } from './resources.js';
27
+ import { resolveResourceClassName, resolveResourceDir, ignoredResourceMethodNames } from './resources.js';
28
28
  import {
29
29
  assignModelsToServices,
30
30
  createServiceDirResolver,
@@ -35,8 +35,8 @@ import {
35
35
  modelHasNewFields,
36
36
  computeNonEventReachable,
37
37
  } from './utils.js';
38
- import { groupByMount } from '../shared/resolved-ops.js';
39
- import { isNodeOwnedService, nodeOptions } from './options.js';
38
+ import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
39
+ import { isNodeOwnedService, nodeOptions, planOperationFor } from './options.js';
40
40
 
41
41
  type BaselineMethod = {
42
42
  params: Array<{ name: string; type: string; optional?: boolean; passingStyle?: string }>;
@@ -57,8 +57,11 @@ function optionsObjectParam(method: BaselineMethod | undefined): { name: string;
57
57
  const [param] = method.params;
58
58
  if (param.name !== 'options') return undefined;
59
59
  if (param.passingStyle && param.passingStyle !== 'options_object') return undefined;
60
- if (!param.type || /^(Record|object|any|unknown)\b/.test(param.type)) return undefined;
61
- return { name: param.name, type: param.type };
60
+ // Strip the `| undefined` arm of an optional param's surface type so the
61
+ // bare type name is used (mirrors resources.ts optionsObjectParam).
62
+ const type = param.type?.replace(/(?:\s*\|\s*(?:undefined|null))+\s*$/, '').trim();
63
+ if (!type || /^(Record|object|any|unknown)\b/.test(type)) return undefined;
64
+ return { name: param.name, type };
62
65
  }
63
66
 
64
67
  function configuredOptionsMethod(ctx: EmitterContext, op: Operation): BaselineMethod | undefined {
@@ -161,6 +164,17 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
161
164
  const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
162
165
  const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
163
166
  if (ops.length === 0) continue;
167
+ // Methods hand-owned inside an `@oagen-ignore` region of the resource
168
+ // file: resources.ts skips generating those methods, so a generated test
169
+ // would reference a shape that no longer matches the preserved hand-written
170
+ // signature (e.g. SSO's `getAuthorizationUrl` returns a string, not
171
+ // `{ url }`). Their `describe` blocks are skipped below, but the ops are
172
+ // still passed through so entity-assertion helpers their preserved
173
+ // hand-written test blocks rely on continue to be emitted.
174
+ const ignoredMethodNames = ignoredResourceMethodNames(
175
+ ctx,
176
+ `src/${resolveResourceDir(mergedService, ctx)}/${fileName(resolveResourceClassName(mergedService, ctx))}.ts`,
177
+ );
164
178
 
165
179
  // Skip tests for services without a WorkOS property in the baseline
166
180
  const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
@@ -176,7 +190,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
176
190
  if (resourceClass !== mountName) continue;
177
191
 
178
192
  const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
179
- files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
193
+ files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors, ignoredMethodNames));
180
194
  }
181
195
 
182
196
  // Generate serializer round-trip tests
@@ -194,6 +208,7 @@ function generateServiceTest(
194
208
  ctx: EmitterContext,
195
209
  modelMap: Map<string, Model>,
196
210
  mountAccessors?: Map<string, string>,
211
+ ignoredMethodNames: Set<string> = new Set(),
197
212
  ): GeneratedFile {
198
213
  const resolvedName = resolveResourceClassName(service, ctx);
199
214
  const serviceDir = resolveResourceDir(service, ctx);
@@ -203,11 +218,31 @@ function generateServiceTest(
203
218
  const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
204
219
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
205
220
 
206
- const plans = service.operations.map((op) => ({
221
+ const allPlans = service.operations.map((op) => ({
207
222
  op,
208
- plan: planOperation(op),
223
+ plan: planOperationFor(op, ctx),
209
224
  method: resolveMethodName(op, service, ctx),
210
225
  }));
226
+ // `plans` drives everything that becomes generated text (fixture imports,
227
+ // test-util imports, `describe` blocks): exclude hand-owned methods so we
228
+ // don't emit broken tests against their preserved signatures. `allPlans`
229
+ // is retained only for entity-helper counting — a hand-owned method's
230
+ // preserved `@oagen-ignore` test block may still rely on an
231
+ // `expect<Model>()` helper that the generated tests would otherwise drop.
232
+ //
233
+ // URL-builder ops (e.g. `getLogoutUrl`) return a string synchronously and
234
+ // make no HTTP call, so the fetch-mock test shape (`fetchMethod()` etc.)
235
+ // throws at runtime. Skip them — like the hand-owned URL helpers.
236
+ const resolvedLookup = buildResolvedLookup(ctx);
237
+ const preservedScopes = methodsWithPreservedTestBlocks(ctx, testPath);
238
+ const plans = allPlans.filter((p) => {
239
+ // Keep the describe when a preserved hand-written `@oagen-ignore` test
240
+ // block is nested under it, so it isn't orphaned out of scope.
241
+ if (preservedScopes.has(p.method)) return true;
242
+ if (ignoredMethodNames.has(p.method)) return false;
243
+ if (lookupResolved(p.op, resolvedLookup)?.urlBuilder) return false;
244
+ return true;
245
+ });
211
246
 
212
247
  // Sort plans to match the existing file's method order (same as resources.ts).
213
248
  if (ctx.overlayLookup?.methodByOperation) {
@@ -294,8 +329,20 @@ function generateServiceTest(
294
329
 
295
330
  // Generate per-entity assertion helpers for models used in 2+ tests.
296
331
  // This deduplicates the field assertion blocks that would otherwise be
297
- // copy-pasted across list/find/create/update test cases.
298
- const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(plans, modelMap, ctx);
332
+ // copy-pasted across list/find/create/update test cases. Count across
333
+ // `allPlans` (incl. hand-owned methods) so a helper a preserved hand test
334
+ // block needs survives, but only emit one that an emitted `describe` or a
335
+ // preserved `@oagen-ignore` block actually references — otherwise it would
336
+ // be an unused function.
337
+ const ignoreRegionText = existingTestIgnoreText(ctx, testPath);
338
+ const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(
339
+ service,
340
+ allPlans,
341
+ plans,
342
+ ignoreRegionText,
343
+ modelMap,
344
+ ctx,
345
+ );
299
346
  for (const line of helperLines) {
300
347
  lines.push(line);
301
348
  }
@@ -325,6 +372,24 @@ function generateServiceTest(
325
372
 
326
373
  lines.push('});');
327
374
 
375
+ // Inject imports for real TS enums referenced as member values (e.g.
376
+ // `ConnectionType.ADFSSAML`). Import each from its own source file so the
377
+ // value's enum is the same nominal declaration the field is typed with.
378
+ const body = lines.join('\n');
379
+ const enumImportLines: string[] = [];
380
+ for (const [name, info] of Object.entries(ctx.apiSurface?.enums ?? {})) {
381
+ if (!new RegExp(`\\b${name}\\.[A-Za-z_$]`).test(body)) continue;
382
+ if (new RegExp(`import\\b[^;]*\\b${name}\\b[^;]*from`).test(body)) continue;
383
+ const sourceFile = (info as { sourceFile?: string }).sourceFile;
384
+ const spec = sourceFile ? relativeImport(testPath, sourceFile).replace(/\.ts$/, '') : './interfaces';
385
+ enumImportLines.push(`import { ${name} } from '${spec}';`);
386
+ }
387
+ if (enumImportLines.length > 0) {
388
+ const anchor = lines.indexOf("import { WorkOS } from '../workos';");
389
+ const at = anchor >= 0 ? anchor + 1 : 0;
390
+ lines.splice(at, 0, ...enumImportLines);
391
+ }
392
+
328
393
  return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
329
394
  }
330
395
 
@@ -342,16 +407,123 @@ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined,
342
407
  return 'test_id';
343
408
  }
344
409
 
345
- function queryParamTestValue(param: Parameter, modelMap?: Map<string, Model>): string {
346
- if (param.example !== undefined) {
347
- if (Array.isArray(param.example)) {
348
- return `[${param.example.map((v: unknown) => (typeof v === 'string' ? `'${v}'` : String(v))).join(', ')}]`;
410
+ /** Render an example value as a valid TS literal expression.
411
+ * Objects and nested arrays go through JSON.stringify so map-typed params
412
+ * (e.g. providerQueryParams) don't coerce to the literal text `[object Object]`.
413
+ */
414
+ function renderExampleLiteral(value: unknown): string {
415
+ if (typeof value === 'string') return `'${value}'`;
416
+ if (Array.isArray(value)) {
417
+ return `[${value.map(renderExampleLiteral).join(', ')}]`;
418
+ }
419
+ if (value !== null && typeof value === 'object') {
420
+ return JSON.stringify(value);
421
+ }
422
+ return String(value);
423
+ }
424
+
425
+ /** Resolve the enum members a query-param test value must satisfy. An enum is
426
+ * expressed several ways: an inline `EnumRef` with `values`, an `enum`/`model`
427
+ * ref by name, or — when the IR flattened the param to a bare string but the
428
+ * hand-written options interface still types the field as an enum — the
429
+ * baseline options field type (e.g. `ConnectionType`). */
430
+ /** All valid values for an enum named `name`. The extracted SDK surface is
431
+ * consulted first: when an options field is typed with a hand-written enum
432
+ * (e.g. `ConnectionType` from `connection-type.enum.ts`), that enum — not a
433
+ * same-named IR enum that may carry extra members like `Pending` — is what
434
+ * the generated test must type-check against. Falls back to the IR spec. */
435
+ function enumValuesByName(name: string, ctx?: EmitterContext): (string | number)[] | undefined {
436
+ const members = ctx?.apiSurface?.enums?.[name]?.members;
437
+ if (members && Object.keys(members).length > 0) return Object.values(members);
438
+ const specValues = ctx?.spec.enums.find((e) => e.name === name)?.values;
439
+ if (specValues?.length) return specValues.map((v) => v.value);
440
+ return undefined;
441
+ }
442
+
443
+ function resolveParamEnumValues(
444
+ param: Parameter,
445
+ optionFieldType: string | undefined,
446
+ ctx?: EmitterContext,
447
+ ): (string | number)[] | undefined {
448
+ // Prefer the enum the GENERATED options field is actually typed with — the
449
+ // operation's own IR enum can diverge from it (e.g. the connection_type
450
+ // query param resolves to `ConnectionsConnectionType` whose value
451
+ // 'GithubOAuth' is absent from the `ConnectionType` enum the hand-written
452
+ // options interface uses, where the member is 'GitHubOAuth').
453
+ if (optionFieldType) {
454
+ const bare = optionFieldType
455
+ .replace(/\[\]/g, '')
456
+ .replace(/\|\s*(undefined|null)/g, '')
457
+ .trim();
458
+ if (/^[A-Za-z_$][\w$]*$/.test(bare)) {
459
+ const values = enumValuesByName(bare, ctx);
460
+ if (values) return values;
349
461
  }
462
+ }
463
+ if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) {
464
+ const values = enumValuesByName(param.type.name, ctx);
465
+ if (values) return values;
466
+ }
467
+ if (param.type.kind === 'enum' && param.type.values?.length) return param.type.values;
468
+ return undefined;
469
+ }
470
+
471
+ /** When an options field is typed with a real (nominal) TS `enum` from the SDK
472
+ * surface, a string literal won't type-check — the value must be a member
473
+ * reference (`ConnectionType.ADFSSAML`). Returns the enum's name + members so
474
+ * the caller can both render the reference and import the right declaration. */
475
+ function resolveRealEnum(
476
+ param: Parameter,
477
+ optionFieldType: string | undefined,
478
+ ctx?: EmitterContext,
479
+ ): { name: string; members: Record<string, string | number> } | null {
480
+ const candidates: string[] = [];
481
+ if (optionFieldType) {
482
+ const bare = optionFieldType
483
+ .replace(/\[\]/g, '')
484
+ .replace(/\|\s*(undefined|null)/g, '')
485
+ .trim();
486
+ if (/^[A-Za-z_$][\w$]*$/.test(bare)) candidates.push(bare);
487
+ }
488
+ if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) candidates.push(param.type.name);
489
+ for (const name of candidates) {
490
+ const members = ctx?.apiSurface?.enums?.[name]?.members;
491
+ if (members && Object.keys(members).length > 0) return { name, members };
492
+ }
493
+ return null;
494
+ }
495
+
496
+ function queryParamTestValue(
497
+ param: Parameter,
498
+ modelMap?: Map<string, Model>,
499
+ ctx?: EmitterContext,
500
+ optionFieldType?: string,
501
+ ): string {
502
+ // Fields typed with a real TS enum need a member reference, not a string
503
+ // literal (string enums are nominal). Pick the member whose value matches the
504
+ // example, else the first member — `enumImportsToInject` later adds the import.
505
+ const realEnum = resolveRealEnum(param, optionFieldType, ctx);
506
+ if (realEnum) {
507
+ const entries = Object.entries(realEnum.members);
508
+ const match = typeof param.example === 'string' ? entries.find(([, v]) => v === param.example) : undefined;
509
+ const [memberKey] = match ?? entries[0];
510
+ return `${realEnum.name}.${memberKey}`;
511
+ }
512
+ // Otherwise (string-literal-union enums, inline enum refs) emit a value. A
513
+ // spec `example` can drift from the enum (e.g. connection_type example
514
+ // 'GithubOAuth' vs member 'GitHubOAuth'); prefer the example only when valid.
515
+ const enumValues = resolveParamEnumValues(param, optionFieldType, ctx);
516
+ if (enumValues?.length) {
517
+ const valid =
518
+ typeof param.example === 'string' && enumValues.includes(param.example) ? param.example : enumValues[0];
519
+ return typeof valid === 'string' ? `'${valid}'` : String(valid);
520
+ }
521
+ if (param.example !== undefined) {
350
522
  const isDateTime = param.type.kind === 'primitive' && param.type.format === 'date-time';
351
523
  if (isDateTime && typeof param.example === 'string') {
352
524
  return `new Date('${param.example}')`;
353
525
  }
354
- return typeof param.example === 'string' ? `'${param.example}'` : String(param.example);
526
+ return renderExampleLiteral(param.example);
355
527
  }
356
528
  return fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
357
529
  }
@@ -661,9 +833,10 @@ function buildOptionsObjectTestArg(
661
833
  if (!optionParam) return null;
662
834
 
663
835
  const entries: string[] = [];
836
+ const pathFieldMap = ctx ? operationOverrideFor(ctx, op)?.pathFieldMap : undefined;
664
837
  for (const param of op.pathParams) {
665
838
  const localName = fieldName(param.name);
666
- const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
839
+ const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx, pathFieldMap);
667
840
  entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
668
841
  }
669
842
 
@@ -677,7 +850,8 @@ function buildOptionsObjectTestArg(
677
850
  ).filter((param) => !param.deprecated);
678
851
  for (const param of queryParams) {
679
852
  const localName = fieldName(param.name);
680
- const value = queryParamTestValue(param, modelMap);
853
+ const optionFieldType = ctx?.apiSurface?.interfaces?.[optionParam.type]?.fields?.[localName]?.type;
854
+ const value = queryParamTestValue(param, modelMap, ctx, optionFieldType);
681
855
  entries.push(`${localName}: ${value}`);
682
856
  }
683
857
 
@@ -713,7 +887,16 @@ function objectLiteralEntries(literal: string): string[] {
713
887
  return body ? body.split(',').map((entry) => entry.trim()) : [];
714
888
  }
715
889
 
716
- function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
890
+ function resolveOptionsObjectField(
891
+ localName: string,
892
+ optionType: string,
893
+ ctx?: EmitterContext,
894
+ pathFieldMap?: Record<string, string>,
895
+ ): string {
896
+ // An explicit pathFieldMap wins unconditionally (mirrors resources.ts) so the
897
+ // generated test destructures the same renamed field the resource does.
898
+ const mapped = pathFieldMap?.[localName];
899
+ if (mapped) return mapped;
717
900
  const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
718
901
  if (!fields) return localName;
719
902
  if (fields[localName]) return localName;
@@ -729,28 +912,109 @@ function resolveOptionsObjectField(localName: string, optionType: string, ctx?:
729
912
  * Generate per-entity assertion helper functions for models used in 2+ tests.
730
913
  * Returns { lines, helpers } where helpers is a Set of helper function names.
731
914
  */
915
+ /** Describe-scope names (i.e. method names) that have an `@oagen-ignore` block
916
+ * nested inside them in the existing test file. Such a `describe` must keep
917
+ * being emitted even when the method is hand-owned — otherwise the engine has
918
+ * no `describe` to re-nest the preserved block under and orphans it to the top
919
+ * of the file (out of `beforeEach` scope), which hangs at runtime. Mirrors the
920
+ * engine's `findContainingDescribeScope`. */
921
+ function methodsWithPreservedTestBlocks(ctx: EmitterContext, relPath: string): Set<string> {
922
+ const scopes = new Set<string>();
923
+ const root = ctx.outputDir ?? ctx.targetDir;
924
+ if (!root) return scopes;
925
+ let content: string;
926
+ try {
927
+ content = fs.readFileSync(path.join(root, relPath), 'utf8');
928
+ } catch {
929
+ return scopes;
930
+ }
931
+ const lines = content.split('\n');
932
+ const indentOf = (l: string) => l.length - l.trimStart().length;
933
+ for (let i = 0; i < lines.length; i++) {
934
+ if (!lines[i].includes('@oagen-ignore-start')) continue;
935
+ if (/^\S/.test(lines[i])) continue; // top-level block — no enclosing describe
936
+ const markerIndent = indentOf(lines[i]);
937
+ for (let j = i - 1; j >= 0; j--) {
938
+ if (indentOf(lines[j]) >= markerIndent) continue;
939
+ const m = lines[j].match(/^\s*describe\((['"`])(.+?)\1\s*,/);
940
+ if (m) {
941
+ scopes.add(m[2]);
942
+ break;
943
+ }
944
+ if (/^\s*(?:export\s+)?class\s+\w+/.test(lines[j])) break;
945
+ }
946
+ }
947
+ return scopes;
948
+ }
949
+
950
+ /** Concatenated text of the existing test file's `@oagen-ignore` regions, so
951
+ * helper generation can see which `expect<Model>()` helpers preserved
952
+ * hand-written test blocks still reference. */
953
+ function existingTestIgnoreText(ctx: EmitterContext, relPath: string): string {
954
+ const root = ctx.outputDir ?? ctx.targetDir;
955
+ if (!root) return '';
956
+ let content: string;
957
+ try {
958
+ content = fs.readFileSync(path.join(root, relPath), 'utf8');
959
+ } catch {
960
+ return '';
961
+ }
962
+ return [...content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)].map((m) => m[0]).join('\n');
963
+ }
964
+
732
965
  function generateEntityHelpers(
733
- plans: { op: Operation; plan: any; method: string }[],
966
+ service: Service,
967
+ allPlans: { op: Operation; plan: any; method: string }[],
968
+ renderedPlans: { op: Operation; plan: any; method: string }[],
969
+ ignoreRegionText: string,
734
970
  modelMap: Map<string, Model>,
735
971
  ctx: EmitterContext,
736
972
  ): { lines: string[]; helpers: Set<string> } {
737
- // Count how many tests reference each response model
738
- const modelUsage = new Map<string, number>();
739
- for (const { op, plan } of plans) {
740
- let modelName: string | null = null;
973
+ const responseModelOf = (entry: { op: Operation; plan: any }): string | null => {
974
+ const { op, plan } = entry;
741
975
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
742
- modelName = op.pagination.itemType.name;
976
+ let modelName = op.pagination.itemType.name;
743
977
  const rawModel = modelMap.get(modelName);
744
978
  if (rawModel) {
745
979
  const unwrapped = unwrapListModel(rawModel, modelMap);
746
980
  if (unwrapped) modelName = unwrapped.name;
747
981
  }
748
- } else if (plan.responseModelName) {
749
- modelName = plan.responseModelName;
982
+ return modelName;
750
983
  }
751
- if (modelName) {
752
- modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
984
+ return plan.responseModelName ?? null;
985
+ };
986
+
987
+ // Count how many tests reference each response model — across ALL plans so a
988
+ // model that a hand-owned method's preserved test block asserts on still
989
+ // clears the 2-use threshold.
990
+ const modelUsage = new Map<string, number>();
991
+ for (const entry of allPlans) {
992
+ const modelName = responseModelOf(entry);
993
+ if (modelName) modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
994
+ }
995
+ // Models an emitted `describe` will actually assert on. A helper only earns
996
+ // its place if an emitted test references it or a preserved `@oagen-ignore`
997
+ // block names it — otherwise it's an unused function.
998
+ const renderedModels = new Set<string>();
999
+ for (const entry of renderedPlans) {
1000
+ const modelName = responseModelOf(entry);
1001
+ if (!modelName) continue;
1002
+ // A paginated test that skips item field assertions (baseline returns a
1003
+ // non-paginatable type, or a different item type than the spec) never
1004
+ // invokes the per-item entity helper — mirror renderPaginatedTest's
1005
+ // `skipFieldAssertions` here so we don't emit a helper no call site
1006
+ // references (TS6133). A model also reached by a non-skipped test is still
1007
+ // added via that entry, so a genuinely-called helper is never dropped.
1008
+ if (entry.plan.isPaginated && entry.op.pagination?.itemType.kind === 'model') {
1009
+ const baselineMethod = optionsMethodFor(service, entry.method, entry.op, entry.plan, ctx);
1010
+ const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
1011
+ const generatedItemType = resolveInterfaceName(modelName, ctx);
1012
+ const skipFieldAssertions =
1013
+ Boolean(baselineMethod?.returnType && !baselineItemType) ||
1014
+ Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
1015
+ if (skipFieldAssertions) continue;
753
1016
  }
1017
+ renderedModels.add(modelName);
754
1018
  }
755
1019
 
756
1020
  const lines: string[] = [];
@@ -765,6 +1029,8 @@ function generateEntityHelpers(
765
1029
  const domainName = resolveInterfaceName(modelName, ctx);
766
1030
  const helperName = `expect${domainName}`;
767
1031
  if (helpers.has(helperName)) continue;
1032
+ const referenced = renderedModels.has(modelName) || ignoreRegionText.includes(`${helperName}(`);
1033
+ if (!referenced) continue;
768
1034
  helpers.add(helperName);
769
1035
 
770
1036
  lines.push(`function ${helperName}(result: any) {`);
@@ -797,7 +1063,7 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
797
1063
 
798
1064
  for (const field of model.fields) {
799
1065
  if (!field.required) continue;
800
- const domainField = fieldName(field.name);
1066
+ const domainField = fieldName(field.domainName ?? field.name);
801
1067
  // `string` + `format: 'date-time'` is deserialized to `Date` by the
802
1068
  // serializer (see `mapPrimitive` in type-map.ts). Asserting against a
803
1069
  // string literal would fail Object.is — compare via `.toISOString()`.