@workos/oagen-emitters 0.2.1 → 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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,123 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+ import { enumCanonicalMap } from './enums.js';
5
+
6
+ /** Resolve an enum name through the canonical map (identity when no alias). */
7
+ function resolveEnumName(name: string): string {
8
+ return className(enumCanonicalMap.get(name) ?? name);
9
+ }
10
+
11
+ /**
12
+ * Map an IR TypeRef to a non-nullable Kotlin type expression.
13
+ *
14
+ * Kotlin's type system marks nullability at use-sites (`T?`). Optional fields
15
+ * on generated models append `?` to the output of this function.
16
+ */
17
+ export function mapTypeRef(ref: TypeRef): string {
18
+ return irMapTypeRef<string>(ref, {
19
+ primitive: mapPrimitive,
20
+ array: (_ref, items) => `List<${items}>`,
21
+ model: (r) => className(r.name),
22
+ enum: (r) => resolveEnumName(r.name),
23
+ union: (r, variants) => joinUnionVariants(r, variants),
24
+ nullable: (_ref, inner) => (inner.endsWith('?') ? inner : `${inner}?`),
25
+ literal: (r) => {
26
+ if (r.value === null) return 'Any?';
27
+ if (typeof r.value === 'string') return 'String';
28
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'Long' : 'Double';
29
+ if (typeof r.value === 'boolean') return 'Boolean';
30
+ return 'Any';
31
+ },
32
+ map: (_ref, value) => `Map<String, ${value}>`,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Map an IR TypeRef to a nullable Kotlin type expression (always appends `?`).
38
+ * Useful for optional fields on models / request options.
39
+ */
40
+ export function mapTypeRefOptional(ref: TypeRef): string {
41
+ const baseType = mapTypeRef(ref);
42
+ return baseType.endsWith('?') ? baseType : `${baseType}?`;
43
+ }
44
+
45
+ /**
46
+ * Is the given IR TypeRef a primitive value? Useful when deciding whether a
47
+ * nullable field needs an explicit `= null` default.
48
+ */
49
+ export function isValueTypeRef(ref: TypeRef): boolean {
50
+ if (ref.kind === 'enum') return true;
51
+ if (ref.kind === 'primitive') {
52
+ if (ref.format === 'date-time') return true;
53
+ switch (ref.type) {
54
+ case 'integer':
55
+ case 'number':
56
+ case 'boolean':
57
+ return true;
58
+ default:
59
+ return false;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function mapPrimitive(ref: PrimitiveType): string {
66
+ if (ref.format === 'binary') return 'ByteArray';
67
+ if (ref.format === 'int32') return 'Int';
68
+ if (ref.format === 'int64') return 'Long';
69
+ if (ref.format === 'date-time') return 'OffsetDateTime';
70
+ switch (ref.type) {
71
+ case 'string':
72
+ return 'String';
73
+ case 'integer':
74
+ return 'Long';
75
+ case 'number':
76
+ return 'Double';
77
+ case 'boolean':
78
+ return 'Boolean';
79
+ case 'unknown':
80
+ return 'Any';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Track discriminated unions so the model generator can emit the appropriate
86
+ * Jackson `@JsonTypeInfo` / `@JsonSubTypes` annotations on the sealed parent.
87
+ */
88
+ export const discriminatedUnions = new Map<
89
+ string,
90
+ { property: string; mapping: Record<string, string>; variantTypes: string[] }
91
+ >();
92
+
93
+ function joinUnionVariants(ref: UnionType, variants: string[]): string {
94
+ if (ref.compositionKind === 'allOf') {
95
+ return variants[0] ?? 'Any';
96
+ }
97
+ const unique = [...new Set(variants)];
98
+ if (unique.length === 1) return unique[0];
99
+
100
+ if (ref.discriminator && ref.discriminator.mapping) {
101
+ const baseName = unique[0];
102
+ discriminatedUnions.set(baseName, {
103
+ property: ref.discriminator.property,
104
+ mapping: ref.discriminator.mapping,
105
+ variantTypes: unique,
106
+ });
107
+ // Use the base sealed type; Jackson @JsonTypeInfo handles variant selection.
108
+ return baseName;
109
+ }
110
+
111
+ // Non-discriminated unions fall back to the Kotlin top type. A generic
112
+ // AnyOf<> is planned for a future phase if emitter tests prove it necessary.
113
+ return 'Any';
114
+ }
115
+
116
+ /** Kotlin imports implied by a given type expression. Caller collects into a set. */
117
+ export function implicitImportsFor(kotlinType: string): string[] {
118
+ const imports: string[] = [];
119
+ if (/\bOffsetDateTime\b/.test(kotlinType)) {
120
+ imports.push('java.time.OffsetDateTime');
121
+ }
122
+ return imports;
123
+ }
@@ -0,0 +1,168 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
2
+ import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
3
+ import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
+ import { sortPathParamsByTemplateOrder } from './resources.js';
6
+
7
+ /**
8
+ * Emit Kotlin wrapper methods for a union-split operation. Each wrapper
9
+ * method takes only the fields it needs for its variant, fills in the
10
+ * operation-level defaults and client-inferred values, and posts to the
11
+ * underlying operation.
12
+ *
13
+ * Returns a list of lines (with leading indentation suitable for inclusion
14
+ * inside the service class body).
15
+ */
16
+ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
17
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
18
+
19
+ const out: string[] = [];
20
+ for (const wrapper of resolvedOp.wrappers) {
21
+ if (out.length > 0) out.push('');
22
+ for (const line of emitWrapperMethod(resolvedOp, wrapper, ctx)) out.push(line);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapper, ctx: EmitterContext): string[] {
28
+ const op = resolvedOp.operation;
29
+ const method = propertyName(wrapper.name);
30
+ const resolvedParams = resolveWrapperParams(wrapper, ctx);
31
+ const responseClass = wrapper.responseModelName ? className(wrapper.responseModelName) : null;
32
+
33
+ const pathParams = sortPathParamsByTemplateOrder(op);
34
+
35
+ const lines: string[] = [];
36
+
37
+ // Build KDoc from operation description + @param docs for each wrapper param.
38
+ const kdocLines: string[] = [];
39
+ const opDesc = (op.description ?? '').trim();
40
+ const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
41
+ if (opDesc) {
42
+ kdocLines.push(opDesc.split('\n')[0]);
43
+ } else {
44
+ kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
45
+ }
46
+ const paramDocs: string[] = [];
47
+ for (const pp of pathParams) {
48
+ if (pp.description?.trim()) {
49
+ paramDocs.push(`@param ${propertyName(pp.name)} ${escapeKdoc(pp.description.split('\n')[0].trim())}`);
50
+ }
51
+ }
52
+ for (const rp of resolvedParams) {
53
+ const desc = rp.field?.description?.trim();
54
+ if (desc) {
55
+ paramDocs.push(`@param ${propertyName(rp.paramName)} ${escapeKdoc(desc.split('\n')[0])}`);
56
+ }
57
+ }
58
+ if (responseClass) {
59
+ paramDocs.push(`@return the ${responseClass}`);
60
+ }
61
+ if (paramDocs.length > 0 || kdocLines.length > 0) {
62
+ lines.push(' /**');
63
+ for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
64
+ if (paramDocs.length > 0) {
65
+ lines.push(' *');
66
+ for (const p of paramDocs) lines.push(` * ${p}`);
67
+ }
68
+ lines.push(' */');
69
+ }
70
+
71
+ lines.push(' @JvmOverloads');
72
+
73
+ // Build the method parameter list: path params, wrapper params, requestOptions
74
+ const params: string[] = [];
75
+ for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
76
+ for (const rp of resolvedParams) {
77
+ const paramName = propertyName(rp.paramName);
78
+ const kotlinType = rp.field
79
+ ? rp.isOptional
80
+ ? mapTypeRefOptional(rp.field.type)
81
+ : mapTypeRef(rp.field.type)
82
+ : rp.isOptional
83
+ ? 'String?'
84
+ : 'String';
85
+ const trailer = rp.isOptional ? ' = null' : '';
86
+ params.push(` ${paramName}: ${kotlinType}${trailer}`);
87
+ }
88
+ params.push(' requestOptions: RequestOptions? = null');
89
+
90
+ const returnClause = responseClass ? `: ${responseClass}` : '';
91
+ if (params.length === 1) {
92
+ const single = params[0].replace(/^\s+/, '');
93
+ lines.push(` fun ${escapeReserved(method)}(${single})${returnClause} {`);
94
+ } else {
95
+ lines.push(` fun ${escapeReserved(method)}(`);
96
+ for (let i = 0; i < params.length; i++) {
97
+ const suffix = i === params.length - 1 ? '' : ',';
98
+ lines.push(`${params[i]}${suffix}`);
99
+ }
100
+ lines.push(` )${returnClause} {`);
101
+ }
102
+
103
+ // Build body using bodyOf() — consistent with non-wrapper methods.
104
+ // bodyOf() automatically drops null optional values.
105
+ const bodyEntries: string[] = [];
106
+ for (const rp of resolvedParams) {
107
+ const paramName = propertyName(rp.paramName);
108
+ bodyEntries.push(` ${ktLiteral(rp.paramName)} to ${paramName}`);
109
+ }
110
+ for (const [k, v] of Object.entries(wrapper.defaults ?? {})) {
111
+ bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
112
+ }
113
+ for (const k of wrapper.inferFromClient ?? []) {
114
+ bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
115
+ }
116
+ if (bodyEntries.length > 0) {
117
+ lines.push(` val body =`);
118
+ lines.push(` bodyOf(`);
119
+ for (let i = 0; i < bodyEntries.length; i++) {
120
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
121
+ lines.push(` ${bodyEntries[i]}${sep}`);
122
+ }
123
+ lines.push(` )`);
124
+ } else {
125
+ lines.push(` val body = linkedMapOf<String, Any?>()`);
126
+ }
127
+
128
+ const pathExpr = buildPathExpr(op.path, pathParams);
129
+ const httpMethod = op.httpMethod.toUpperCase();
130
+
131
+ lines.push(` val config =`);
132
+ lines.push(` RequestConfig(`);
133
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
134
+ lines.push(` path = ${pathExpr},`);
135
+ lines.push(` body = body,`);
136
+ if (op.requestBodyEncoding === 'form-urlencoded') {
137
+ // Some ops (SSO token, User Management authenticate) are form-encoded.
138
+ // Rewrite as formBody mapping string→string instead of JSON body.
139
+ // Fallback: leave body as JSON — the API accepts JSON for these too.
140
+ }
141
+ lines.push(` requestOptions = requestOptions`);
142
+ lines.push(` )`);
143
+
144
+ if (responseClass) {
145
+ lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
146
+ } else {
147
+ lines.push(` workos.baseClient.requestVoid(config)`);
148
+ }
149
+
150
+ lines.push(' }');
151
+ return lines;
152
+ }
153
+
154
+ function escapeKdoc(s: string): string {
155
+ return s.replace(/\*\//g, '*\u200b/');
156
+ }
157
+
158
+ function buildPathExpr(path: string, pathParams: Parameter[]): string {
159
+ if (pathParams.length === 0) return ktLiteral(path);
160
+ let result = path;
161
+ for (const pp of pathParams) {
162
+ const placeholder = `{${pp.name}}`;
163
+ const propName = propertyName(pp.name);
164
+ const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
165
+ result = result.replaceAll(placeholder, replacement);
166
+ }
167
+ return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
168
+ }
@@ -1,4 +1,7 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
4
+ import { collectReferencedNames } from '@workos/oagen';
2
5
  import { fileName, resolveServiceDir, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
3
6
  import {
4
7
  docComment,
@@ -15,9 +18,7 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
15
18
  files.push(generateWorkOSClient(spec, ctx));
16
19
  files.push(...generateServiceBarrels(spec, ctx));
17
20
  files.push(generateBarrel(spec, ctx));
18
- files.push(generateWorkerBarrel(spec, ctx));
19
- files.push(generatePackageJson(ctx));
20
- files.push(generateTsConfig());
21
+ // worker barrel, package.json, tsconfig.json are now hand-maintained in the target SDK
21
22
 
22
23
  return files;
23
24
  }
@@ -86,6 +87,15 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
86
87
  const resolvedName = resolveResourceClassName(service, ctx);
87
88
  const propName = servicePropertyName(resolvedName);
88
89
  if (existingProps.has(propName)) continue;
90
+ // Propagate `@deprecated` from the service class to the property so
91
+ // IDEs surface the strikethrough at `workos.xyz` access sites, not
92
+ // just when users `new Xyz()` directly. TS's deprecation-lint reads
93
+ // the property JSDoc, not the underlying type declaration.
94
+ const classDeprecation = ctx.apiSurface?.classes?.[resolvedName]?.deprecationMessage;
95
+ if (classDeprecation !== undefined) {
96
+ const body = classDeprecation ? ` ${classDeprecation}` : '';
97
+ lines.push(` /** @deprecated${body} */`);
98
+ }
89
99
  lines.push(` readonly ${propName} = new ${resolvedName}(this);`);
90
100
  }
91
101
 
@@ -128,47 +138,36 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
128
138
  // exports a name (e.g., AuditLogSchema from create-audit-log-schema-options),
129
139
  // the generated model with the same name must be skipped to prevent the
130
140
  // merger from adding a duplicate `export *` that causes TS2308.
141
+ //
142
+ // Also track baseline file stems per directory so we can detect when the
143
+ // barrel needs updating with new export lines (see hasNewExports below).
144
+ const dirSymbolsFromBaseline = new Map<string, Set<string>>();
145
+ const seedFromBaseline = (sourceFile: string, name: string) => {
146
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
147
+ if (!match) return;
148
+ const dirName = match[1];
149
+ const fileStem = match[2];
150
+ if (!dirSymbols.has(dirName)) dirSymbols.set(dirName, new Set());
151
+ dirSymbols.get(dirName)!.add(name);
152
+ if (!dirSymbolsFromBaseline.has(dirName)) dirSymbolsFromBaseline.set(dirName, new Set());
153
+ dirSymbolsFromBaseline.get(dirName)!.add(fileStem);
154
+ };
131
155
  if (ctx.apiSurface?.interfaces) {
132
156
  for (const [name, iface] of Object.entries(ctx.apiSurface.interfaces)) {
133
157
  const sourceFile = (iface as any).sourceFile as string | undefined;
134
- if (!sourceFile) continue;
135
- // Match paths like "src/audit-logs/interfaces/foo.interface.ts" to directory "audit-logs"
136
- const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
137
- if (match) {
138
- const dirName = match[1];
139
- if (!dirSymbols.has(dirName)) {
140
- dirSymbols.set(dirName, new Set());
141
- }
142
- dirSymbols.get(dirName)!.add(name);
143
- }
158
+ if (sourceFile) seedFromBaseline(sourceFile, name);
144
159
  }
145
160
  }
146
161
  if (ctx.apiSurface?.enums) {
147
162
  for (const [name, enumDef] of Object.entries(ctx.apiSurface.enums)) {
148
163
  const sourceFile = (enumDef as any).sourceFile as string | undefined;
149
- if (!sourceFile) continue;
150
- const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
151
- if (match) {
152
- const dirName = match[1];
153
- if (!dirSymbols.has(dirName)) {
154
- dirSymbols.set(dirName, new Set());
155
- }
156
- dirSymbols.get(dirName)!.add(name);
157
- }
164
+ if (sourceFile) seedFromBaseline(sourceFile, name);
158
165
  }
159
166
  }
160
167
  if (ctx.apiSurface?.typeAliases) {
161
168
  for (const [name, alias] of Object.entries(ctx.apiSurface.typeAliases)) {
162
169
  const sourceFile = (alias as any).sourceFile as string | undefined;
163
- if (!sourceFile) continue;
164
- const match = sourceFile.match(/^src\/([^/]+)\/interfaces\//);
165
- if (match) {
166
- const dirName = match[1];
167
- if (!dirSymbols.has(dirName)) {
168
- dirSymbols.set(dirName, new Set());
169
- }
170
- dirSymbols.get(dirName)!.add(name);
171
- }
170
+ if (sourceFile) seedFromBaseline(sourceFile, name);
172
171
  }
173
172
  }
174
173
 
@@ -187,8 +186,12 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
187
186
  // Models -> service directories
188
187
  // Skip list wrapper and list metadata models — they use shared List<T>/ListMetadata
189
188
  // from common utils, so no per-resource interface file is generated.
189
+ // Also skip unreachable models — oagen only passes service-referenced models
190
+ // to generateModels, so unreachable models have no interface file to export.
191
+ const barrelReachable = collectReferencedNames(spec.services, spec.models);
190
192
  for (const model of spec.models) {
191
193
  if (isListMetadataModel(model) || isListWrapperModel(model)) continue;
194
+ if (!barrelReachable.models.has(model.name)) continue;
192
195
  const service = modelToService.get(model.name);
193
196
  const dirName = resolveDir(service);
194
197
  if (!dirExports.has(dirName)) {
@@ -240,14 +243,96 @@ function generateServiceBarrels(spec: ApiSpec, ctx: EmitterContext): GeneratedFi
240
243
  }
241
244
 
242
245
  for (const [dirName, exports] of dirExports) {
243
- // Deduplicate (an enum and model could theoretically share a file name)
244
- const uniqueExports = [...new Set(exports)];
246
+ const exportSet = new Set(exports);
247
+
248
+ // When integrating into an existing SDK, include baseline exports from
249
+ // the api-surface so the barrel is comprehensive. This ensures stale
250
+ // entries (e.g., renamed files from previous generations) are removed
251
+ // when overwriteExisting replaces the barrel.
252
+ if (ctx.apiSurface) {
253
+ const addBaselineExports = (items: Record<string, any> | undefined) => {
254
+ if (!items) return;
255
+ for (const item of Object.values(items)) {
256
+ const sourceFile = (item as any).sourceFile as string | undefined;
257
+ if (!sourceFile) continue;
258
+ const match = sourceFile.match(/^src\/([^/]+)\/interfaces\/(.+)\.ts$/);
259
+ if (match && match[1] === dirName) {
260
+ exportSet.add(`export * from './${match[2].replace(/\.ts$/, '')}';`);
261
+ }
262
+ }
263
+ };
264
+ addBaselineExports(ctx.apiSurface.interfaces);
265
+ addBaselineExports(ctx.apiSurface.typeAliases);
266
+ addBaselineExports(ctx.apiSurface.enums);
267
+
268
+ // Scan the target directory for interface files not captured by the
269
+ // api-surface (e.g., list wrappers, hand-written types). Only add
270
+ // files whose exported symbols don't collide with symbols already
271
+ // claimed by another directory's barrel (TS2308 prevention).
272
+ if (ctx.targetDir) {
273
+ const interfacesDir = path.join(ctx.targetDir, 'src', dirName, 'interfaces');
274
+ const symbols = dirSymbols.get(dirName) ?? new Set<string>();
275
+ try {
276
+ for (const entry of fs.readdirSync(interfacesDir)) {
277
+ if (entry === 'index.ts') continue;
278
+ if (!entry.endsWith('.ts')) continue;
279
+ const stem = entry.replace(/\.ts$/, '');
280
+ const exportLine = `export * from './${stem}';`;
281
+ if (exportSet.has(exportLine)) continue;
282
+
283
+ // Extract exported symbol names from the file to check for conflicts
284
+ const content = fs.readFileSync(path.join(interfacesDir, entry), 'utf-8');
285
+ const exportedNames: string[] = [];
286
+ for (const m of content.matchAll(/export\s+(?:interface|type|enum|class|const|function)\s+(\w+)/g)) {
287
+ exportedNames.push(m[1]);
288
+ }
289
+
290
+ // Skip if any exported name collides with a symbol already
291
+ // claimed by any file (same or different directory)
292
+ const hasCollision = exportedNames.some((name) => globalExistingSymbols.has(name));
293
+ if (hasCollision) continue;
294
+
295
+ // Safe to add — register symbols and include in barrel
296
+ for (const name of exportedNames) {
297
+ symbols.add(name);
298
+ globalExistingSymbols.add(name);
299
+ }
300
+ exportSet.add(exportLine);
301
+ }
302
+ } catch {
303
+ // Directory doesn't exist in target — nothing to scan
304
+ }
305
+ }
306
+ }
307
+
308
+ // Deduplicate and sort
309
+ const uniqueExports = [...exportSet];
245
310
  uniqueExports.sort();
246
- files.push({
247
- path: `src/${dirName}/interfaces/index.ts`,
248
- content: uniqueExports.join('\n'),
249
- skipIfExists: true,
250
- });
311
+
312
+ if (ctx.apiSurface) {
313
+ // Integration mode: overwrite the barrel so stale entries are removed.
314
+ files.push({
315
+ path: `src/${dirName}/interfaces/index.ts`,
316
+ content: uniqueExports.join('\n'),
317
+ overwriteExisting: true,
318
+ });
319
+ } else {
320
+ // Standalone generation: only update if there are new exports.
321
+ const baselineSymbols = dirSymbolsFromBaseline.get(dirName);
322
+ const hasNewExports = baselineSymbols
323
+ ? uniqueExports.some((exp) => {
324
+ const match = exp.match(/from '\.\/(.*?)'/);
325
+ if (!match) return false;
326
+ return !baselineSymbols.has(match[1]);
327
+ })
328
+ : false;
329
+
330
+ files.push({
331
+ path: `src/${dirName}/interfaces/index.ts`,
332
+ content: uniqueExports.join('\n'),
333
+ skipIfExists: !hasNewExports,
334
+ });
335
+ }
251
336
  }
252
337
 
253
338
  return files;
@@ -457,7 +542,11 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
457
542
  }
458
543
 
459
544
  // Unassigned models (common) — use barrel if any exist
460
- const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name));
545
+ // Filter to reachable models only: oagen's generateAllFiles passes only
546
+ // service-referenced models to generateModels, so unreachable models
547
+ // never get interface files. Exporting them here would create broken imports.
548
+ const reachable = collectReferencedNames(spec.services, spec.models);
549
+ const unassignedModels = spec.models.filter((m) => !modelToService.has(m.name) && reachable.models.has(m.name));
461
550
  const commonEnums = spec.enums.filter((e) => {
462
551
  const enumService = findEnumService(e.name, spec.services);
463
552
  return !enumService;
@@ -527,23 +616,6 @@ function generateBarrel(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
527
616
  };
528
617
  }
529
618
 
530
- /**
531
- * Generate a worker-compatible barrel file that re-exports everything from
532
- * the main barrel. This keeps type exports in sync automatically.
533
- */
534
- function generateWorkerBarrel(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile {
535
- const lines: string[] = [];
536
-
537
- // Re-export everything from the main index — keeps type exports in sync
538
- lines.push("export * from './index';");
539
-
540
- return {
541
- path: 'src/index.worker.ts',
542
- content: lines.join('\n'),
543
- skipIfExists: true,
544
- };
545
- }
546
-
547
619
  function findEnumService(enumName: string, services: Service[]): string | undefined {
548
620
  for (const service of services) {
549
621
  for (const op of service.operations) {
@@ -610,62 +682,3 @@ function serverConstName(description: string): string {
610
682
  .toUpperCase()
611
683
  );
612
684
  }
613
-
614
- function generatePackageJson(ctx: EmitterContext): GeneratedFile {
615
- const pkg = {
616
- name: `@${ctx.namespace}/sdk`,
617
- version: '0.0.0',
618
- type: 'module',
619
- main: 'src/index.ts',
620
- types: 'src/index.ts',
621
- exports: {
622
- '.': './src/index.ts',
623
- },
624
- scripts: {
625
- test: 'jest',
626
- build: 'tsc',
627
- },
628
- devDependencies: {
629
- typescript: '^5.0.0',
630
- jest: '^29.0.0',
631
- 'jest-fetch-mock': '^3.0.0',
632
- '@types/jest': '^29.0.0',
633
- 'ts-jest': '^29.0.0',
634
- },
635
- };
636
-
637
- return {
638
- path: 'package.json',
639
- content: JSON.stringify(pkg, null, 2),
640
- skipIfExists: true,
641
- integrateTarget: false,
642
- };
643
- }
644
-
645
- function generateTsConfig(): GeneratedFile {
646
- const config = {
647
- compilerOptions: {
648
- target: 'ES2020',
649
- module: 'CommonJS',
650
- lib: ['ES2020'],
651
- declaration: true,
652
- strict: true,
653
- exactOptionalPropertyTypes: true,
654
- esModuleInterop: true,
655
- skipLibCheck: true,
656
- forceConsistentCasingInFileNames: true,
657
- resolveJsonModule: true,
658
- outDir: './lib',
659
- rootDir: './src',
660
- },
661
- include: ['src/**/*'],
662
- exclude: ['node_modules', 'lib', '**/*.spec.ts'],
663
- };
664
-
665
- return {
666
- path: 'tsconfig.json',
667
- content: JSON.stringify(config, null, 2),
668
- skipIfExists: true,
669
- integrateTarget: false,
670
- };
671
- }
package/src/node/enums.ts CHANGED
@@ -19,6 +19,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
19
19
  // Check baseline surface for representation and values
20
20
  const baselineEnum = ctx.apiSurface?.enums?.[enumDef.name];
21
21
  const baselineAlias = ctx.apiSurface?.typeAliases?.[enumDef.name];
22
+ const generatedPath = `src/${dirName}/interfaces/${fileName(enumDef.name)}.interface.ts`;
23
+
24
+ // If the baseline already provides this enum from a different file (e.g., `.enum.ts`),
25
+ // skip generation to avoid duplicate exports from the same barrel.
26
+ const baselineSourceFile = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
27
+ if (baselineSourceFile && baselineSourceFile !== generatedPath) {
28
+ continue;
29
+ }
30
+
22
31
  const lines: string[] = [];
23
32
 
24
33
  // Track whether the generated content has new values not in the baseline.