@workos/oagen-emitters 0.18.3 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +16 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-1ckLMpgo.mjs → plugin-BXDPA9pJ.mjs} +581 -172
  6. package/dist/plugin-BXDPA9pJ.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/docs/sdk-architecture/rust.md +2 -2
  9. package/package.json +5 -5
  10. package/src/dotnet/enums.ts +11 -5
  11. package/src/dotnet/fixtures.ts +5 -2
  12. package/src/dotnet/index.ts +2 -1
  13. package/src/dotnet/models.ts +41 -10
  14. package/src/dotnet/naming.ts +10 -0
  15. package/src/dotnet/resources.ts +3 -3
  16. package/src/dotnet/tests.ts +8 -4
  17. package/src/go/fixtures.ts +4 -2
  18. package/src/go/index.ts +4 -0
  19. package/src/go/models.ts +4 -2
  20. package/src/go/naming.ts +10 -0
  21. package/src/go/resources.ts +22 -9
  22. package/src/go/tests.ts +3 -3
  23. package/src/kotlin/enums.ts +21 -11
  24. package/src/kotlin/index.ts +2 -1
  25. package/src/kotlin/models.ts +24 -9
  26. package/src/kotlin/naming.ts +11 -0
  27. package/src/kotlin/resources.ts +2 -2
  28. package/src/kotlin/tests.ts +7 -3
  29. package/src/node/enums.ts +8 -5
  30. package/src/node/field-plan.ts +3 -3
  31. package/src/node/index.ts +2 -1
  32. package/src/node/models.ts +69 -22
  33. package/src/node/naming.ts +10 -0
  34. package/src/node/options.ts +45 -1
  35. package/src/node/resources.ts +67 -18
  36. package/src/node/tests.ts +302 -31
  37. package/src/php/enums.ts +18 -5
  38. package/src/php/index.ts +13 -4
  39. package/src/php/models.ts +22 -10
  40. package/src/php/naming.ts +10 -0
  41. package/src/php/resources.ts +6 -4
  42. package/src/php/tests.ts +17 -5
  43. package/src/python/enums.ts +39 -28
  44. package/src/python/fixtures.ts +4 -3
  45. package/src/python/index.ts +2 -1
  46. package/src/python/models.ts +39 -24
  47. package/src/python/naming.ts +10 -0
  48. package/src/python/resources.ts +3 -3
  49. package/src/python/tests.ts +14 -9
  50. package/src/ruby/enums.ts +28 -19
  51. package/src/ruby/index.ts +2 -1
  52. package/src/ruby/models.ts +33 -19
  53. package/src/ruby/naming.ts +10 -0
  54. package/src/ruby/rbi.ts +20 -7
  55. package/src/ruby/resources.ts +2 -2
  56. package/src/ruby/tests.ts +6 -3
  57. package/src/rust/enums.ts +9 -1
  58. package/src/rust/index.ts +2 -1
  59. package/src/rust/models.ts +100 -15
  60. package/src/rust/naming.ts +10 -0
  61. package/src/rust/resources.ts +14 -3
  62. package/src/rust/tests.ts +2 -2
  63. package/src/shared/file-header.ts +13 -0
  64. package/src/shared/resolved-ops.ts +47 -0
  65. package/test/rust/models.test.ts +49 -0
  66. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  67. package/dist/plugin-1ckLMpgo.mjs.map +0 -1
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, isMountInScope } 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 {
@@ -134,7 +137,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
134
137
  }
135
138
 
136
139
  const testEntries: Array<{ name: string; operations: Operation[] }> =
137
- mountGroups.size > 0
140
+ mountGroups.size > 0 || ctx.scopedServices?.size
138
141
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
139
142
  : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
140
143
 
@@ -156,11 +159,27 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
156
159
  }
157
160
 
158
161
  for (const { name: mountName, operations } of testEntries) {
162
+ // Scope gate: in a scoped (`--services`) run, only emit per-service test
163
+ // files for the selected post-mount names. `mountName` is the mount-group
164
+ // key (the POST-MOUNT name that matches `ctx.scopedServices`). Applied as an
165
+ // additional early continue ahead of the node-owned/coverage skip logic.
166
+ if (!isMountInScope(mountName, ctx)) continue;
159
167
  if (operations.length === 0) continue;
160
168
  const mergedService: Service = { name: mountName, operations };
161
169
  const isOwnedService = isNodeOwnedService(ctx, mountName, resolveResourceClassName(mergedService, ctx));
162
170
  const ops = isOwnedService ? operations : uncoveredOperations(mergedService, ctx);
163
171
  if (ops.length === 0) continue;
172
+ // Methods hand-owned inside an `@oagen-ignore` region of the resource
173
+ // file: resources.ts skips generating those methods, so a generated test
174
+ // would reference a shape that no longer matches the preserved hand-written
175
+ // signature (e.g. SSO's `getAuthorizationUrl` returns a string, not
176
+ // `{ url }`). Their `describe` blocks are skipped below, but the ops are
177
+ // still passed through so entity-assertion helpers their preserved
178
+ // hand-written test blocks rely on continue to be emitted.
179
+ const ignoredMethodNames = ignoredResourceMethodNames(
180
+ ctx,
181
+ `src/${resolveResourceDir(mergedService, ctx)}/${fileName(resolveResourceClassName(mergedService, ctx))}.ts`,
182
+ );
164
183
 
165
184
  // Skip tests for services without a WorkOS property in the baseline
166
185
  const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
@@ -176,7 +195,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
176
195
  if (resourceClass !== mountName) continue;
177
196
 
178
197
  const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
179
- files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
198
+ files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors, ignoredMethodNames));
180
199
  }
181
200
 
182
201
  // Generate serializer round-trip tests
@@ -194,6 +213,7 @@ function generateServiceTest(
194
213
  ctx: EmitterContext,
195
214
  modelMap: Map<string, Model>,
196
215
  mountAccessors?: Map<string, string>,
216
+ ignoredMethodNames: Set<string> = new Set(),
197
217
  ): GeneratedFile {
198
218
  const resolvedName = resolveResourceClassName(service, ctx);
199
219
  const serviceDir = resolveResourceDir(service, ctx);
@@ -203,11 +223,31 @@ function generateServiceTest(
203
223
  const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
204
224
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
205
225
 
206
- const plans = service.operations.map((op) => ({
226
+ const allPlans = service.operations.map((op) => ({
207
227
  op,
208
- plan: planOperation(op),
228
+ plan: planOperationFor(op, ctx),
209
229
  method: resolveMethodName(op, service, ctx),
210
230
  }));
231
+ // `plans` drives everything that becomes generated text (fixture imports,
232
+ // test-util imports, `describe` blocks): exclude hand-owned methods so we
233
+ // don't emit broken tests against their preserved signatures. `allPlans`
234
+ // is retained only for entity-helper counting — a hand-owned method's
235
+ // preserved `@oagen-ignore` test block may still rely on an
236
+ // `expect<Model>()` helper that the generated tests would otherwise drop.
237
+ //
238
+ // URL-builder ops (e.g. `getLogoutUrl`) return a string synchronously and
239
+ // make no HTTP call, so the fetch-mock test shape (`fetchMethod()` etc.)
240
+ // throws at runtime. Skip them — like the hand-owned URL helpers.
241
+ const resolvedLookup = buildResolvedLookup(ctx);
242
+ const preservedScopes = methodsWithPreservedTestBlocks(ctx, testPath);
243
+ const plans = allPlans.filter((p) => {
244
+ // Keep the describe when a preserved hand-written `@oagen-ignore` test
245
+ // block is nested under it, so it isn't orphaned out of scope.
246
+ if (preservedScopes.has(p.method)) return true;
247
+ if (ignoredMethodNames.has(p.method)) return false;
248
+ if (lookupResolved(p.op, resolvedLookup)?.urlBuilder) return false;
249
+ return true;
250
+ });
211
251
 
212
252
  // Sort plans to match the existing file's method order (same as resources.ts).
213
253
  if (ctx.overlayLookup?.methodByOperation) {
@@ -294,8 +334,20 @@ function generateServiceTest(
294
334
 
295
335
  // Generate per-entity assertion helpers for models used in 2+ tests.
296
336
  // 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);
337
+ // copy-pasted across list/find/create/update test cases. Count across
338
+ // `allPlans` (incl. hand-owned methods) so a helper a preserved hand test
339
+ // block needs survives, but only emit one that an emitted `describe` or a
340
+ // preserved `@oagen-ignore` block actually references — otherwise it would
341
+ // be an unused function.
342
+ const ignoreRegionText = existingTestIgnoreText(ctx, testPath);
343
+ const { lines: helperLines, helpers: entityHelperNames } = generateEntityHelpers(
344
+ service,
345
+ allPlans,
346
+ plans,
347
+ ignoreRegionText,
348
+ modelMap,
349
+ ctx,
350
+ );
299
351
  for (const line of helperLines) {
300
352
  lines.push(line);
301
353
  }
@@ -325,6 +377,24 @@ function generateServiceTest(
325
377
 
326
378
  lines.push('});');
327
379
 
380
+ // Inject imports for real TS enums referenced as member values (e.g.
381
+ // `ConnectionType.ADFSSAML`). Import each from its own source file so the
382
+ // value's enum is the same nominal declaration the field is typed with.
383
+ const body = lines.join('\n');
384
+ const enumImportLines: string[] = [];
385
+ for (const [name, info] of Object.entries(ctx.apiSurface?.enums ?? {})) {
386
+ if (!new RegExp(`\\b${name}\\.[A-Za-z_$]`).test(body)) continue;
387
+ if (new RegExp(`import\\b[^;]*\\b${name}\\b[^;]*from`).test(body)) continue;
388
+ const sourceFile = (info as { sourceFile?: string }).sourceFile;
389
+ const spec = sourceFile ? relativeImport(testPath, sourceFile).replace(/\.ts$/, '') : './interfaces';
390
+ enumImportLines.push(`import { ${name} } from '${spec}';`);
391
+ }
392
+ if (enumImportLines.length > 0) {
393
+ const anchor = lines.indexOf("import { WorkOS } from '../workos';");
394
+ const at = anchor >= 0 ? anchor + 1 : 0;
395
+ lines.splice(at, 0, ...enumImportLines);
396
+ }
397
+
328
398
  return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
329
399
  }
330
400
 
@@ -342,16 +412,123 @@ function pathParamTestValue(param: { type: TypeRef; name?: string } | undefined,
342
412
  return 'test_id';
343
413
  }
344
414
 
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(', ')}]`;
415
+ /** Render an example value as a valid TS literal expression.
416
+ * Objects and nested arrays go through JSON.stringify so map-typed params
417
+ * (e.g. providerQueryParams) don't coerce to the literal text `[object Object]`.
418
+ */
419
+ function renderExampleLiteral(value: unknown): string {
420
+ if (typeof value === 'string') return `'${value}'`;
421
+ if (Array.isArray(value)) {
422
+ return `[${value.map(renderExampleLiteral).join(', ')}]`;
423
+ }
424
+ if (value !== null && typeof value === 'object') {
425
+ return JSON.stringify(value);
426
+ }
427
+ return String(value);
428
+ }
429
+
430
+ /** Resolve the enum members a query-param test value must satisfy. An enum is
431
+ * expressed several ways: an inline `EnumRef` with `values`, an `enum`/`model`
432
+ * ref by name, or — when the IR flattened the param to a bare string but the
433
+ * hand-written options interface still types the field as an enum — the
434
+ * baseline options field type (e.g. `ConnectionType`). */
435
+ /** All valid values for an enum named `name`. The extracted SDK surface is
436
+ * consulted first: when an options field is typed with a hand-written enum
437
+ * (e.g. `ConnectionType` from `connection-type.enum.ts`), that enum — not a
438
+ * same-named IR enum that may carry extra members like `Pending` — is what
439
+ * the generated test must type-check against. Falls back to the IR spec. */
440
+ function enumValuesByName(name: string, ctx?: EmitterContext): (string | number)[] | undefined {
441
+ const members = ctx?.apiSurface?.enums?.[name]?.members;
442
+ if (members && Object.keys(members).length > 0) return Object.values(members);
443
+ const specValues = ctx?.spec.enums.find((e) => e.name === name)?.values;
444
+ if (specValues?.length) return specValues.map((v) => v.value);
445
+ return undefined;
446
+ }
447
+
448
+ function resolveParamEnumValues(
449
+ param: Parameter,
450
+ optionFieldType: string | undefined,
451
+ ctx?: EmitterContext,
452
+ ): (string | number)[] | undefined {
453
+ // Prefer the enum the GENERATED options field is actually typed with — the
454
+ // operation's own IR enum can diverge from it (e.g. the connection_type
455
+ // query param resolves to `ConnectionsConnectionType` whose value
456
+ // 'GithubOAuth' is absent from the `ConnectionType` enum the hand-written
457
+ // options interface uses, where the member is 'GitHubOAuth').
458
+ if (optionFieldType) {
459
+ const bare = optionFieldType
460
+ .replace(/\[\]/g, '')
461
+ .replace(/\|\s*(undefined|null)/g, '')
462
+ .trim();
463
+ if (/^[A-Za-z_$][\w$]*$/.test(bare)) {
464
+ const values = enumValuesByName(bare, ctx);
465
+ if (values) return values;
349
466
  }
467
+ }
468
+ if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) {
469
+ const values = enumValuesByName(param.type.name, ctx);
470
+ if (values) return values;
471
+ }
472
+ if (param.type.kind === 'enum' && param.type.values?.length) return param.type.values;
473
+ return undefined;
474
+ }
475
+
476
+ /** When an options field is typed with a real (nominal) TS `enum` from the SDK
477
+ * surface, a string literal won't type-check — the value must be a member
478
+ * reference (`ConnectionType.ADFSSAML`). Returns the enum's name + members so
479
+ * the caller can both render the reference and import the right declaration. */
480
+ function resolveRealEnum(
481
+ param: Parameter,
482
+ optionFieldType: string | undefined,
483
+ ctx?: EmitterContext,
484
+ ): { name: string; members: Record<string, string | number> } | null {
485
+ const candidates: string[] = [];
486
+ if (optionFieldType) {
487
+ const bare = optionFieldType
488
+ .replace(/\[\]/g, '')
489
+ .replace(/\|\s*(undefined|null)/g, '')
490
+ .trim();
491
+ if (/^[A-Za-z_$][\w$]*$/.test(bare)) candidates.push(bare);
492
+ }
493
+ if ((param.type.kind === 'enum' || param.type.kind === 'model') && param.type.name) candidates.push(param.type.name);
494
+ for (const name of candidates) {
495
+ const members = ctx?.apiSurface?.enums?.[name]?.members;
496
+ if (members && Object.keys(members).length > 0) return { name, members };
497
+ }
498
+ return null;
499
+ }
500
+
501
+ function queryParamTestValue(
502
+ param: Parameter,
503
+ modelMap?: Map<string, Model>,
504
+ ctx?: EmitterContext,
505
+ optionFieldType?: string,
506
+ ): string {
507
+ // Fields typed with a real TS enum need a member reference, not a string
508
+ // literal (string enums are nominal). Pick the member whose value matches the
509
+ // example, else the first member — `enumImportsToInject` later adds the import.
510
+ const realEnum = resolveRealEnum(param, optionFieldType, ctx);
511
+ if (realEnum) {
512
+ const entries = Object.entries(realEnum.members);
513
+ const match = typeof param.example === 'string' ? entries.find(([, v]) => v === param.example) : undefined;
514
+ const [memberKey] = match ?? entries[0];
515
+ return `${realEnum.name}.${memberKey}`;
516
+ }
517
+ // Otherwise (string-literal-union enums, inline enum refs) emit a value. A
518
+ // spec `example` can drift from the enum (e.g. connection_type example
519
+ // 'GithubOAuth' vs member 'GitHubOAuth'); prefer the example only when valid.
520
+ const enumValues = resolveParamEnumValues(param, optionFieldType, ctx);
521
+ if (enumValues?.length) {
522
+ const valid =
523
+ typeof param.example === 'string' && enumValues.includes(param.example) ? param.example : enumValues[0];
524
+ return typeof valid === 'string' ? `'${valid}'` : String(valid);
525
+ }
526
+ if (param.example !== undefined) {
350
527
  const isDateTime = param.type.kind === 'primitive' && param.type.format === 'date-time';
351
528
  if (isDateTime && typeof param.example === 'string') {
352
529
  return `new Date('${param.example}')`;
353
530
  }
354
- return typeof param.example === 'string' ? `'${param.example}'` : String(param.example);
531
+ return renderExampleLiteral(param.example);
355
532
  }
356
533
  return fixtureValueForType(param.type, param.name, 'Options', modelMap) ?? "'test'";
357
534
  }
@@ -661,9 +838,10 @@ function buildOptionsObjectTestArg(
661
838
  if (!optionParam) return null;
662
839
 
663
840
  const entries: string[] = [];
841
+ const pathFieldMap = ctx ? operationOverrideFor(ctx, op)?.pathFieldMap : undefined;
664
842
  for (const param of op.pathParams) {
665
843
  const localName = fieldName(param.name);
666
- const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx);
844
+ const optionField = resolveOptionsObjectField(localName, optionParam.type, ctx, pathFieldMap);
667
845
  entries.push(`${optionField}: ${JSON.stringify(pathParamTestValue(param, localName))}`);
668
846
  }
669
847
 
@@ -677,7 +855,8 @@ function buildOptionsObjectTestArg(
677
855
  ).filter((param) => !param.deprecated);
678
856
  for (const param of queryParams) {
679
857
  const localName = fieldName(param.name);
680
- const value = queryParamTestValue(param, modelMap);
858
+ const optionFieldType = ctx?.apiSurface?.interfaces?.[optionParam.type]?.fields?.[localName]?.type;
859
+ const value = queryParamTestValue(param, modelMap, ctx, optionFieldType);
681
860
  entries.push(`${localName}: ${value}`);
682
861
  }
683
862
 
@@ -713,7 +892,16 @@ function objectLiteralEntries(literal: string): string[] {
713
892
  return body ? body.split(',').map((entry) => entry.trim()) : [];
714
893
  }
715
894
 
716
- function resolveOptionsObjectField(localName: string, optionType: string, ctx?: EmitterContext): string {
895
+ function resolveOptionsObjectField(
896
+ localName: string,
897
+ optionType: string,
898
+ ctx?: EmitterContext,
899
+ pathFieldMap?: Record<string, string>,
900
+ ): string {
901
+ // An explicit pathFieldMap wins unconditionally (mirrors resources.ts) so the
902
+ // generated test destructures the same renamed field the resource does.
903
+ const mapped = pathFieldMap?.[localName];
904
+ if (mapped) return mapped;
717
905
  const fields = ctx?.apiSurface?.interfaces?.[optionType]?.fields;
718
906
  if (!fields) return localName;
719
907
  if (fields[localName]) return localName;
@@ -729,28 +917,109 @@ function resolveOptionsObjectField(localName: string, optionType: string, ctx?:
729
917
  * Generate per-entity assertion helper functions for models used in 2+ tests.
730
918
  * Returns { lines, helpers } where helpers is a Set of helper function names.
731
919
  */
920
+ /** Describe-scope names (i.e. method names) that have an `@oagen-ignore` block
921
+ * nested inside them in the existing test file. Such a `describe` must keep
922
+ * being emitted even when the method is hand-owned — otherwise the engine has
923
+ * no `describe` to re-nest the preserved block under and orphans it to the top
924
+ * of the file (out of `beforeEach` scope), which hangs at runtime. Mirrors the
925
+ * engine's `findContainingDescribeScope`. */
926
+ function methodsWithPreservedTestBlocks(ctx: EmitterContext, relPath: string): Set<string> {
927
+ const scopes = new Set<string>();
928
+ const root = ctx.outputDir ?? ctx.targetDir;
929
+ if (!root) return scopes;
930
+ let content: string;
931
+ try {
932
+ content = fs.readFileSync(path.join(root, relPath), 'utf8');
933
+ } catch {
934
+ return scopes;
935
+ }
936
+ const lines = content.split('\n');
937
+ const indentOf = (l: string) => l.length - l.trimStart().length;
938
+ for (let i = 0; i < lines.length; i++) {
939
+ if (!lines[i].includes('@oagen-ignore-start')) continue;
940
+ if (/^\S/.test(lines[i])) continue; // top-level block — no enclosing describe
941
+ const markerIndent = indentOf(lines[i]);
942
+ for (let j = i - 1; j >= 0; j--) {
943
+ if (indentOf(lines[j]) >= markerIndent) continue;
944
+ const m = lines[j].match(/^\s*describe\((['"`])(.+?)\1\s*,/);
945
+ if (m) {
946
+ scopes.add(m[2]);
947
+ break;
948
+ }
949
+ if (/^\s*(?:export\s+)?class\s+\w+/.test(lines[j])) break;
950
+ }
951
+ }
952
+ return scopes;
953
+ }
954
+
955
+ /** Concatenated text of the existing test file's `@oagen-ignore` regions, so
956
+ * helper generation can see which `expect<Model>()` helpers preserved
957
+ * hand-written test blocks still reference. */
958
+ function existingTestIgnoreText(ctx: EmitterContext, relPath: string): string {
959
+ const root = ctx.outputDir ?? ctx.targetDir;
960
+ if (!root) return '';
961
+ let content: string;
962
+ try {
963
+ content = fs.readFileSync(path.join(root, relPath), 'utf8');
964
+ } catch {
965
+ return '';
966
+ }
967
+ return [...content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)].map((m) => m[0]).join('\n');
968
+ }
969
+
732
970
  function generateEntityHelpers(
733
- plans: { op: Operation; plan: any; method: string }[],
971
+ service: Service,
972
+ allPlans: { op: Operation; plan: any; method: string }[],
973
+ renderedPlans: { op: Operation; plan: any; method: string }[],
974
+ ignoreRegionText: string,
734
975
  modelMap: Map<string, Model>,
735
976
  ctx: EmitterContext,
736
977
  ): { 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;
978
+ const responseModelOf = (entry: { op: Operation; plan: any }): string | null => {
979
+ const { op, plan } = entry;
741
980
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
742
- modelName = op.pagination.itemType.name;
981
+ let modelName = op.pagination.itemType.name;
743
982
  const rawModel = modelMap.get(modelName);
744
983
  if (rawModel) {
745
984
  const unwrapped = unwrapListModel(rawModel, modelMap);
746
985
  if (unwrapped) modelName = unwrapped.name;
747
986
  }
748
- } else if (plan.responseModelName) {
749
- modelName = plan.responseModelName;
987
+ return modelName;
750
988
  }
751
- if (modelName) {
752
- modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
989
+ return plan.responseModelName ?? null;
990
+ };
991
+
992
+ // Count how many tests reference each response model — across ALL plans so a
993
+ // model that a hand-owned method's preserved test block asserts on still
994
+ // clears the 2-use threshold.
995
+ const modelUsage = new Map<string, number>();
996
+ for (const entry of allPlans) {
997
+ const modelName = responseModelOf(entry);
998
+ if (modelName) modelUsage.set(modelName, (modelUsage.get(modelName) ?? 0) + 1);
999
+ }
1000
+ // Models an emitted `describe` will actually assert on. A helper only earns
1001
+ // its place if an emitted test references it or a preserved `@oagen-ignore`
1002
+ // block names it — otherwise it's an unused function.
1003
+ const renderedModels = new Set<string>();
1004
+ for (const entry of renderedPlans) {
1005
+ const modelName = responseModelOf(entry);
1006
+ if (!modelName) continue;
1007
+ // A paginated test that skips item field assertions (baseline returns a
1008
+ // non-paginatable type, or a different item type than the spec) never
1009
+ // invokes the per-item entity helper — mirror renderPaginatedTest's
1010
+ // `skipFieldAssertions` here so we don't emit a helper no call site
1011
+ // references (TS6133). A model also reached by a non-skipped test is still
1012
+ // added via that entry, so a genuinely-called helper is never dropped.
1013
+ if (entry.plan.isPaginated && entry.op.pagination?.itemType.kind === 'model') {
1014
+ const baselineMethod = optionsMethodFor(service, entry.method, entry.op, entry.plan, ctx);
1015
+ const baselineItemType = autoPaginatableItemType(baselineMethod?.returnType);
1016
+ const generatedItemType = resolveInterfaceName(modelName, ctx);
1017
+ const skipFieldAssertions =
1018
+ Boolean(baselineMethod?.returnType && !baselineItemType) ||
1019
+ Boolean(baselineItemType && generatedItemType && baselineItemType !== generatedItemType);
1020
+ if (skipFieldAssertions) continue;
753
1021
  }
1022
+ renderedModels.add(modelName);
754
1023
  }
755
1024
 
756
1025
  const lines: string[] = [];
@@ -765,6 +1034,8 @@ function generateEntityHelpers(
765
1034
  const domainName = resolveInterfaceName(modelName, ctx);
766
1035
  const helperName = `expect${domainName}`;
767
1036
  if (helpers.has(helperName)) continue;
1037
+ const referenced = renderedModels.has(modelName) || ignoreRegionText.includes(`${helperName}(`);
1038
+ if (!referenced) continue;
768
1039
  helpers.add(helperName);
769
1040
 
770
1041
  lines.push(`function ${helperName}(result: any) {`);
@@ -797,7 +1068,7 @@ function buildFieldAssertions(model: Model, accessor: string, modelMap?: Map<str
797
1068
 
798
1069
  for (const field of model.fields) {
799
1070
  if (!field.required) continue;
800
- const domainField = fieldName(field.name);
1071
+ const domainField = fieldName(field.domainName ?? field.name);
801
1072
  // `string` + `format: 'date-time'` is deserialized to `Date` by the
802
1073
  // serializer (see `mapPrimitive` in type-map.ts). Asserting against a
803
1074
  // string literal would fail Object.is — compare via `.toISOString()`.
package/src/php/enums.ts CHANGED
@@ -2,6 +2,7 @@ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
  import { className, resolveEnumName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
+ import { isEnumInScope } from '../shared/resolved-ops.js';
5
6
 
6
7
  /**
7
8
  * Generate PHP enum files from IR enums.
@@ -17,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
17
18
  if (emittedCanonical.has(canonical)) continue; // skip aliases
18
19
  emittedCanonical.add(canonical);
19
20
 
21
+ // FR-1.4: write the per-enum FILE only when in scope. PHP dedupes
22
+ // value-identical enums onto a single canonical class, so the canonical
23
+ // file is needed when EITHER the canonical name OR any alias resolving to
24
+ // it is reachable from the selected services. PSR-4 (one class per file
25
+ // under lib/Resource/, no barrel) means an out-of-scope enum is simply
26
+ // left untouched on disk and stays loadable.
27
+ const enumInScope = enums.some(
28
+ (other) => resolveEnumName(other.name) === canonical && isEnumInScope(other.name, ctx),
29
+ );
30
+
20
31
  const name = className(canonical);
21
32
  const _isAllStrings = e.values.every((v) => typeof v.value === 'string');
22
33
  const isAllInts = e.values.every((v) => typeof v.value === 'number' && Number.isInteger(v.value));
@@ -56,11 +67,13 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
56
67
 
57
68
  lines.push('}');
58
69
 
59
- files.push({
60
- path: `lib/Resource/${name}.php`,
61
- content: lines.join('\n'),
62
- overwriteExisting: true,
63
- });
70
+ if (enumInScope) {
71
+ files.push({
72
+ path: `lib/Resource/${name}.php`,
73
+ content: lines.join('\n'),
74
+ overwriteExisting: true,
75
+ });
76
+ }
64
77
  }
65
78
 
66
79
  return files;
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 {
@@ -41,9 +42,17 @@ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
41
42
  * classes (no sum types), so a discriminated base whose IR fields the
42
43
  * parser stripped (post-allOf-aware detection) gets its original fields
43
44
  * restored to avoid silently dropping variant data.
45
+ *
46
+ * `enums` is forwarded to seed `enrichModelsFromSpec`'s collision set: an
47
+ * inline oneOf enum whose synthetic name (`Parent_field`) snake-collapses
48
+ * onto an existing IR enum (e.g. `DataIntegrationAccessTokenResponse_error`
49
+ * vs `DataIntegrationAccessTokenResponseError`) must NOT spawn a duplicate
50
+ * synthetic. Otherwise both collapse to the same `lib/Resource/X.php` path
51
+ * and the later writer wins by array order — which differs between a full
52
+ * and a scoped (`--services`) run, producing a non-deterministic case order.
44
53
  */
45
- function enrichModelsForPhp(models: Model[]): Model[] {
46
- const enriched = enrichModelsFromSpec(models);
54
+ function enrichModelsForPhp(models: Model[], enums: Enum[]): Model[] {
55
+ const enriched = enrichModelsFromSpec(models, enums);
47
56
  const originalByName = new Map(models.map((m) => [m.name, m]));
48
57
  return enriched.map((m) => {
49
58
  if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
@@ -61,7 +70,7 @@ export const phpEmitter: Emitter = {
61
70
 
62
71
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
63
72
  ensureNamingInitialized(ctx);
64
- return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models), ctx));
73
+ return ensureTrailingNewlines(generateModels(enrichModelsForPhp(models, ctx.spec.enums), ctx));
65
74
  },
66
75
 
67
76
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -100,7 +109,7 @@ export const phpEmitter: Emitter = {
100
109
  },
101
110
 
102
111
  fileHeader(): string {
103
- return '<?php\n\ndeclare(strict_types=1);\n\n// This file is auto-generated by oagen. Do not edit.';
112
+ return `<?php\n\ndeclare(strict_types=1);\n\n// ${AUTOGEN_NOTICE}`;
104
113
  },
105
114
 
106
115
  formatCommand(targetDir: string): FormatCommand | null {
package/src/php/models.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Model, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
3
- import { className, enumClassName, fieldName } from './naming.js';
3
+ import { className, enumClassName, domainFieldName } from './naming.js';
4
4
  import { phpDocComment } from './utils.js';
5
5
 
6
6
  // Import and re-export shared model detection utilities
@@ -10,6 +10,7 @@ import {
10
10
  collectNonPaginatedResponseModelNames,
11
11
  collectReferencedListMetadataModels,
12
12
  } from '../shared/model-utils.js';
13
+ import { isModelInScope } from '../shared/resolved-ops.js';
13
14
  export { isListMetadataModel, isListWrapperModel };
14
15
 
15
16
  /**
@@ -67,7 +68,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
67
68
  // Deduplicate fields that map to the same PHP name
68
69
  const seenNames = new Set<string>();
69
70
  const allFields = [...requiredFields, ...optionalFields].filter((f) => {
70
- const phpName = fieldName(f.name);
71
+ // DOMAIN identifier: the PHP property name (honors a `domainName` override).
72
+ const phpName = domainFieldName(f);
71
73
  if (seenNames.has(phpName)) return false;
72
74
  seenNames.add(phpName);
73
75
  return true;
@@ -75,7 +77,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
75
77
 
76
78
  for (let i = 0; i < allFields.length; i++) {
77
79
  const field = allFields[i];
78
- const phpName = fieldName(field.name);
80
+ // DOMAIN identifier: the promoted constructor property name.
81
+ const phpName = domainFieldName(field);
79
82
  const phpType = mapTypeRef(field.type);
80
83
  const isOptional = !field.required;
81
84
  const comma = i < allFields.length - 1 ? ',' : ',';
@@ -108,7 +111,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
108
111
  lines.push(` return new self(`);
109
112
  for (let i = 0; i < allFields.length; i++) {
110
113
  const field = allFields[i];
111
- const phpName = fieldName(field.name);
114
+ // DOMAIN identifier: the named constructor argument (PHP property).
115
+ const phpName = domainFieldName(field);
116
+ // WIRE key: the JSON key read from `$data[...]` (stays `field.name`).
112
117
  const wireName = field.name;
113
118
  const comma = i < allFields.length - 1 ? ',' : ',';
114
119
  const accessor = generateFromArrayAccessor(field.type, wireName, field.required);
@@ -124,7 +129,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
124
129
  lines.push(' {');
125
130
  lines.push(' return [');
126
131
  for (const field of allFields) {
127
- const phpName = fieldName(field.name);
132
+ // DOMAIN identifier: the `$this->...` property being serialized.
133
+ const phpName = domainFieldName(field);
134
+ // WIRE key: the JSON key emitted into the array (stays `field.name`).
128
135
  const wireName = field.name;
129
136
  const serialized = generateToArrayValue(field.type, `$this->${phpName}`, !field.required);
130
137
  lines.push(` '${wireName}' => ${serialized},`);
@@ -134,11 +141,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
134
141
 
135
142
  lines.push('}');
136
143
 
137
- files.push({
138
- path: `lib/Resource/${name}.php`,
139
- content: lines.join('\n'),
140
- overwriteExisting: true,
141
- });
144
+ // FR-1.4: write the per-model FILE only when in scope. PHP uses PSR-4
145
+ // (one class per file under lib/Resource/, no barrel/index), so an
146
+ // out-of-scope model is simply left untouched on disk and stays loadable.
147
+ if (isModelInScope(model.name, ctx)) {
148
+ files.push({
149
+ path: `lib/Resource/${name}.php`,
150
+ content: lines.join('\n'),
151
+ overwriteExisting: true,
152
+ });
153
+ }
142
154
  }
143
155
 
144
156
  return files;
package/src/php/naming.ts CHANGED
@@ -138,6 +138,16 @@ export function fieldName(name: string): string {
138
138
  return toCamelCase(name);
139
139
  }
140
140
 
141
+ /**
142
+ * camelCase DOMAIN property name for a model field, honoring a `domainName`
143
+ * override (set via the `fieldHints` config) so a wire field can surface under
144
+ * a friendlier PHP property name. The wire key (see {@link wireName}) still
145
+ * derives from `field.name`. No-op when `domainName` is unset.
146
+ */
147
+ export function domainFieldName(field: { name: string; domainName?: string }): string {
148
+ return fieldName(field.domainName ?? field.name);
149
+ }
150
+
141
151
  /** snake_case name for fixtures and other snake_case contexts. */
142
152
  export function snakeName(name: string): string {
143
153
  return toSnakeCase(stripUrnPrefix(name));