@workos/oagen-emitters 0.18.3 → 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 (51) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +9 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-Cciic50q.mjs} +443 -99
  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 +5 -2
  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 +55 -17
  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/rust/models.test.ts +49 -0
  51. package/dist/plugin-1ckLMpgo.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 {
@@ -568,7 +569,7 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
568
569
 
569
570
  const plans = service.operations.map((op) => ({
570
571
  op,
571
- plan: planOperation(op),
572
+ plan: planOperationFor(op, ctx),
572
573
  method: resolveMethodName(op, service, ctx),
573
574
  }));
574
575
 
@@ -577,7 +578,13 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
577
578
  const baselineMethod = baselineMethodFor(service, method, ctx);
578
579
  const optionInfo = optionsObjectInfo(service, method, op, plan, ctx, baselineMethod, resolvedOp);
579
580
  if (!optionInfo?.generated) continue;
580
- 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;
581
588
 
582
589
  const optionsName = optionInfo.type;
583
590
  const optionFileStem = `${fileName(optionsName)}.interface`;
@@ -645,9 +652,10 @@ function generateOptionsInterfaces(service: Service, ctx: EmitterContext, specEn
645
652
  headerParts.push(` ${name}${opt}: ${type};`);
646
653
  };
647
654
 
655
+ const optionsPathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
648
656
  for (const param of op.pathParams) {
649
657
  pushField(
650
- fieldName(param.name),
658
+ optionsPathFieldMap?.[fieldName(param.name)] ?? fieldName(param.name),
651
659
  true,
652
660
  mapParamType(param.type, specEnumNames),
653
661
  param.description,
@@ -779,7 +787,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
779
787
 
780
788
  let plans = service.operations.map((op) => ({
781
789
  op,
782
- plan: planOperation(op),
790
+ plan: planOperationFor(op, ctx),
783
791
  method: resolveMethodName(op, service, ctx),
784
792
  }));
785
793
 
@@ -1038,6 +1046,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
1038
1046
  }
1039
1047
 
1040
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');
1041
1053
  for (const optionType of optionObjectTypes) {
1042
1054
  if (isValidTypeIdentifier(optionType)) {
1043
1055
  if (importedTypeNames.has(optionType)) continue;
@@ -1954,7 +1966,20 @@ function renderOptionsObjectMethod(
1954
1966
  return true;
1955
1967
  }
1956
1968
 
1957
- 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
+ }
1958
1983
  }
1959
1984
 
1960
1985
  function renderOptionsObjectDestructure(lines: string[], pathBindings: string[], restName?: string): void {
@@ -1988,7 +2013,8 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
1988
2013
  // Return resolved SDK field names directly — the URL template uses these
1989
2014
  // names too (via the param-name map threaded into buildNodePathExpression),
1990
2015
  // so the destructure no longer needs `optionField: localName` renames.
1991
- 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));
1992
2018
  }
1993
2019
 
1994
2020
  /**
@@ -2000,15 +2026,27 @@ function buildOptionsObjectPathBindings(op: Operation, optionType: string, ctx:
2000
2026
  */
2001
2027
  function buildOptionsObjectPathParamMap(op: Operation, optionType: string, ctx: EmitterContext): Map<string, string> {
2002
2028
  const map = new Map<string, string>();
2029
+ const pathFieldMap = operationOverrideFor(ctx, op)?.pathFieldMap;
2003
2030
  for (const param of op.pathParams) {
2004
2031
  const localName = fieldName(param.name);
2005
- const sdkField = resolveOptionsObjectField(localName, optionType, ctx);
2032
+ const sdkField = resolveOptionsObjectField(localName, optionType, ctx, pathFieldMap);
2006
2033
  if (sdkField !== localName) map.set(param.name, sdkField);
2007
2034
  }
2008
2035
  return map;
2009
2036
  }
2010
2037
 
2011
- 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;
2012
2050
  const fields = ctx.apiSurface?.interfaces?.[optionType]?.fields;
2013
2051
  if (!fields) return localName;
2014
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()`.
package/src/php/index.ts CHANGED
@@ -19,6 +19,7 @@ import { generateTests } from './tests.js';
19
19
  import { buildOperationsMap } from './manifest.js';
20
20
  import { initializeEnumDedup } from './naming.js';
21
21
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
22
+ import { AUTOGEN_NOTICE } from '../shared/file-header.js';
22
23
 
23
24
  /** Initialize enum deduplication from spec data. */
24
25
  function ensureNamingInitialized(ctx: EmitterContext): void {
@@ -100,7 +101,7 @@ export const phpEmitter: Emitter = {
100
101
  },
101
102
 
102
103
  fileHeader(): string {
103
- return '<?php\n\ndeclare(strict_types=1);\n\n// This file is auto-generated by oagen. Do not edit.';
104
+ return `<?php\n\ndeclare(strict_types=1);\n\n// ${AUTOGEN_NOTICE}`;
104
105
  },
105
106
 
106
107
  formatCommand(targetDir: string): FormatCommand | null {