@workos/oagen-emitters 0.3.0 → 0.5.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/node/tests.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  servicePropertyName,
10
10
  resolveMethodName,
11
11
  resolveInterfaceName,
12
+ wireInterfaceName,
12
13
  } from './naming.js';
13
14
  import { generateFixtures } from './fixtures.js';
14
15
  import { resolveResourceClassName } from './resources.js';
@@ -19,6 +20,8 @@ import {
19
20
  relativeImport,
20
21
  isListMetadataModel,
21
22
  isListWrapperModel,
23
+ modelHasNewFields,
24
+ computeNonEventReachable,
22
25
  } from './utils.js';
23
26
  import { groupByMount } from '../shared/resolved-ops.js';
24
27
 
@@ -28,7 +31,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
28
31
  // Generate fixture JSON files
29
32
  const fixtures = generateFixtures(spec, ctx);
30
33
  for (const f of fixtures) {
31
- files.push({ path: f.path, content: f.content, headerPlacement: 'skip', integrateTarget: false });
34
+ files.push({ path: f.path, content: f.content, headerPlacement: 'skip', skipIfExists: true });
32
35
  }
33
36
 
34
37
  // Build model lookup for response field assertions
@@ -53,11 +56,33 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
53
56
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
54
57
  : spec.services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
55
58
 
59
+ // When integrating into an existing SDK, only generate tests for services
60
+ // that have a registered property on the WorkOS class. The WorkOS client
61
+ // file uses skipIfExists, so new service properties aren't added — tests
62
+ // referencing workos.<service> would fail at compile time.
63
+ const baselineWorkOSProps = new Set<string>();
64
+ if (ctx.apiSurface?.classes?.['WorkOS']?.methods) {
65
+ // The extractor stores property accessors as methods
66
+ for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].methods)) {
67
+ baselineWorkOSProps.add(name);
68
+ }
69
+ }
70
+ if (ctx.apiSurface?.classes?.['WorkOS']?.properties) {
71
+ for (const name of Object.keys(ctx.apiSurface.classes['WorkOS'].properties)) {
72
+ baselineWorkOSProps.add(name);
73
+ }
74
+ }
75
+
56
76
  for (const { name: mountName, operations } of testEntries) {
57
77
  if (operations.length === 0) continue;
58
78
  const mergedService: Service = { name: mountName, operations };
59
79
  const ops = uncoveredOperations(mergedService, ctx);
60
80
  if (ops.length === 0) continue;
81
+
82
+ // Skip tests for services without a WorkOS property in the baseline
83
+ const propName = mountAccessors.get(mountName) ?? servicePropertyName(mountName);
84
+ if (ctx.apiSurface && baselineWorkOSProps.size > 0 && !baselineWorkOSProps.has(propName)) continue;
85
+
61
86
  const testService = ops.length < operations.length ? { ...mergedService, operations: ops } : mergedService;
62
87
  files.push(generateServiceTest(testService, spec, ctx, modelMap, mountAccessors));
63
88
  }
@@ -154,7 +179,7 @@ function generateServiceTest(
154
179
  // List fixtures are always generated in the current service's directory
155
180
  // (the service owning the list operation), not in the model's home service.
156
181
  // Always use a local import path.
157
- const fixturePath = `./fixtures/list-${fileName(itemModelName)}.fixture.json`;
182
+ const fixturePath = `./fixtures/list-${fileName(itemModelName)}.json`;
158
183
  fixtureImports.add(`import list${itemModelName}Fixture from '${fixturePath}';`);
159
184
  }
160
185
  } else if (plan.responseModelName) {
@@ -162,8 +187,8 @@ function generateServiceTest(
162
187
  const respDir = resolveDir(respService);
163
188
  const fixturePath =
164
189
  respDir === serviceDir
165
- ? `./fixtures/${fileName(plan.responseModelName)}.fixture.json`
166
- : `../${respDir}/fixtures/${fileName(plan.responseModelName)}.fixture.json`;
190
+ ? `./fixtures/${fileName(plan.responseModelName)}.json`
191
+ : `../${respDir}/fixtures/${fileName(plan.responseModelName)}.json`;
167
192
  fixtureImports.add(`import ${toCamelCase(plan.responseModelName)}Fixture from '${fixturePath}';`);
168
193
  }
169
194
  // NOTE: Request body fixtures are not imported for body tests because
@@ -215,7 +240,7 @@ function generateServiceTest(
215
240
 
216
241
  lines.push('});');
217
242
 
218
- return { path: testPath, content: lines.join('\n'), skipIfExists: true };
243
+ return { path: testPath, content: lines.join('\n'), overwriteExisting: true };
219
244
  }
220
245
 
221
246
  /** Compute the test value for a single path parameter.
@@ -751,9 +776,21 @@ function buildTestPayload(
751
776
  op: Operation,
752
777
  modelMap: Map<string, Model>,
753
778
  ): { camelCaseObj: string; snakeCaseObj: string } | null {
754
- if (!op.requestBody || op.requestBody.kind !== 'model') return null;
755
-
756
- const model = modelMap.get(op.requestBody.name);
779
+ if (!op.requestBody) return null;
780
+
781
+ // For discriminated unions, build a payload from the first variant so the
782
+ // generated test produces a value that satisfies the union type. Without
783
+ // this, emitted tests pass `{} as any` for union bodies — fine for older
784
+ // permissive runtimes, but the dispatch switch now throws on unknown
785
+ // discriminator values, so the tests would fail before hitting fetch.
786
+ let model: Model | undefined;
787
+ if (op.requestBody.kind === 'union') {
788
+ const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
789
+ if (!firstVariant || firstVariant.kind !== 'model') return null;
790
+ model = modelMap.get(firstVariant.name);
791
+ } else if (op.requestBody.kind === 'model') {
792
+ model = modelMap.get(op.requestBody.name);
793
+ }
757
794
  if (!model) return null;
758
795
 
759
796
  const fields = model.fields.filter((f) => f.required);
@@ -789,8 +826,14 @@ function buildTestPayload(
789
826
  * fall back to `{} as any` to bypass type checking for complex required fields.
790
827
  */
791
828
  function fallbackBodyArg(op: Operation, modelMap: Map<string, Model>): string {
792
- if (!op.requestBody || op.requestBody.kind !== 'model') return '{} as any';
793
- const model = modelMap.get(op.requestBody.name);
829
+ if (!op.requestBody) return '{} as any';
830
+ let model: Model | undefined;
831
+ if (op.requestBody.kind === 'union') {
832
+ const firstVariant = op.requestBody.variants.find((v) => v.kind === 'model');
833
+ if (firstVariant && firstVariant.kind === 'model') model = modelMap.get(firstVariant.name);
834
+ } else if (op.requestBody.kind === 'model') {
835
+ model = modelMap.get(op.requestBody.name);
836
+ }
794
837
  if (!model) return '{} as any';
795
838
  const hasRequiredFields = model.fields.some((f) => f.required);
796
839
  return hasRequiredFields ? '{} as any' : '{}';
@@ -821,12 +864,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
821
864
 
822
865
  // Only generate round-trip tests for models with fields that have serializers generated.
823
866
  // Skip list metadata and list wrapper models since their serializers are not emitted.
867
+ // Skip models unchanged from baseline (no new fields) since their serializers are not regenerated.
868
+ // Skip models unreachable from non-event services (no model/serializer files generated).
869
+ const nonEventReachable = computeNonEventReachable(spec.services, spec.models);
824
870
  const eligibleModels = spec.models.filter(
825
- (m) => modelNeedsRoundTripTest(m) && !isListMetadataModel(m) && !isListWrapperModel(m),
871
+ (m) =>
872
+ nonEventReachable.has(m.name) &&
873
+ modelNeedsRoundTripTest(m) &&
874
+ !isListMetadataModel(m) &&
875
+ !isListWrapperModel(m) &&
876
+ modelHasNewFields(m, ctx),
826
877
  );
827
878
 
828
879
  if (eligibleModels.length === 0) return files;
829
880
 
881
+ // Use the skipped-serialize set computed by the serializer generator.
882
+ // It's stashed on the context during generateSerializers.
883
+ const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
884
+
830
885
  // Group eligible models by service directory for one test file per service
831
886
  const modelsByDir = new Map<string, Model[]>();
832
887
  for (const model of eligibleModels) {
@@ -844,6 +899,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
844
899
 
845
900
  // Collect imports
846
901
  const serializerImports: string[] = [];
902
+ const interfaceImports: string[] = [];
847
903
  const fixtureImports: string[] = [];
848
904
 
849
905
  for (const model of models) {
@@ -851,18 +907,30 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
851
907
  const service = modelToService.get(model.name);
852
908
  const modelDir = resolveDir(service);
853
909
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
854
- const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.fixture.json`;
910
+ const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
911
+ const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
855
912
 
856
- serializerImports.push(
857
- `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
858
- );
859
- const camelName = domainName.charAt(0).toLowerCase() + domainName.slice(1);
913
+ if (serializeSkipped.has(model.name)) {
914
+ serializerImports.push(
915
+ `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
916
+ );
917
+ } else {
918
+ serializerImports.push(
919
+ `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
920
+ );
921
+ }
922
+ const wireName = wireInterfaceName(domainName);
923
+ interfaceImports.push(`import type { ${wireName} } from '${relativeImport(testPath, interfacePath)}';`);
924
+ const camelName = toCamelCase(domainName);
860
925
  fixtureImports.push(`import ${camelName}Fixture from '${relativeImport(testPath, fixturePath)}';`);
861
926
  }
862
927
 
863
928
  for (const imp of serializerImports) {
864
929
  lines.push(imp);
865
930
  }
931
+ for (const imp of interfaceImports) {
932
+ lines.push(imp);
933
+ }
866
934
  for (const imp of fixtureImports) {
867
935
  lines.push(imp);
868
936
  }
@@ -870,25 +938,36 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
870
938
 
871
939
  for (const model of models) {
872
940
  const domainName = resolveInterfaceName(model.name, ctx);
873
- const camelDomain = domainName.charAt(0).toLowerCase() + domainName.slice(1);
874
- const fixtureName = `${camelDomain}Fixture`;
875
-
876
- lines.push(`describe('${domainName}Serializer', () => {`);
877
- lines.push(" it('round-trips through serialize/deserialize', () => {");
878
- lines.push(` const fixture = ${fixtureName};`);
879
- lines.push(` const deserialized = deserialize${domainName}(fixture);`);
880
- lines.push(` const reserialized = serialize${domainName}(deserialized);`);
881
- lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
882
- lines.push(' });');
883
- lines.push('});');
941
+ const fixtureName = `${toCamelCase(domainName)}Fixture`;
942
+ const wireName = wireInterfaceName(domainName);
943
+
944
+ if (serializeSkipped.has(model.name)) {
945
+ // Deserialize-only test (no serialize function available)
946
+ lines.push(`describe('${domainName}Serializer', () => {`);
947
+ lines.push(" it('deserializes correctly', () => {");
948
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
949
+ lines.push(` const deserialized = deserialize${domainName}(fixture);`);
950
+ lines.push(' expect(deserialized).toBeDefined();');
951
+ lines.push(' });');
952
+ lines.push('});');
953
+ } else {
954
+ // Round-trip test
955
+ lines.push(`describe('${domainName}Serializer', () => {`);
956
+ lines.push(" it('round-trips through serialize/deserialize', () => {");
957
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
958
+ lines.push(` const deserialized = deserialize${domainName}(fixture);`);
959
+ lines.push(` const reserialized = serialize${domainName}(deserialized);`);
960
+ lines.push(' expect(reserialized).toEqual(expect.objectContaining(fixture));');
961
+ lines.push(' });');
962
+ lines.push('});');
963
+ }
884
964
  lines.push('');
885
965
  }
886
966
 
887
967
  files.push({
888
968
  path: testPath,
889
969
  content: lines.join('\n'),
890
- skipIfExists: true,
891
- integrateTarget: false,
970
+ overwriteExisting: true,
892
971
  });
893
972
  }
894
973
 
@@ -3,7 +3,6 @@ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
3
  import { wireInterfaceName } from './naming.js';
4
4
 
5
5
  export interface MapTypeRefOpts {
6
- stringDates?: boolean;
7
6
  /** Map from model name → default type args (e.g., `'<Record<string, unknown>>'`).
8
7
  * When present, model refs for generic models get their defaults appended. */
9
8
  genericDefaults?: Map<string, string>;
@@ -13,16 +12,12 @@ export interface MapTypeRefOpts {
13
12
  * Map an IR TypeRef to a TypeScript domain type string.
14
13
  * Domain types use PascalCase model names (e.g., `Organization`).
15
14
  *
16
- * @param opts.stringDates - When true, map `date-time` to `string` instead of `Date`.
17
- * Use this when integrating into an existing SDK that represents timestamps as
18
- * ISO 8601 strings rather than Date objects.
19
15
  * @param opts.genericDefaults - When present, appends default type args to generic model refs.
20
16
  */
21
17
  export function mapTypeRef(ref: TypeRef, opts?: MapTypeRefOpts): string {
22
- const primMapper = opts?.stringDates ? mapPrimitiveStringDates : mapPrimitive;
23
18
  const genericDefaults = opts?.genericDefaults;
24
19
  return irMapTypeRef<string>(ref, {
25
- primitive: primMapper,
20
+ primitive: mapPrimitive,
26
21
  array: (_r, items) => `${parenthesizeUnion(items)}[]`,
27
22
  model: (r) => r.name + (genericDefaults?.get(r.name) ?? ''),
28
23
  enum: (r) => r.name,
@@ -74,31 +69,6 @@ function mapPrimitive(ref: PrimitiveType): string {
74
69
  }
75
70
  }
76
71
 
77
- /**
78
- * Map a primitive type using string representation for dates.
79
- * Used when the existing SDK represents timestamps as ISO 8601 strings.
80
- */
81
- function mapPrimitiveStringDates(ref: PrimitiveType): string {
82
- if (ref.format) {
83
- switch (ref.format) {
84
- case 'int64':
85
- return 'bigint';
86
- // date-time intentionally falls through to the string case
87
- }
88
- }
89
- switch (ref.type) {
90
- case 'string':
91
- return 'string';
92
- case 'integer':
93
- case 'number':
94
- return 'number';
95
- case 'boolean':
96
- return 'boolean';
97
- case 'unknown':
98
- return 'any';
99
- }
100
- }
101
-
102
72
  /**
103
73
  * Map an IR PrimitiveType to a TypeScript wire/JSON type string.
104
74
  * Wire types match JSON encoding: date-time stays string, int64 stays string/number.
package/src/node/utils.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Model, EmitterContext, Service, Operation } from '@workos/oagen';
1
+ import type { Model, EmitterContext, Service, Operation, TypeRef } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
3
  export {
4
4
  collectModelRefs,
@@ -269,17 +269,31 @@ function modelFingerprint(model: Model): string {
269
269
  *
270
270
  * Returns a Map from duplicate model name → canonical model name.
271
271
  */
272
- export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Map<string, string> {
272
+ export function buildDeduplicationMap(
273
+ models: Model[],
274
+ ctx?: EmitterContext,
275
+ reachable?: Set<string>,
276
+ ): Map<string, string> {
273
277
  const dedup = new Map<string, string>();
274
278
 
275
279
  // Pass 1: structural fingerprint dedup (exact match)
280
+ // When a reachability set is provided, prefer reachable models as canonicals
281
+ // so that aliases always point to models that will actually be generated.
276
282
  const fingerprints = new Map<string, string>();
277
283
  for (const model of models) {
278
284
  if (model.fields.length === 0) continue;
279
285
  const fp = modelFingerprint(model);
280
286
  const existing = fingerprints.get(fp);
281
287
  if (existing) {
282
- dedup.set(model.name, existing);
288
+ // If the existing canonical is unreachable but this model is reachable,
289
+ // swap: make this model the canonical and demote the old one to alias.
290
+ if (reachable && !reachable.has(existing) && reachable.has(model.name)) {
291
+ dedup.delete(existing); // remove stale alias if present
292
+ dedup.set(existing, model.name);
293
+ fingerprints.set(fp, model.name);
294
+ } else {
295
+ dedup.set(model.name, existing);
296
+ }
283
297
  } else {
284
298
  fingerprints.set(fp, model.name);
285
299
  }
@@ -287,7 +301,8 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
287
301
 
288
302
  // Pass 2: name-based dedup for models that resolve to the same interface
289
303
  // name across services. Only applies when context with name resolution is
290
- // available. Picks the model with the most fields as canonical.
304
+ // available. Picks the model with the most fields as canonical, preferring
305
+ // reachable models when a reachability set is provided.
291
306
  if (ctx) {
292
307
  const byDomainName = new Map<string, Model[]>();
293
308
  for (const model of models) {
@@ -303,8 +318,15 @@ export function buildDeduplicationMap(models: Model[], ctx?: EmitterContext): Ma
303
318
  }
304
319
  for (const [, group] of byDomainName) {
305
320
  if (group.length < 2) continue;
306
- // Choose canonical: most fields, then alphabetically by name
307
- group.sort((a, b) => b.fields.length - a.fields.length || a.name.localeCompare(b.name));
321
+ // Choose canonical: prefer reachable, then most fields, then alphabetically
322
+ group.sort((a, b) => {
323
+ if (reachable) {
324
+ const aReach = reachable.has(a.name) ? 0 : 1;
325
+ const bReach = reachable.has(b.name) ? 0 : 1;
326
+ if (aReach !== bReach) return aReach - bReach;
327
+ }
328
+ return b.fields.length - a.fields.length || a.name.localeCompare(b.name);
329
+ });
308
330
  const canonical = group[0];
309
331
  for (let i = 1; i < group.length; i++) {
310
332
  dedup.set(group[i].name, canonical.name);
@@ -397,6 +419,26 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
397
419
  return false;
398
420
  }
399
421
 
422
+ /**
423
+ * Check whether an IR model has fields not present in the baseline interface.
424
+ * Returns true if the model has new fields that need generation.
425
+ * Returns true if no baseline exists (new model entirely).
426
+ */
427
+ export function modelHasNewFields(model: Model, ctx: EmitterContext): boolean {
428
+ if (!ctx.apiSurface?.interfaces) return true; // No surface = generate everything
429
+
430
+ const domainName = resolveInterfaceName(model.name, ctx);
431
+ const baseline = ctx.apiSurface.interfaces[domainName];
432
+ if (!baseline?.fields) return true; // No baseline for this model = new model
433
+
434
+ for (const field of model.fields) {
435
+ const camelName = fieldName(field.name);
436
+ if (!baseline.fields[camelName]) return true; // New field found
437
+ }
438
+
439
+ return false; // All fields exist in baseline
440
+ }
441
+
400
442
  /**
401
443
  * Return operations in a service that are NOT covered by existing hand-written
402
444
  * service classes. For fully uncovered services, returns all operations.
@@ -417,3 +459,51 @@ export function uncoveredOperations(service: Service, ctx: EmitterContext): Oper
417
459
  return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
418
460
  });
419
461
  }
462
+
463
+ /**
464
+ * Compute the set of model names reachable from non-event service operations.
465
+ * The Events service pulls in hundreds of webhook payload models that the
466
+ * existing SDK handles via hand-written event types, so those models are
467
+ * excluded from generation.
468
+ *
469
+ * Shared between model generation, barrel generation, dedup, and tests to
470
+ * ensure consistency: every module agrees on which models will be generated.
471
+ */
472
+ export function computeNonEventReachable(services: Service[], models: Model[]): Set<string> {
473
+ const seeds = new Set<string>();
474
+ for (const svc of services) {
475
+ if (svc.name.toLowerCase() === 'events') continue;
476
+ for (const op of svc.operations) {
477
+ const collectFromRef = (t: TypeRef | undefined): void => {
478
+ if (!t) return;
479
+ if (t.kind === 'model') seeds.add(t.name);
480
+ if (t.kind === 'array') collectFromRef(t.items);
481
+ if (t.kind === 'nullable') collectFromRef(t.inner);
482
+ if (t.kind === 'union') t.variants.forEach(collectFromRef);
483
+ };
484
+ collectFromRef(op.response);
485
+ collectFromRef(op.requestBody);
486
+ if (op.pagination?.itemType) collectFromRef(op.pagination.itemType);
487
+ }
488
+ }
489
+ const modelMap = new Map(models.map((m) => [m.name, m]));
490
+ const reachable = new Set<string>();
491
+ const queue = [...seeds];
492
+ while (queue.length > 0) {
493
+ const name = queue.pop()!;
494
+ if (reachable.has(name)) continue;
495
+ reachable.add(name);
496
+ const m = modelMap.get(name);
497
+ if (!m) continue;
498
+ for (const field of m.fields) {
499
+ const walk = (t: TypeRef): void => {
500
+ if (t.kind === 'model' && !reachable.has(t.name)) queue.push(t.name);
501
+ if (t.kind === 'array') walk(t.items);
502
+ if (t.kind === 'nullable') walk(t.inner);
503
+ if (t.kind === 'union') t.variants.forEach(walk);
504
+ };
505
+ walk(field.type);
506
+ }
507
+ }
508
+ return reachable;
509
+ }
@@ -77,7 +77,37 @@ function emitWrapperMethod(
77
77
  const returnType = responseTypeName ?? 'void';
78
78
 
79
79
  // JSDoc
80
- lines.push(` /** ${formatWrapperDescription(wrapper.name)}. */`);
80
+ const docParts: string[] = [];
81
+ docParts.push(formatWrapperDescription(wrapper.name) + '.');
82
+
83
+ for (const p of op.pathParams) {
84
+ if (p.description) {
85
+ docParts.push(`@param ${fieldName(p.name)} - ${p.description}`);
86
+ }
87
+ }
88
+
89
+ for (const { paramName, field } of wrapperParams) {
90
+ const tsName = fieldName(paramName);
91
+ if (field?.description) {
92
+ docParts.push(`@param ${tsName} - ${field.description}`);
93
+ }
94
+ }
95
+
96
+ if (responseTypeName) {
97
+ docParts.push(`@returns {Promise<${returnType}>}`);
98
+ }
99
+
100
+ if (docParts.length === 1) {
101
+ lines.push(` /** ${docParts[0]} */`);
102
+ } else {
103
+ lines.push(' /**');
104
+ for (const part of docParts) {
105
+ for (const line of part.split('\n')) {
106
+ lines.push(line === '' ? ' *' : ` * ${line}`);
107
+ }
108
+ }
109
+ lines.push(' */');
110
+ }
81
111
 
82
112
  // Method signature
83
113
  lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
package/src/php/client.ts CHANGED
@@ -127,10 +127,13 @@ function generateMainClient(
127
127
  for (const svc of services) {
128
128
  lines.push(` private ?Service\\${svc.name} $${svc.propName} = null;`);
129
129
  }
130
- // Non-spec service properties (hand-maintained modules)
130
+ // Non-spec service properties wrapped in ignore markers so the target
131
+ // SDK can hand-maintain the list. The emitter provides a positional anchor.
132
+ lines.push(' // @oagen-ignore-start — non-spec service properties (hand-maintained)');
131
133
  for (const a of nonSpecAccessors) {
132
134
  lines.push(` private ?${a.className} $${a.propName} = null;`);
133
135
  }
136
+ lines.push(' // @oagen-ignore-end');
134
137
 
135
138
  lines.push('');
136
139
  lines.push(' public function __construct(');
@@ -140,11 +143,12 @@ function generateMainClient(
140
143
  lines.push(' int $timeout = 60,');
141
144
  lines.push(' int $maxRetries = 3,');
142
145
  lines.push(' ?\\GuzzleHttp\\HandlerStack $handler = null,');
146
+ lines.push(' ?string $userAgent = null,');
143
147
  lines.push(' ) {');
144
148
  lines.push(" $apiKey ??= getenv('WORKOS_API_KEY') ?: self::$apiKey ?? '';");
145
149
  lines.push(" $clientId ??= getenv('WORKOS_CLIENT_ID') ?: self::$clientId;");
146
150
  lines.push(
147
- ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler);',
151
+ ' $this->httpClient = new HttpClient($apiKey, $clientId, $baseUrl, $timeout, $maxRetries, $handler, $userAgent);',
148
152
  );
149
153
  lines.push(' }');
150
154
 
@@ -157,7 +161,10 @@ function generateMainClient(
157
161
  lines.push(' }');
158
162
  }
159
163
 
160
- // Non-spec service accessors (hand-maintained modules)
164
+ // Non-spec service accessors wrapped in ignore markers so the target
165
+ // SDK can hand-maintain these. The emitter provides a positional anchor.
166
+ lines.push('');
167
+ lines.push(' // @oagen-ignore-start — non-spec service accessors (hand-maintained)');
161
168
  for (const a of nonSpecAccessors) {
162
169
  lines.push('');
163
170
  lines.push(` public function ${a.propName}(): ${a.className}`);
@@ -165,6 +172,7 @@ function generateMainClient(
165
172
  lines.push(` return $this->${a.propName} ??= new ${a.className}($this->httpClient);`);
166
173
  lines.push(' }');
167
174
  }
175
+ lines.push(' // @oagen-ignore-end');
168
176
 
169
177
  lines.push('}');
170
178
  return lines.join('\n');
package/src/php/models.ts CHANGED
@@ -13,30 +13,6 @@ export { isListMetadataModel, isListWrapperModel };
13
13
  export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
14
14
  if (models.length === 0) return [];
15
15
 
16
- // Build structural hash for deduplication
17
- const modelHashMap = new Map<string, string>();
18
- const hashGroups = new Map<string, string[]>();
19
- for (const model of models) {
20
- if (isListMetadataModel(model)) continue;
21
- if (isListWrapperModel(model)) continue;
22
- const hash = structuralHash(model);
23
- modelHashMap.set(model.name, hash);
24
- if (!hashGroups.has(hash)) hashGroups.set(hash, []);
25
- hashGroups.get(hash)!.push(model.name);
26
- }
27
-
28
- // Pick canonical for each duplicate group (shortest class name wins)
29
- const aliasOf = new Map<string, string>();
30
- for (const [hash, names] of hashGroups) {
31
- if (names.length <= 1) continue;
32
- if (hash === '') continue;
33
- const sorted = [...names].sort((a, b) => className(a).length - className(b).length);
34
- const canonical = sorted[0];
35
- for (let i = 1; i < sorted.length; i++) {
36
- aliasOf.set(sorted[i], canonical);
37
- }
38
- }
39
-
40
16
  const files: GeneratedFile[] = [];
41
17
 
42
18
  // Emit shared JsonSerializableTrait once
@@ -59,8 +35,6 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
59
35
  for (const model of models) {
60
36
  if (isListMetadataModel(model)) continue;
61
37
  if (isListWrapperModel(model)) continue;
62
- if (aliasOf.has(model.name)) continue; // skip structural duplicates
63
-
64
38
  const name = className(model.name);
65
39
  const lines: string[] = [];
66
40
 
@@ -301,10 +275,3 @@ function needsVarAnnotation(ref: TypeRef): boolean {
301
275
  return false;
302
276
  }
303
277
  }
304
-
305
- function structuralHash(model: Model): string {
306
- return model.fields
307
- .map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`)
308
- .sort()
309
- .join('|');
310
- }
package/src/php/naming.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
- import { stripUrnPrefix } from '../shared/naming-utils.js';
4
+ import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
5
5
 
6
6
  /** Namespace grouping result (shared with client.ts). */
7
7
  export interface NamespaceGroup {
@@ -16,22 +16,6 @@ export interface NamespaceGrouping {
16
16
  namespaces: NamespaceGroup[];
17
17
  }
18
18
 
19
- /**
20
- * Map of lowercase acronym forms to their correct casing.
21
- */
22
- const ACRONYM_FIXES: [RegExp, string][] = [
23
- [/Workos/g, 'WorkOS'],
24
- [/Sso/g, 'SSO'],
25
- [/Mfa/g, 'MFA'],
26
- [/Jwt/g, 'JWT'],
27
- [/Cors/g, 'CORS'],
28
- [/Saml/g, 'SAML'],
29
- [/Scim/g, 'SCIM'],
30
- [/Rbac/g, 'RBAC'],
31
- [/Oauth/g, 'OAuth'],
32
- [/Oidc/g, 'OIDC'],
33
- ];
34
-
35
19
  /**
36
20
  * PHP reserved class names that would collide with builtins.
37
21
  */
@@ -99,10 +83,7 @@ export function enumClassName(name: string): string {
99
83
 
100
84
  /** PascalCase class name with acronym preservation. */
101
85
  export function className(name: string): string {
102
- let result = toPascalCase(stripUrnPrefix(name));
103
- for (const [pattern, replacement] of ACRONYM_FIXES) {
104
- result = result.replace(pattern, replacement);
105
- }
86
+ let result = applyAcronymFixes(toPascalCase(stripUrnPrefix(name)));
106
87
  if (PHP_RESERVED_CLASS_NAMES.has(result)) {
107
88
  result += 'Model';
108
89
  }