@workos/oagen-emitters 0.3.0 → 0.4.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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -0,0 +1,246 @@
1
+ import type {
2
+ Emitter,
3
+ EmitterContext,
4
+ FormatCommand,
5
+ GeneratedFile,
6
+ ApiSpec,
7
+ Model,
8
+ Enum,
9
+ Service,
10
+ } from '@workos/oagen';
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+
14
+ import { generateModels } from './models.js';
15
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
16
+ import { generateEnums, primeEnumAliases } from './enums.js';
17
+ import { generateResources } from './resources.js';
18
+ import { generateClient } from './client.js';
19
+ import { generateTests } from './tests.js';
20
+ import { generateManifest } from './manifest.js';
21
+ import { generateWrapperOptionsClasses } from './wrappers.js';
22
+ import { groupByMount } from '../shared/resolved-ops.js';
23
+ import { discriminatedUnions } from './type-map.js';
24
+
25
+ /**
26
+ * Fix the namespace for C#. The CLI passes `--namespace workos` which gives
27
+ * namespacePascal = "Workos", but C# needs "WorkOS" (preserving the brand casing).
28
+ */
29
+ function fixNamespace(ctx: EmitterContext): EmitterContext {
30
+ if (ctx.namespace === 'workos' || ctx.namespacePascal === 'Workos') {
31
+ return { ...ctx, namespacePascal: 'WorkOS' };
32
+ }
33
+ return ctx;
34
+ }
35
+
36
+ /** Ensure every generated file's content ends with a trailing newline. */
37
+ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
38
+ for (const f of files) {
39
+ if (f.content && !f.content.endsWith('\n')) {
40
+ f.content += '\n';
41
+ }
42
+ }
43
+ return files;
44
+ }
45
+
46
+ /** Prefix for source files so they land under the .csproj directory. */
47
+ const SRC_PREFIX = 'src/WorkOS.net/';
48
+ /** Prefix for test files so they land under the test project directory. */
49
+ const TEST_PREFIX = 'test/WorkOSTests/';
50
+
51
+ /** Prefix generated source file paths to match the .NET project layout. */
52
+ function prefixSourcePaths(files: GeneratedFile[]): GeneratedFile[] {
53
+ for (const f of files) {
54
+ f.path = `${SRC_PREFIX}${f.path}`;
55
+ }
56
+ return files;
57
+ }
58
+
59
+ /** Prefix generated test/fixture paths to match the .NET test project layout. */
60
+ function prefixTestPaths(files: GeneratedFile[]): GeneratedFile[] {
61
+ for (const f of files) {
62
+ f.path = `${TEST_PREFIX}${f.path}`;
63
+ }
64
+ return files;
65
+ }
66
+
67
+ export const dotnetEmitter: Emitter = {
68
+ language: 'dotnet',
69
+
70
+ generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
71
+ const c = fixNamespace(ctx);
72
+ primeEnumAliases(c.spec.enums);
73
+ const enriched = enrichModelsFromSpec(models);
74
+ // Re-prime after enrichment so synthetic enums from oneOf branches are
75
+ // included in the alias map used by mapTypeRef during model emission.
76
+ const synEnumsForModels = getSyntheticEnums();
77
+ if (synEnumsForModels.length > 0) {
78
+ primeEnumAliases([...c.spec.enums, ...synEnumsForModels]);
79
+ }
80
+ const files = generateModels(enriched, c);
81
+
82
+ // Generate discriminator converters for oneOf unions with discriminator
83
+ if (discriminatedUnions.size > 0) {
84
+ for (const [baseName, disc] of discriminatedUnions) {
85
+ const converterName = `${baseName}DiscriminatorConverter`;
86
+ const lines: string[] = [];
87
+ lines.push(`namespace ${c.namespacePascal}`);
88
+ lines.push('{');
89
+ lines.push(' using System;');
90
+ lines.push(' using System.Text.Json;');
91
+ lines.push(' using System.Text.Json.Serialization;');
92
+ lines.push(' using Newtonsoft.Json;');
93
+ lines.push(' using Newtonsoft.Json.Linq;');
94
+ lines.push('');
95
+ lines.push(` /// <summary>`);
96
+ lines.push(` /// JSON converter that deserializes discriminated union variants`);
97
+ lines.push(` /// based on the "${disc.property}" property.`);
98
+ lines.push(` /// </summary>`);
99
+ lines.push(` public class ${converterName} : Newtonsoft.Json.JsonConverter`);
100
+ lines.push(' {');
101
+ lines.push(' public override bool CanConvert(Type objectType) => objectType == typeof(object);');
102
+ lines.push('');
103
+ lines.push(
104
+ ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)',
105
+ );
106
+ lines.push(' {');
107
+ lines.push(' var jObject = JObject.Load(reader);');
108
+ lines.push(` var discriminatorValue = jObject["${disc.property}"]?.ToString();`);
109
+ lines.push(' switch (discriminatorValue)');
110
+ lines.push(' {');
111
+ for (const [value, modelName] of Object.entries(disc.mapping)) {
112
+ const csName = modelName.replace(/([a-z])([A-Z])/g, '$1$2');
113
+ lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
114
+ }
115
+ lines.push(' default: return jObject.ToObject<object>(serializer);');
116
+ lines.push(' }');
117
+ lines.push(' }');
118
+ lines.push('');
119
+ lines.push(
120
+ ' public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)',
121
+ );
122
+ lines.push(' {');
123
+ lines.push(' serializer.Serialize(writer, value);');
124
+ lines.push(' }');
125
+ lines.push(' }');
126
+ lines.push('}');
127
+
128
+ files.push({
129
+ path: `Client/Utilities/${converterName}.cs`,
130
+ content: lines.join('\n'),
131
+ overwriteExisting: true,
132
+ });
133
+ }
134
+ }
135
+
136
+ return prefixSourcePaths(ensureTrailingNewlines(files));
137
+ },
138
+
139
+ generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
140
+ const c = fixNamespace(ctx);
141
+ // Ensure synthetic enums are populated regardless of method execution order.
142
+ // enrichModelsFromSpec is idempotent (cached raw spec) and populates the
143
+ // module-level synthetic-enum store consumed by getSyntheticEnums().
144
+ enrichModelsFromSpec(c.spec.models);
145
+ const syntheticEnums = getSyntheticEnums();
146
+ const allEnums = syntheticEnums.length > 0 ? [...enums, ...syntheticEnums] : enums;
147
+ return prefixSourcePaths(ensureTrailingNewlines(generateEnums(allEnums, c)));
148
+ },
149
+
150
+ generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
151
+ const c = fixNamespace(ctx);
152
+ const synEnums = getSyntheticEnums();
153
+ primeEnumAliases(synEnums.length > 0 ? [...c.spec.enums, ...synEnums] : c.spec.enums);
154
+ const files = generateResources(services, c);
155
+
156
+ // Also generate wrapper options classes
157
+ const mountGroups = groupByMount(c);
158
+ for (const [, group] of mountGroups) {
159
+ for (const resolvedOp of group.resolvedOps) {
160
+ if (resolvedOp.wrappers && resolvedOp.wrappers.length > 0) {
161
+ const wrapperOptionsLines = generateWrapperOptionsClasses(resolvedOp, c);
162
+ if (wrapperOptionsLines.length > 0) {
163
+ const mountName = resolvedOp.mountOn;
164
+ const optionsPath = `Services/${mountName}/_interfaces/${mountName}WrapperOptions.cs`;
165
+ const content = [
166
+ `namespace ${c.namespacePascal}`,
167
+ '{',
168
+ ' using System.Collections.Generic;',
169
+ ' using Newtonsoft.Json;',
170
+ ' using STJS = System.Text.Json.Serialization;',
171
+ ...wrapperOptionsLines,
172
+ '}',
173
+ ].join('\n');
174
+ files.push({
175
+ path: optionsPath,
176
+ content,
177
+ overwriteExisting: true,
178
+ });
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ return prefixSourcePaths(ensureTrailingNewlines(files));
185
+ },
186
+
187
+ generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
188
+ const c = fixNamespace(ctx);
189
+ return prefixSourcePaths(ensureTrailingNewlines(generateClient(spec, c)));
190
+ },
191
+
192
+ generateErrors(): GeneratedFile[] {
193
+ return [];
194
+ },
195
+
196
+ generateTypeSignatures(): GeneratedFile[] {
197
+ return [];
198
+ },
199
+
200
+ generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
201
+ const c = fixNamespace(ctx);
202
+ const synEnumsForTests = getSyntheticEnums();
203
+ primeEnumAliases(synEnumsForTests.length > 0 ? [...spec.enums, ...synEnumsForTests] : spec.enums);
204
+ return prefixTestPaths(ensureTrailingNewlines(generateTests(spec, c)));
205
+ },
206
+
207
+ generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
208
+ return ensureTrailingNewlines(generateManifest(spec, fixNamespace(ctx)));
209
+ },
210
+
211
+ fileHeader(): string {
212
+ return '// This file is auto-generated by oagen. Do not edit.';
213
+ },
214
+
215
+ formatCommand(targetDir: string): FormatCommand | null {
216
+ // `dotnet format` applies both whitespace rules and analyzer code fixes
217
+ // (StyleCop, etc.) to the generated files, matching the target project's
218
+ // conventions. We prefer a .sln/.slnx/.csproj workspace so MSBuild loads
219
+ // the analyzer ruleset correctly.
220
+ const workspace = findDotnetWorkspace(targetDir);
221
+ if (!workspace) return null;
222
+
223
+ // `dotnet format` expects `--include` paths relative to the workspace
224
+ // (or absolute). Our harness appends absolute paths, which is fine.
225
+ // Run `--no-restore` so formatting doesn't trigger a package restore on
226
+ // every codegen run.
227
+ return {
228
+ cmd: 'dotnet',
229
+ args: ['format', workspace, '--no-restore', '--include'],
230
+ // Keep batches small enough to stay under argv length limits while
231
+ // still amortizing MSBuild startup across many files.
232
+ batchSize: 50,
233
+ };
234
+ },
235
+ };
236
+
237
+ /** Locate a .sln/.slnx/.csproj file in the target directory for `dotnet format`. */
238
+ function findDotnetWorkspace(targetDir: string): string | null {
239
+ if (!fs.existsSync(targetDir)) return null;
240
+ const entries = fs.readdirSync(targetDir);
241
+ const sln = entries.find((e) => e.endsWith('.sln') || e.endsWith('.slnx'));
242
+ if (sln) return path.join(targetDir, sln);
243
+ const csproj = entries.find((e) => e.endsWith('.csproj'));
244
+ if (csproj) return path.join(targetDir, csproj);
245
+ return null;
246
+ }
@@ -0,0 +1,36 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { resolveMethodName } from './naming.js';
3
+ import { buildServiceAccessPaths } from './client.js';
4
+ import { getMountTarget } from '../shared/resolved-ops.js';
5
+
6
+ /**
7
+ * Generate smoke test manifest mapping HTTP operations to SDK methods.
8
+ */
9
+ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
+ const manifest: Record<string, { sdkMethod: string; service: string }> = {};
11
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
12
+
13
+ for (const service of spec.services) {
14
+ let propName = accessPaths.get(service.name);
15
+ if (!propName) {
16
+ const mountTarget = getMountTarget(service, ctx);
17
+ propName = accessPaths.get(mountTarget);
18
+ }
19
+ if (!propName) {
20
+ throw new Error(`Missing public client access path for service ${service.name}`);
21
+ }
22
+ for (const op of service.operations) {
23
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
24
+ const method = resolveMethodName(op, service, ctx);
25
+ manifest[httpKey] = { sdkMethod: method, service: propName };
26
+ }
27
+ }
28
+
29
+ return [
30
+ {
31
+ path: 'smoke-manifest.json',
32
+ content: JSON.stringify(manifest, null, 2),
33
+ integrateTarget: false,
34
+ },
35
+ ];
36
+ }
@@ -0,0 +1,344 @@
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef } from '@workos/oagen';
2
+ import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
3
+ import {
4
+ articleFor,
5
+ className,
6
+ escapeXml,
7
+ fieldName,
8
+ humanize,
9
+ emitXmlDoc,
10
+ deprecationMessage,
11
+ escapeCsAttributeString,
12
+ } from './naming.js';
13
+
14
+ // Import and re-export shared model detection utilities
15
+ import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
16
+ export { isListWrapperModel, isListMetadataModel };
17
+
18
+ /**
19
+ * Generate C# model classes from IR Models.
20
+ * Each model becomes a separate .cs file under Services/{mount}/Entities/.
21
+ * For initial generation, all models go into a flat Entities/ directory.
22
+ */
23
+ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
24
+ if (models.length === 0) return [];
25
+
26
+ // Build a lookup from enum name → single wire value for 1-value enums so
27
+ // we can emit a const initializer on the owning property without needing
28
+ // the full EnumRef.values payload (which the IR sometimes omits on refs).
29
+ const enumConstByName = new Map<string, string>();
30
+ for (const e of ctx.spec.enums) {
31
+ if (e.values.length === 1) {
32
+ enumConstByName.set(e.name, String(e.values[0].value));
33
+ }
34
+ }
35
+
36
+ const files: GeneratedFile[] = [];
37
+
38
+ // Build structural hash for deduplication. Run the hash → canonical pass
39
+ // iteratively so that parent classes whose only structural difference is
40
+ // an already-aliased child type also collapse. Terminates when a full
41
+ // round produces no new aliases.
42
+ const eligibleModels = models.filter((m) => !isListWrapperModel(m) && !isListMetadataModel(m));
43
+ const aliasOf = new Map<string, string>();
44
+ while (true) {
45
+ const hashGroups = new Map<string, string[]>();
46
+ for (const model of eligibleModels) {
47
+ const hash = structuralHash(model, aliasOf);
48
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
49
+ hashGroups.get(hash)!.push(model.name);
50
+ }
51
+
52
+ let added = false;
53
+ for (const [hash, names] of hashGroups) {
54
+ if (names.length <= 1) continue;
55
+ if (hash === '') continue;
56
+ const sorted = [...names].sort();
57
+ const canonical = sorted[0];
58
+ for (let i = 1; i < sorted.length; i++) {
59
+ const name = sorted[i];
60
+ if (aliasOf.get(name) !== canonical) {
61
+ aliasOf.set(name, canonical);
62
+ added = true;
63
+ }
64
+ }
65
+ }
66
+ if (!added) break;
67
+ }
68
+
69
+ for (const model of models) {
70
+ if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
71
+
72
+ const csClassName = className(model.name);
73
+ const canonicalName = aliasOf.get(model.name);
74
+
75
+ if (canonicalName) {
76
+ // Emit alias as subclass of canonical
77
+ const canonicalClass = className(canonicalName);
78
+ const lines: string[] = [];
79
+ lines.push(`namespace ${ctx.namespacePascal}`);
80
+ lines.push('{');
81
+ if (model.description) {
82
+ const descLines = model.description
83
+ .split('\n')
84
+ .map((l) => l.trim())
85
+ .filter((l) => l);
86
+ lines.push(` /// <summary>${escapeXml(descLines[0])}</summary>`);
87
+ if (descLines.length > 1) {
88
+ lines.push(` /// <remarks>`);
89
+ for (const remark of descLines.slice(1)) {
90
+ lines.push(` /// ${escapeXml(remark)}`);
91
+ }
92
+ lines.push(` /// Structurally identical to <see cref="${canonicalClass}"/>.`);
93
+ lines.push(` /// </remarks>`);
94
+ } else {
95
+ lines.push(` /// <remarks>Structurally identical to <see cref="${canonicalClass}"/>.</remarks>`);
96
+ }
97
+ } else {
98
+ const human = humanize(model.name);
99
+ lines.push(` /// <summary>Represents ${articleFor(human)} ${human}.</summary>`);
100
+ lines.push(` /// <remarks>Structurally identical to <see cref="${canonicalClass}"/>.</remarks>`);
101
+ }
102
+ lines.push(` public class ${csClassName} : ${canonicalClass} { }`);
103
+ lines.push('}');
104
+
105
+ files.push({
106
+ path: `Entities/${csClassName}.cs`,
107
+ content: lines.join('\n'),
108
+ overwriteExisting: true,
109
+ });
110
+ continue;
111
+ }
112
+
113
+ const lines: string[] = [];
114
+ const needsCollections = model.fields.some((f) => {
115
+ const csType = mapTypeRef(f.type);
116
+ return csType.startsWith('List<') || csType.startsWith('Dictionary<');
117
+ });
118
+ const needsSystem = model.fields.some((f) => {
119
+ const csType = mapTypeRef(f.type);
120
+ return csType.includes('DateTimeOffset');
121
+ });
122
+
123
+ lines.push(`namespace ${ctx.namespacePascal}`);
124
+ lines.push('{');
125
+ if (needsSystem) {
126
+ lines.push(' using System;');
127
+ }
128
+ if (needsCollections) {
129
+ lines.push(' using System.Collections.Generic;');
130
+ }
131
+ lines.push(' using Newtonsoft.Json;');
132
+ lines.push(' using STJS = System.Text.Json.Serialization;');
133
+ lines.push('');
134
+
135
+ // XML doc comment
136
+ if (model.description) {
137
+ lines.push(...emitXmlDoc(model.description, ' '));
138
+ } else {
139
+ const human = humanize(model.name);
140
+ lines.push(` /// <summary>Represents ${articleFor(human)} ${human}.</summary>`);
141
+ }
142
+
143
+ lines.push(` public class ${csClassName}`);
144
+ lines.push(' {');
145
+
146
+ // Track Dictionary<string, object> fields so we can emit a typed
147
+ // accessor helper per field at the end of the class body.
148
+ const dictObjectFields: Array<{ csName: string; typeText: string }> = [];
149
+
150
+ // Deduplicate fields by C# property name
151
+ const seenFieldNames = new Set<string>();
152
+ for (const field of model.fields) {
153
+ const csFieldName = fieldName(field.name);
154
+ if (seenFieldNames.has(csFieldName)) continue;
155
+ seenFieldNames.add(csFieldName);
156
+
157
+ const isOptional = !field.required;
158
+ const baseType = mapTypeRef(field.type);
159
+ const isAlreadyNullable = baseType.endsWith('?');
160
+ const constInit = singleValueConstInitializer(field.type, enumConstByName);
161
+ let csType: string;
162
+ let initializer = '';
163
+ let setterModifier = '';
164
+
165
+ if (constInit !== null) {
166
+ // Discriminator-style single-value enum/literal: emit with a const
167
+ // initializer and a non-public setter so callers can't drift the
168
+ // wire value. The converter still reads whatever the server sends.
169
+ csType = baseType;
170
+ initializer = ` = ${constInit};`;
171
+ setterModifier = 'internal ';
172
+ } else if (isOptional) {
173
+ if (isAlreadyNullable) {
174
+ csType = baseType;
175
+ } else if (isValueTypeRef(field.type)) {
176
+ csType = `${baseType}?`;
177
+ } else {
178
+ // With nullable enabled, optional reference types need `?`
179
+ csType = `${baseType}?`;
180
+ }
181
+ } else {
182
+ csType = baseType;
183
+ // Required non-nullable reference types need = default! to suppress CS8618
184
+ if (!isAlreadyNullable && !isValueTypeRef(field.type)) {
185
+ initializer = ' = default!;';
186
+ }
187
+ }
188
+
189
+ // Field description (full multi-line, with continuations as <remarks>)
190
+ const fieldDocs = emitXmlDoc(field.description, ' ');
191
+ if (fieldDocs.length > 0) {
192
+ lines.push('');
193
+ lines.push(...fieldDocs);
194
+ }
195
+
196
+ if (field.deprecated) {
197
+ const msg = escapeCsAttributeString(deprecationMessage(field.description, 'field'));
198
+ lines.push(` [System.Obsolete("${msg}")]`);
199
+ }
200
+
201
+ const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
202
+ lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
203
+ lines.push(` public ${csType} ${csFieldName} { get; ${setterModifier}set; }${initializer}`);
204
+
205
+ // Track additional-properties / metadata dictionaries for typed accessors.
206
+ // Skip deprecated fields so the generated accessor doesn't reference
207
+ // a field marked `[System.Obsolete]` (which would fail the build).
208
+ if (isDictionaryOfObject(csType) && !field.deprecated) {
209
+ dictObjectFields.push({ csName: csFieldName, typeText: csType });
210
+ }
211
+ }
212
+
213
+ for (const dict of dictObjectFields) {
214
+ lines.push('');
215
+ lines.push(` /// <summary>`);
216
+ lines.push(` /// Typed accessor for <see cref="${dict.csName}"/>. Returns the value stored under`);
217
+ lines.push(` /// <paramref name="key"/> coerced to <typeparamref name="T"/>, or the default`);
218
+ lines.push(` /// value when the key is missing or the value is not convertible.`);
219
+ lines.push(` /// </summary>`);
220
+ lines.push(` /// <typeparam name="T">Expected value type.</typeparam>`);
221
+ lines.push(` /// <param name="key">The key to look up.</param>`);
222
+ lines.push(` public T? Get${dict.csName}Attribute<T>(string key)`);
223
+ lines.push(' {');
224
+ lines.push(` if (this.${dict.csName} == null)`);
225
+ lines.push(' {');
226
+ lines.push(' return default;');
227
+ lines.push(' }');
228
+ lines.push('');
229
+ lines.push(` if (!this.${dict.csName}.TryGetValue(key, out var value))`);
230
+ lines.push(' {');
231
+ lines.push(' return default;');
232
+ lines.push(' }');
233
+ lines.push('');
234
+ lines.push(' if (value is T typed)');
235
+ lines.push(' {');
236
+ lines.push(' return typed;');
237
+ lines.push(' }');
238
+ lines.push('');
239
+ lines.push(' if (value is Newtonsoft.Json.Linq.JToken token)');
240
+ lines.push(' {');
241
+ lines.push(' return token.ToObject<T>();');
242
+ lines.push(' }');
243
+ lines.push('');
244
+ lines.push(' if (value is System.Text.Json.JsonElement element)');
245
+ lines.push(' {');
246
+ lines.push(' return System.Text.Json.JsonSerializer.Deserialize<T>(element.GetRawText());');
247
+ lines.push(' }');
248
+ lines.push('');
249
+ lines.push(' return default;');
250
+ lines.push(' }');
251
+ }
252
+
253
+ lines.push(' }');
254
+ lines.push('}');
255
+
256
+ files.push({
257
+ path: `Entities/${csClassName}.cs`,
258
+ content: lines.join('\n'),
259
+ overwriteExisting: true,
260
+ });
261
+ }
262
+
263
+ return files;
264
+ }
265
+
266
+ /**
267
+ * Whether the emitted C# type is `Dictionary<string, object>` or its
268
+ * nullable variant — the usual shape of metadata / additional-properties
269
+ * fields that get typed accessors.
270
+ */
271
+ function isDictionaryOfObject(csType: string): boolean {
272
+ const bare = csType.endsWith('?') ? csType.slice(0, -1) : csType;
273
+ return bare === 'Dictionary<string, object>';
274
+ }
275
+
276
+ /**
277
+ * If the given TypeRef is a single-value enum / literal (a discriminator
278
+ * const masquerading as an enum), return the C# literal expression (already
279
+ * quoted for strings, bare for bool/number) so the emitter can lock the
280
+ * field down with a const initializer and non-public setter. Returns null
281
+ * for any other type.
282
+ */
283
+ function singleValueConstInitializer(ref: TypeRef, enumConstByName: Map<string, string>): string | null {
284
+ // OpenAPI `enum: [value]` (single-value) is normalized by the IR to a
285
+ // LiteralType on the field, not an EnumRef. Emit per-type: booleans and
286
+ // numbers are bare literals; strings get JSON-quoted.
287
+ if (ref.kind === 'literal') {
288
+ if (ref.value === null) return null;
289
+ if (typeof ref.value === 'boolean') return ref.value ? 'true' : 'false';
290
+ if (typeof ref.value === 'number') return String(ref.value);
291
+ if (typeof ref.value === 'string') return JSON.stringify(ref.value);
292
+ return null;
293
+ }
294
+ if (ref.kind !== 'enum') return null;
295
+ let wire: string | null = null;
296
+ if (ref.values && ref.values.length === 1) {
297
+ const v = ref.values[0] as string | number | { value: string | number };
298
+ wire = typeof v === 'string' || typeof v === 'number' ? String(v) : String(v.value);
299
+ } else {
300
+ wire = enumConstByName.get(ref.name) ?? null;
301
+ }
302
+ if (wire === null) return null;
303
+ // Enum wire values serialize as strings in JSON, and mapTypeRef returns
304
+ // `string` for single-value enums — so always quote.
305
+ return JSON.stringify(wire);
306
+ }
307
+
308
+ /**
309
+ * Normalize a TypeRef for structural comparison.
310
+ * Enum references are normalized to their values (not names) so that
311
+ * structurally identical enums with different names still match.
312
+ * Model references are rewritten to their canonical alias (if any) so that
313
+ * parents whose only difference is an already-aliased child collapse too.
314
+ */
315
+ function normalizeTypeForHash(ref: TypeRef, aliasOf: Map<string, string>): any {
316
+ if (ref.kind === 'enum') {
317
+ // Normalize enum refs by their sorted values, not their name
318
+ const vals = ref.values ? [...ref.values].sort() : [];
319
+ return { kind: 'enum', values: vals };
320
+ }
321
+ if (ref.kind === 'model') {
322
+ return { kind: 'model', name: aliasOf.get(ref.name) ?? ref.name };
323
+ }
324
+ if (ref.kind === 'nullable') {
325
+ return { kind: 'nullable', inner: normalizeTypeForHash(ref.inner, aliasOf) };
326
+ }
327
+ if (ref.kind === 'array') {
328
+ return { kind: 'array', items: normalizeTypeForHash(ref.items, aliasOf) };
329
+ }
330
+ if (ref.kind === 'union') {
331
+ return { kind: 'union', variants: ref.variants.map((v) => normalizeTypeForHash(v, aliasOf)) };
332
+ }
333
+ if (ref.kind === 'map') {
334
+ return { kind: 'map', valueType: normalizeTypeForHash(ref.valueType, aliasOf) };
335
+ }
336
+ return ref;
337
+ }
338
+
339
+ function structuralHash(model: Model, aliasOf: Map<string, string> = new Map()): string {
340
+ return model.fields
341
+ .map((f) => `${f.name}:${JSON.stringify(normalizeTypeForHash(f.type, aliasOf))}:${f.required}`)
342
+ .sort()
343
+ .join('|');
344
+ }