@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
@@ -0,0 +1,323 @@
1
+ import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { walkTypeRef } from '@workos/oagen';
3
+ import { className, deprecationMessage, escapeCsAttributeString, humanize } from './naming.js';
4
+ import { setEnumAliases, setSingleValueEnumNames } from './type-map.js';
5
+ import { enrichModelsFromSpec } from '../shared/model-utils.js';
6
+
7
+ /**
8
+ * Generate C# enum definitions from IR Enum definitions.
9
+ * Each enum becomes a separate .cs file. Structurally-identical enums are
10
+ * deduplicated: only the canonical (alphabetically-first) name is emitted,
11
+ * and every reference to a duplicate enum is rewritten to the canonical one
12
+ * by `mapTypeRef` via `setEnumAliases`.
13
+ */
14
+ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
15
+ if (enums.length === 0) return [];
16
+
17
+ // Publish the alias map + single-value enum set so model/options/wrapper
18
+ // emitters all resolve duplicate enum references to the canonical name and
19
+ // rewrite 1-value enum refs to `string`.
20
+ const aliasOf = collectEnumAliasOf(enums);
21
+ setEnumAliases(aliasOf);
22
+ setSingleValueEnumNames(enums.filter((e) => e.values.length === 1).map((e) => e.name));
23
+ diagnoseDivergentEnums(enums);
24
+
25
+ // Collect all enum names actually referenced by models and operations so we
26
+ // can suppress orphan enums that would otherwise be emitted but never used.
27
+ const referencedEnums = collectReferencedEnumNames(ctx);
28
+
29
+ const files: GeneratedFile[] = [];
30
+
31
+ for (const enumDef of enums) {
32
+ const typeName = className(enumDef.name);
33
+
34
+ // Skip duplicate enums — their references are retargeted to the canonical.
35
+ if (aliasOf.has(enumDef.name)) continue;
36
+
37
+ // Skip empty and single-value enums — the single-value case is a discriminator
38
+ // masquerading as an enum, and mapTypeRef rewrites such refs to `string` with
39
+ // a const initializer on the owning property.
40
+ if (enumDef.values.length <= 1) continue;
41
+
42
+ // Skip orphan enums that are not referenced by any model field or operation
43
+ // parameter. Resolve aliases so that a canonical enum is kept if any of its
44
+ // aliases are referenced.
45
+ const isReferenced =
46
+ referencedEnums.has(enumDef.name) ||
47
+ [...aliasOf.entries()].some(([alias, canon]) => canon === enumDef.name && referencedEnums.has(alias));
48
+ if (!isReferenced) continue;
49
+
50
+ // Deduplicate values
51
+ const seenValues = new Set<string>();
52
+ const uniqueValues: typeof enumDef.values = [];
53
+ for (const v of enumDef.values) {
54
+ const vs = String(v.value);
55
+ if (!seenValues.has(vs)) {
56
+ seenValues.add(vs);
57
+ uniqueValues.push(v);
58
+ }
59
+ }
60
+
61
+ const lines: string[] = [];
62
+ lines.push(`namespace ${ctx.namespacePascal}`);
63
+ lines.push('{');
64
+ lines.push(' using System.Runtime.Serialization;');
65
+ lines.push(' using Newtonsoft.Json;');
66
+ lines.push(' using STJS = System.Text.Json.Serialization;');
67
+ lines.push('');
68
+ lines.push(` /// <summary>Represents ${humanize(enumDef.name)} values.</summary>`);
69
+ lines.push(' [JsonConverter(typeof(WorkOSNewtonsoftStringEnumConverter))]');
70
+ lines.push(' [STJS.JsonConverter(typeof(WorkOSStringEnumConverterFactory))]');
71
+ lines.push(` public enum ${typeName}`);
72
+ lines.push(' {');
73
+ // Unknown sentinel as first member (value 0) for forward-compatibility
74
+ lines.push(` [EnumMember(Value = "unknown")]`);
75
+ lines.push(` Unknown,`);
76
+ lines.push('');
77
+
78
+ const usedNames = new Set<string>();
79
+ usedNames.add('Unknown');
80
+ // Track used EnumMember wire values to avoid duplicates (sentinel uses "unknown")
81
+ const usedWireValues = new Set<string>();
82
+ usedWireValues.add('unknown');
83
+ for (let i = 0; i < uniqueValues.length; i++) {
84
+ const v = uniqueValues[i];
85
+ // Skip values whose wire representation collides with the sentinel
86
+ if (usedWireValues.has(String(v.value))) continue;
87
+ usedWireValues.add(String(v.value));
88
+ let memberName = className(String(v.value));
89
+ // Avoid collision with the type itself or previously used names
90
+ if (memberName === typeName || usedNames.has(memberName)) {
91
+ let suffix = 2;
92
+ while (usedNames.has(`${memberName}${suffix}`)) suffix++;
93
+ memberName = `${memberName}${suffix}`;
94
+ }
95
+ usedNames.add(memberName);
96
+
97
+ if (v.description) {
98
+ lines.push(` /// <summary>${escapeXml(v.description)}</summary>`);
99
+ }
100
+ if (v.deprecated) {
101
+ const msg = escapeCsAttributeString(deprecationMessage(v.description, 'value'));
102
+ lines.push(` [System.Obsolete("${msg}")]`);
103
+ }
104
+ lines.push(` [EnumMember(Value = "${v.value}")]`);
105
+ const comma = i < uniqueValues.length - 1 ? ',' : ',';
106
+ lines.push(` ${memberName}${comma}`);
107
+ }
108
+
109
+ lines.push(' }');
110
+ lines.push('}');
111
+
112
+ files.push({
113
+ path: `Enums/${typeName}.cs`,
114
+ content: lines.join('\n'),
115
+ overwriteExisting: true,
116
+ });
117
+ }
118
+
119
+ return files;
120
+ }
121
+
122
+ function escapeXml(s: string): string {
123
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
124
+ }
125
+
126
+ /**
127
+ * Populate the module-level enum alias resolver from the given spec's enums.
128
+ * Call from every emitter entrypoint that uses `mapTypeRef` so enum
129
+ * references resolve to their canonical names regardless of which emitter
130
+ * phase runs first.
131
+ */
132
+ export function primeEnumAliases(enums: Enum[]): void {
133
+ setEnumAliases(collectEnumAliasOf(enums));
134
+ setSingleValueEnumNames(enums.filter((e) => e.values.length === 1).map((e) => e.name));
135
+ }
136
+
137
+ /**
138
+ * Warn when two enums share a trailing stem (e.g., `ConnectionType`) but
139
+ * carry *contradictory* wire values — each side has values the other lacks.
140
+ * Pure subset relationships (event-specific payloads that narrow a shared
141
+ * base set — the usual shape of WorkOS event enums) are legitimate and not
142
+ * flagged. Only true divergences, which typically indicate spec drift, are
143
+ * reported.
144
+ */
145
+ export function diagnoseDivergentEnums(enums: Enum[]): void {
146
+ const byStem = new Map<string, Enum[]>();
147
+ for (const e of enums) {
148
+ if (e.values.length < 2) continue;
149
+ const stem = trailingPascalStem(e.name);
150
+ if (!stem) continue;
151
+ if (!byStem.has(stem)) byStem.set(stem, []);
152
+ byStem.get(stem)!.push(e);
153
+ }
154
+
155
+ for (const [stem, group] of byStem) {
156
+ if (group.length < 2) continue;
157
+ // Skip generic event-payload stems (`DataState`, `DataStatus`,
158
+ // `DataActorSource`, …). Many unrelated event subtypes expose a
159
+ // `data.{state,status}` field whose allowed values are scoped per
160
+ // event; grouping them by the generic `Data*` suffix surfaces
161
+ // false-positive divergences, not real drift.
162
+ if (stem.startsWith('Data')) continue;
163
+ // Identical value sets are handled by the alias/dedupe pass.
164
+ const distinctSigs = new Set(group.map(valueSignature));
165
+ if (distinctSigs.size === 1) continue;
166
+ // Only warn when some pair is *mutually exclusive*: each enum has a
167
+ // value the other doesn't. Subsets are not drift.
168
+ if (!hasMutuallyExclusivePair(group)) continue;
169
+ const summary = group.map((e) => `${e.name}[${e.values.length}]`).join(', ');
170
+ console.warn(`[oagen:dotnet] Divergent enums sharing stem "${stem}": ${summary}`);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * True if some pair in the group both:
176
+ * (a) shares enough values to plausibly be the same concept, AND
177
+ * (b) has values exclusive to each side (i.e., is not a subset/equal).
178
+ * Sharing the trailing stem alone is insufficient — `DataStatus` shows up on
179
+ * unrelated domains (connection link state vs. membership state) that only
180
+ * coincidentally overlap on a sentinel like `unknown`. Require a non-trivial
181
+ * intersection before treating two enums as the same logical enum.
182
+ */
183
+ function hasMutuallyExclusivePair(group: Enum[]): boolean {
184
+ // Compare case-insensitively so cosmetic drift like `GitHubOAuth` vs
185
+ // `GithubOAuth` — an intentional historical spelling carried forward by
186
+ // the spec — doesn't register as divergence.
187
+ const valueSets = group.map((e) => new Set(e.values.map((v) => String(v.value).toLowerCase())));
188
+ for (let i = 0; i < valueSets.length; i++) {
189
+ for (let j = i + 1; j < valueSets.length; j++) {
190
+ const a = valueSets[i];
191
+ const b = valueSets[j];
192
+ // Concept-similarity gate: two enums are treated as the same logical
193
+ // enum only when they share >= 3 values AND those shared values
194
+ // dominate both sides (>= 50% of the larger set). A handful of
195
+ // shared sentinels (`unknown`, `pending`) recurs across unrelated
196
+ // domains (`AuthMethod` on Radar assessments vs. sessions: only
197
+ // `password`/`passkey`/`sso`/`unknown` overlap out of ~15 total), and
198
+ // that sparse overlap shouldn't count as drift.
199
+ let shared = 0;
200
+ for (const v of a) {
201
+ if (b.has(v)) shared++;
202
+ }
203
+ if (shared < 3) continue;
204
+ const larger = Math.max(a.size, b.size);
205
+ if (larger === 0 || shared / larger < 0.5) continue;
206
+ // Both sides must have values the other lacks — a subset is legitimate
207
+ // narrowing, not drift.
208
+ let aHasExtra = false;
209
+ for (const v of a) {
210
+ if (!b.has(v)) {
211
+ aHasExtra = true;
212
+ break;
213
+ }
214
+ }
215
+ if (!aHasExtra) continue;
216
+ for (const v of b) {
217
+ if (!a.has(v)) return true;
218
+ }
219
+ }
220
+ }
221
+ return false;
222
+ }
223
+
224
+ function trailingPascalStem(name: string): string | null {
225
+ // Extract the last two PascalCase segments so that `SSOConnectionType`
226
+ // and `ConnectionFindResponseConnectionType` both map to `ConnectionType`.
227
+ const segments = name.match(/[A-Z]+[a-z0-9]*/g);
228
+ if (!segments || segments.length < 2) return null;
229
+ return segments.slice(-2).join('');
230
+ }
231
+
232
+ function valueSignature(e: Enum): string {
233
+ return [...e.values]
234
+ .map((v) => String(v.value))
235
+ .sort()
236
+ .join('|');
237
+ }
238
+
239
+ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
240
+ const hashGroups = new Map<string, string[]>();
241
+ for (const enumDef of enums) {
242
+ const hash = [...enumDef.values]
243
+ .map((v) => String(v.value))
244
+ .sort()
245
+ .join('|');
246
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
247
+ hashGroups.get(hash)!.push(enumDef.name);
248
+ }
249
+
250
+ const aliasOf = new Map<string, string>();
251
+ for (const [, names] of hashGroups) {
252
+ if (names.length <= 1) continue;
253
+ const sorted = [...names].sort();
254
+ const canonical = sorted[0];
255
+ for (let i = 1; i < sorted.length; i++) {
256
+ aliasOf.set(sorted[i], canonical);
257
+ }
258
+ }
259
+ return aliasOf;
260
+ }
261
+
262
+ /**
263
+ * Collect all enum names referenced anywhere in the spec — model fields,
264
+ * operation params, request bodies, and responses. Used to prune orphan
265
+ * enums that the IR extracted from the spec but that no generated code
266
+ * actually references.
267
+ */
268
+ function collectReferencedEnumNames(ctx: EmitterContext): Set<string> {
269
+ const refs = new Set<string>();
270
+ const collect = (ref: any) => {
271
+ walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
272
+ };
273
+ // Walk the enriched models (which include synthetic fields from oneOf
274
+ // flattening) so synthetic enums are also counted as referenced.
275
+ const enrichedModels = enrichModelsFromSpec(ctx.spec.models);
276
+ for (const model of enrichedModels) {
277
+ for (const field of model.fields) {
278
+ collect(field.type);
279
+ }
280
+ }
281
+ for (const service of ctx.spec.services) {
282
+ for (const op of service.operations) {
283
+ if (op.requestBody) collect(op.requestBody);
284
+ if (op.response) collect(op.response);
285
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
286
+ collect(p.type);
287
+ }
288
+ }
289
+ }
290
+ return refs;
291
+ }
292
+
293
+ /** Get the canonical enum name if the given enum is an alias. */
294
+ export function resolveEnumName(name: string, enums: Enum[]): string {
295
+ const aliasOf = collectEnumAliasOf(enums);
296
+ return aliasOf.get(name) ? className(aliasOf.get(name)!) : className(name);
297
+ }
298
+
299
+ export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
300
+ const enumToService = new Map<string, string>();
301
+ const enumNames = new Set(enums.map((e) => e.name));
302
+
303
+ for (const service of services) {
304
+ for (const op of service.operations) {
305
+ const refs = new Set<string>();
306
+ const collect = (ref: any) => {
307
+ walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
308
+ };
309
+ if (op.requestBody) collect(op.requestBody);
310
+ collect(op.response);
311
+ for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
312
+ collect(p.type);
313
+ }
314
+ for (const name of refs) {
315
+ if (enumNames.has(name) && !enumToService.has(name)) {
316
+ enumToService.set(name, service.name);
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ return enumToService;
323
+ }
@@ -0,0 +1,236 @@
1
+ import type { Model, TypeRef, Enum } from '@workos/oagen';
2
+ import { fixtureFileName, fieldName } from './naming.js';
3
+ import { isListMetadataModel, isListWrapperModel } from './models.js';
4
+
5
+ /**
6
+ * Prefix mapping for generating realistic ID fixture values.
7
+ */
8
+ export const ID_PREFIXES: Record<string, string> = {
9
+ Connection: 'conn_',
10
+ Organization: 'org_',
11
+ OrganizationMembership: 'om_',
12
+ User: 'user_',
13
+ Directory: 'directory_',
14
+ DirectoryGroup: 'dir_grp_',
15
+ DirectoryUser: 'dir_usr_',
16
+ Invitation: 'inv_',
17
+ Session: 'session_',
18
+ AuthenticationFactor: 'auth_factor_',
19
+ EmailVerification: 'email_verification_',
20
+ MagicAuth: 'magic_auth_',
21
+ PasswordReset: 'password_reset_',
22
+ };
23
+
24
+ /**
25
+ * Generate JSON fixture files for test data.
26
+ */
27
+ export function generateFixtures(spec: {
28
+ models: Model[];
29
+ enums: Enum[];
30
+ services: any[];
31
+ }): { path: string; content: string }[] {
32
+ if (spec.models.length === 0) return [];
33
+
34
+ const modelMap = new Map(spec.models.map((m) => [m.name, m]));
35
+ const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
36
+ const files: { path: string; content: string }[] = [];
37
+
38
+ for (const model of spec.models) {
39
+ if (isListMetadataModel(model)) continue;
40
+ if (isListWrapperModel(model)) continue;
41
+
42
+ const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
43
+
44
+ files.push({
45
+ path: `testdata/${fixtureFileName(model.name)}.json`,
46
+ content: JSON.stringify(fixture, null, 2),
47
+ });
48
+
49
+ // Generate null-field variant for models with nullable/optional fields
50
+ const hasNullableFields = model.fields.some((f) => !f.required || f.type.kind === 'nullable');
51
+ if (hasNullableFields && model.fields.length > 0) {
52
+ const nullFixture: Record<string, any> = {};
53
+ for (const field of model.fields) {
54
+ if (!field.required || field.type.kind === 'nullable') {
55
+ nullFixture[field.name] = null;
56
+ } else {
57
+ nullFixture[field.name] = fixture[field.name] ?? null;
58
+ }
59
+ }
60
+ files.push({
61
+ path: `testdata/${fixtureFileName(model.name)}_nulls.json`,
62
+ content: JSON.stringify(nullFixture, null, 2),
63
+ });
64
+ }
65
+ }
66
+
67
+ // Generate list fixtures for paginated responses
68
+ for (const service of spec.services) {
69
+ for (const op of service.operations) {
70
+ if (op.pagination) {
71
+ let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
72
+ if (itemModel) {
73
+ const unwrapped = unwrapListModel(itemModel, modelMap);
74
+ if (unwrapped) itemModel = unwrapped;
75
+ if (itemModel.fields.length === 0) continue;
76
+ const fixture = generateModelFixture(itemModel, modelMap, enumMap);
77
+ const listFixture = {
78
+ data: [fixture],
79
+ list_metadata: {
80
+ before: null,
81
+ after: null,
82
+ },
83
+ };
84
+ files.push({
85
+ path: `testdata/list_${fixtureFileName(itemModel.name)}.json`,
86
+ content: JSON.stringify(listFixture, null, 2),
87
+ });
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ // Generate empty list fixtures for paginated responses
94
+ for (const service of spec.services) {
95
+ for (const op of service.operations) {
96
+ if (op.pagination) {
97
+ let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
98
+ if (itemModel) {
99
+ const unwrapped = unwrapListModel(itemModel, modelMap);
100
+ if (unwrapped) itemModel = unwrapped;
101
+ const emptyFixture = {
102
+ data: [],
103
+ list_metadata: {
104
+ before: null,
105
+ after: null,
106
+ },
107
+ };
108
+ files.push({
109
+ path: `testdata/list_empty_${fixtureFileName(itemModel.name)}.json`,
110
+ content: JSON.stringify(emptyFixture, null, 2),
111
+ });
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // Deduplicate fixtures by path (keep last-written for each path)
118
+ const byPath = new Map<string, { path: string; content: string }>();
119
+ for (const f of files) {
120
+ byPath.set(f.path, f);
121
+ }
122
+
123
+ return [...byPath.values()];
124
+ }
125
+
126
+ function unwrapListModel(model: Model, modelMap: Map<string, Model>): Model | null {
127
+ const dataField = model.fields.find((f) => f.name === 'data');
128
+ const hasListMetadata = model.fields.some((f) => f.name === 'list_metadata' || f.name === 'listMetadata');
129
+ if (dataField && hasListMetadata && dataField.type.kind === 'array') {
130
+ const itemType = dataField.type.items;
131
+ if (itemType.kind === 'model') {
132
+ return modelMap.get(itemType.name) ?? null;
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ export function generateModelFixture(
139
+ model: Model,
140
+ modelMap: Map<string, Model>,
141
+ enumMap: Map<string, Enum>,
142
+ ): Record<string, any> {
143
+ const fixture: Record<string, any> = {};
144
+
145
+ const seenFieldNames = new Set<string>();
146
+ const deduplicatedFields = model.fields.filter((f) => {
147
+ const csName = fieldName(f.name);
148
+ if (seenFieldNames.has(csName)) return false;
149
+ seenFieldNames.add(csName);
150
+ return true;
151
+ });
152
+
153
+ for (const field of deduplicatedFields) {
154
+ const wireName = field.name;
155
+ if (field.example !== undefined) {
156
+ fixture[wireName] = field.example;
157
+ } else {
158
+ fixture[wireName] = generateFieldValue(field.type, field.name, model.name, modelMap, enumMap);
159
+ }
160
+ }
161
+
162
+ return fixture;
163
+ }
164
+
165
+ function generateFieldValue(
166
+ ref: TypeRef,
167
+ fName: string,
168
+ modelName: string,
169
+ modelMap: Map<string, Model>,
170
+ enumMap: Map<string, Enum>,
171
+ ): any {
172
+ switch (ref.kind) {
173
+ case 'primitive':
174
+ return generatePrimitiveValue(ref.type, ref.format, fName, modelName);
175
+ case 'literal':
176
+ return ref.value;
177
+ case 'enum': {
178
+ const e = enumMap.get(ref.name);
179
+ return e?.values[0]?.value ?? 'unknown';
180
+ }
181
+ case 'model': {
182
+ const nested = modelMap.get(ref.name);
183
+ if (nested) return generateModelFixture(nested, modelMap, enumMap);
184
+ return {};
185
+ }
186
+ case 'array': {
187
+ if (ref.items.kind === 'enum') {
188
+ const e = enumMap.get(ref.items.name);
189
+ if (e && e.values.length > 0) {
190
+ return e.values.map((v) => v.value);
191
+ }
192
+ }
193
+ const item = generateFieldValue(ref.items, fName, modelName, modelMap, enumMap);
194
+ return [item];
195
+ }
196
+ case 'nullable':
197
+ return generateFieldValue(ref.inner, fName, modelName, modelMap, enumMap);
198
+ case 'union':
199
+ if (ref.variants.length > 0) {
200
+ return generateFieldValue(ref.variants[0], fName, modelName, modelMap, enumMap);
201
+ }
202
+ return null;
203
+ case 'map':
204
+ return {
205
+ key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
206
+ };
207
+ }
208
+ }
209
+
210
+ function generatePrimitiveValue(type: string, format: string | undefined, name: string, modelName: string): any {
211
+ switch (type) {
212
+ case 'string':
213
+ if (format === 'date-time') return '2023-01-01T00:00:00.000Z';
214
+ if (format === 'date') return '2023-01-01';
215
+ if (format === 'uuid') return '00000000-0000-0000-0000-000000000000';
216
+ if (name === 'id') {
217
+ const prefix = ID_PREFIXES[modelName] ?? '';
218
+ return `${prefix}01234`;
219
+ }
220
+ if (name.includes('id')) return `${name}_01234`;
221
+ if (name.includes('email')) return 'test@example.com';
222
+ if (name.includes('url') || name.includes('uri')) return 'https://example.com';
223
+ if (name.includes('name')) return 'Test';
224
+ return `test_${name}`;
225
+ case 'integer':
226
+ return 1;
227
+ case 'number':
228
+ return 1.0;
229
+ case 'boolean':
230
+ return true;
231
+ case 'unknown':
232
+ return {};
233
+ default:
234
+ return null;
235
+ }
236
+ }