@workos/oagen-emitters 0.12.0 → 0.12.2

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 (53) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-C408Wh-o.mjs → plugin-eCuvoL1T.mjs} +3914 -2121
  12. package/dist/plugin-eCuvoL1T.mjs.map +1 -0
  13. package/dist/plugin.d.mts.map +1 -1
  14. package/dist/plugin.mjs +1 -1
  15. package/package.json +10 -10
  16. package/renovate.json +46 -6
  17. package/src/node/client.ts +19 -32
  18. package/src/node/enums.ts +67 -30
  19. package/src/node/errors.ts +2 -8
  20. package/src/node/field-plan.ts +188 -52
  21. package/src/node/fixtures.ts +11 -33
  22. package/src/node/index.ts +345 -20
  23. package/src/node/live-surface.ts +378 -0
  24. package/src/node/models.ts +540 -351
  25. package/src/node/naming.ts +119 -25
  26. package/src/node/node-overrides.ts +77 -0
  27. package/src/node/options.ts +41 -0
  28. package/src/node/resources.ts +455 -46
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +108 -83
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/src/rust/fixtures.ts +87 -1
  35. package/src/rust/models.ts +17 -2
  36. package/src/rust/resources.ts +697 -62
  37. package/src/rust/tests.ts +540 -20
  38. package/test/node/client.test.ts +106 -1201
  39. package/test/node/enums.test.ts +59 -130
  40. package/test/node/errors.test.ts +2 -3
  41. package/test/node/live-surface.test.ts +240 -0
  42. package/test/node/models.test.ts +396 -765
  43. package/test/node/naming.test.ts +69 -234
  44. package/test/node/resources.test.ts +376 -2036
  45. package/test/node/tests.test.ts +119 -0
  46. package/test/node/type-map.test.ts +49 -54
  47. package/test/node/utils.test.ts +29 -80
  48. package/test/rust/fixtures.test.ts +227 -0
  49. package/test/rust/models.test.ts +38 -0
  50. package/test/rust/resources.test.ts +505 -2
  51. package/test/rust/tests.test.ts +504 -0
  52. package/dist/plugin-C408Wh-o.mjs.map +0 -1
  53. package/test/node/serializers.test.ts +0 -444
package/src/node/index.ts CHANGED
@@ -12,58 +12,385 @@ import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
14
  import { generateModelsAndSerializers } from './models.js';
15
- import { generateEnums } from './enums.js';
16
- import { generateResources } from './resources.js';
15
+ import { generateEnums as generateEnumFiles } from './enums.js';
16
+ import { generateResources, resolveResourceClassName, resolveResourceDir } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
- import { generateErrors } from './errors.js';
18
+ import { generateTests as generateTestFiles } from './tests.js';
19
+ import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
20
+ import {
21
+ setBaselineSerializedNames,
22
+ setBaselineInterfaceNames,
23
+ setAdoptedModelNames,
24
+ resolveInterfaceName,
25
+ } from './naming.js';
26
+ import { withNodeOperationOverrides } from './node-overrides.js';
27
+ import { isNodeOwnedService, nodeOptions } from './options.js';
28
+ import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
29
+ import { groupByMount } from '../shared/resolved-ops.js';
30
+ import { assignModelsToServices, createServiceDirResolver } from './utils.js';
31
+ import { fileName } from './naming.js';
19
32
 
20
- import { generateTests } from './tests.js';
21
- import { buildOperationsMap } from './manifest.js';
33
+ /**
34
+ * Cache live-surface per ctx — every emitter method receives the same ctx in
35
+ * one oagen run, so we walk the target SDK once and reuse it.
36
+ */
37
+ const surfaceCache = new WeakMap<EmitterContext, LiveSurface>();
22
38
 
23
- /** Ensure every generated file's content ends with a trailing newline. */
24
- function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
39
+ function getSurface(ctx: EmitterContext): LiveSurface {
40
+ let surface = surfaceCache.get(ctx);
41
+ if (surface) return surface;
42
+ // Prefer --output (where we are writing) over --target (rare for node).
43
+ // The literal command `--output <SDK>` makes outputDir the SDK root.
44
+ const root = ctx.outputDir ?? ctx.targetDir;
45
+ surface = root ? buildLiveSurface(root) : emptyLiveSurface();
46
+ if (root) markPriorManifestAutogen(surface, root, ctx.priorTargetManifestPaths);
47
+ surfaceCache.set(ctx, surface);
48
+ setActiveLiveSurface(surface);
49
+
50
+ // Wire the type-map's resolver so field-type references (`schema: X`) and
51
+ // import statements (`import type { X }`) agree on the same name. The
52
+ // structural baseline match in `resolveInterfaceName` can map an IR name
53
+ // to a different live-SDK name (e.g. `AuditLogSchemaJson` → baseline
54
+ // `AuditLogSchemaResponse` via `overlayLookup.modelNameByIR`).
55
+ setDomainNameResolver((irName: string) => resolveInterfaceName(irName, ctx));
56
+
57
+ // Tell `naming.wireInterfaceName` which `Serialized*` interfaces exist in
58
+ // the live SDK so generated imports use the legacy name where applicable.
59
+ // Source from the api-surface JSON (richer) when present, else fall back
60
+ // to the disk walk.
61
+ const serialized = new Set<string>();
62
+ const ifaces = ctx.apiSurface?.interfaces;
63
+ if (ifaces) {
64
+ for (const name of Object.keys(ifaces)) {
65
+ if (name.startsWith('Serialized')) serialized.add(name);
66
+ }
67
+ }
68
+ for (const name of surface.interfaces.keys()) {
69
+ if (name.startsWith('Serialized')) serialized.add(name);
70
+ }
71
+ setBaselineSerializedNames(serialized);
72
+
73
+ // Full set of baseline interface names — used by `wireInterfaceName` to
74
+ // detect when a `Response`-suffixed name has no `*Wire` companion in
75
+ // the live SDK and is therefore the single-form wire interface.
76
+ const allInterfaces = new Set<string>();
77
+ if (ifaces) {
78
+ for (const name of Object.keys(ifaces)) allInterfaces.add(name);
79
+ }
80
+ for (const name of surface.interfaces.keys()) allInterfaces.add(name);
81
+ setBaselineInterfaceNames(allInterfaces);
82
+
83
+ // Inline-enum optimization is intentionally disabled. workos-node emits the
84
+ // dual `const X = {...} as const; type X = ...` pattern so callers can use
85
+ // members at runtime (e.g. `GenerateLinkIntent.SSO`). Inlining the type
86
+ // would drop the enum's file but leave value references in hand-written
87
+ // test files dangling — see admin-portal.spec.ts referencing `.SSO`.
88
+ // Pass an empty map; type-map will fall back to emitting the symbol name.
89
+ setInlineEnumUnions(new Map());
90
+ setAdoptedModelNames(computeAdoptedModelNames(ctx, surface));
91
+ return surface;
92
+ }
93
+
94
+ /**
95
+ * Apply the live-surface filter to a batch of generated files.
96
+ *
97
+ * Three signals decide what happens to each `GeneratedFile`:
98
+ *
99
+ * 1. `@oagen-ignore-file` marker (`surface.protectedFiles`) — the user has
100
+ * taken ownership of the file, never write on top of it.
101
+ * 2. `auto-generated by oagen` header (`surface.autogenFiles`) — the file
102
+ * was produced by a prior generation. Spec changes (e.g. parameter
103
+ * renames) must propagate, so we leave the file in the output and let
104
+ * the engine's AST merger update it. No skip flags.
105
+ * 3. File on disk without either marker — treat as hand-written; drop from
106
+ * the output to avoid the engine prepending a header on `skipIfExists`
107
+ * files (see writeFiles:~4360 in @workos/oagen) or merging unrequested
108
+ * changes into it.
109
+ * 4. Brand-new path in an existing SDK — drop it. For a live SDK we treat
110
+ * the git-tracked baseline as the managed surface; generating new files
111
+ * is what caused the workos-node file explosion.
112
+ *
113
+ * `integrateTarget: false` files (smoke-manifest.json etc.) are also dropped:
114
+ * with no `--target` step they would otherwise land as untracked cruft.
115
+ *
116
+ * Note: pairing this with `--no-prune` is required for stable behavior — see
117
+ * `scripts/sdk-generate.sh` in the spec repo, which enables it for `--lang node`.
118
+ */
119
+ /**
120
+ * `*.spec.ts`, `*.test.ts`, and JSON fixtures under `fixtures/` are owned by
121
+ * the test author after first emission. The emitter scaffolds them on a
122
+ * brand-new resource, but subsequent regenerations must leave them alone —
123
+ * assertions get hand-tuned, fixture data gets stabilized, and re-emission
124
+ * would point them at sibling serializers/interfaces that may have shape-
125
+ * shifted under the user's feet.
126
+ */
127
+ function isUserOwnedAfterFirstEmit(relPath: string): boolean {
128
+ if (relPath.endsWith('.spec.ts') || relPath.endsWith('.test.ts')) return true;
129
+ if (/\/fixtures\/[^/]+\.json$/.test(relPath)) return true;
130
+ return false;
131
+ }
132
+
133
+ interface LiveSurfacePolicy {
134
+ managedPaths: Set<string>;
135
+ hasExistingSdk: boolean;
136
+ adoptedServiceDirs: Set<string>;
137
+ ownedServiceDirs: Set<string>;
138
+ oagenOwnedDirs: Set<string>;
139
+ regenerateOwnedTests: boolean;
140
+ }
141
+
142
+ function managedPathsFor(ctx: EmitterContext, surface: LiveSurface): Set<string> {
143
+ const managedPaths = new Set(surface.trackedFiles.size > 0 ? surface.trackedFiles : surface.files);
144
+ for (const relPath of ctx.priorTargetManifestPaths ?? []) {
145
+ if (relPath.startsWith('src/')) managedPaths.add(relPath);
146
+ }
147
+ return managedPaths;
148
+ }
149
+
150
+ function markPriorManifestAutogen(
151
+ surface: LiveSurface,
152
+ root: string,
153
+ priorManifestPaths: Set<string> | undefined,
154
+ ): void {
155
+ if (!priorManifestPaths) return;
156
+
157
+ for (const relPath of priorManifestPaths) {
158
+ if (!relPath.startsWith('src/')) continue;
159
+ if (!surface.files.has(relPath)) continue;
160
+ if (surface.protectedFiles.has(relPath)) continue;
161
+
162
+ try {
163
+ const text = fs.readFileSync(path.join(root, relPath), 'utf8');
164
+ if (/auto-generated by oagen/i.test(text.slice(0, 400))) {
165
+ surface.autogenFiles.add(relPath);
166
+ extractManifestFunctions(text, relPath, surface);
167
+ }
168
+ } catch {
169
+ // File disappeared between surface walk and read; ignore it.
170
+ }
171
+ }
172
+ }
173
+
174
+ const MANIFEST_FUNCTION_RE = /^\s*export\s+(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)/gm;
175
+ const MANIFEST_CONST_FN_RE =
176
+ /^\s*export\s+const\s+([a-zA-Z_$][\w$]*)\s*(?::\s*[^=]+)?=\s*(?:async\s+)?(?:<[^>]*>\s*)?\(/gm;
177
+
178
+ function extractManifestFunctions(text: string, relPath: string, surface: LiveSurface): void {
179
+ for (const match of text.matchAll(MANIFEST_FUNCTION_RE)) {
180
+ surface.functions.set(match[1], relPath);
181
+ }
182
+ for (const match of text.matchAll(MANIFEST_CONST_FN_RE)) {
183
+ surface.functions.set(match[1], relPath);
184
+ }
185
+ }
186
+
187
+ function buildLiveSurfacePolicy(ctx: EmitterContext, surface: LiveSurface): LiveSurfacePolicy {
188
+ const managedPaths = managedPathsFor(ctx, surface);
189
+ const hasExistingSdk = managedPaths.size > 0;
190
+ const adoptedServiceDirs = nodeOptions(ctx).adoptMissingServices
191
+ ? computeAdoptedServiceDirs(ctx, surface)
192
+ : new Set<string>();
193
+ const ownedServiceDirs = computeOwnedServiceDirs(ctx);
194
+ for (const dir of ownedServiceDirs) {
195
+ for (const relPath of surface.files) {
196
+ if (topLevelDir(relPath) === dir) managedPaths.add(relPath);
197
+ }
198
+ }
199
+
200
+ return {
201
+ managedPaths,
202
+ hasExistingSdk,
203
+ adoptedServiceDirs,
204
+ ownedServiceDirs,
205
+ oagenOwnedDirs: topLevelDirs(surface.autogenFiles),
206
+ regenerateOwnedTests: nodeOptions(ctx).regenerateOwnedTests === true,
207
+ };
208
+ }
209
+
210
+ function computeOwnedServiceDirs(ctx: EmitterContext): Set<string> {
211
+ const dirs = new Set<string>();
212
+ if ((nodeOptions(ctx).ownedServices ?? []).length === 0) return dirs;
213
+
214
+ const mountGroups = groupByMount(ctx);
215
+ const services =
216
+ mountGroups.size > 0
217
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
218
+ : ctx.spec.services;
219
+ const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
220
+
221
+ for (const service of services) {
222
+ const resourceName = resolveResourceClassName(service, ctx);
223
+ if (!isNodeOwnedService(ctx, service.name, resourceName)) continue;
224
+ dirs.add(resolveResourceDir(service, ctx));
225
+ dirs.add(resolveDir(service.name));
226
+ }
227
+
228
+ return dirs;
229
+ }
230
+
231
+ function computeAdoptedServiceDirs(ctx: EmitterContext, surface: LiveSurface): Set<string> {
232
+ const dirs = new Set<string>();
233
+ const mountGroups = groupByMount(ctx);
234
+ const services =
235
+ mountGroups.size > 0
236
+ ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
237
+ : ctx.spec.services;
238
+ const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
239
+
240
+ for (const service of services) {
241
+ if (service.operations.length === 0) continue;
242
+
243
+ const resourceName = resolveResourceClassName(service, ctx);
244
+ if (surface.classes.has(resourceName) || ctx.apiSurface?.classes?.[resourceName]) continue;
245
+
246
+ const resourceDir = resolveResourceDir(service, ctx);
247
+ const resourcePath = `src/${resourceDir}/${fileName(resourceName)}.ts`;
248
+ if (surface.protectedFiles.has(resourcePath)) continue;
249
+
250
+ dirs.add(resourceDir);
251
+ dirs.add(resolveDir(service.name));
252
+ }
253
+
254
+ return dirs;
255
+ }
256
+
257
+ function computeAdoptedModelNames(ctx: EmitterContext, surface: LiveSurface): Set<string> {
258
+ if (!nodeOptions(ctx).adoptMissingServices) return new Set();
259
+
260
+ const adoptedServiceDirs = computeAdoptedServiceDirs(ctx, surface);
261
+ if (adoptedServiceDirs.size === 0) return new Set();
262
+
263
+ const modelToService = assignModelsToServices(ctx.spec.models, ctx.spec.services, ctx.modelHints);
264
+ const { resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
265
+ const names = new Set<string>();
266
+ for (const model of ctx.spec.models) {
267
+ const dirName = resolveDir(modelToService.get(model.name));
268
+ if (adoptedServiceDirs.has(dirName)) names.add(model.name);
269
+ }
270
+ return names;
271
+ }
272
+
273
+ function topLevelDirs(paths: Set<string>): Set<string> {
274
+ const dirs = new Set<string>();
275
+ for (const relPath of paths) {
276
+ const dir = topLevelDir(relPath);
277
+ if (dir) dirs.add(dir);
278
+ }
279
+ return dirs;
280
+ }
281
+
282
+ function topLevelDir(relPath: string): string | undefined {
283
+ return relPath.match(/^src\/([^/]+)\//)?.[1];
284
+ }
285
+
286
+ function canCreateNewPath(relPath: string, policy: LiveSurfacePolicy): boolean {
287
+ const dir = topLevelDir(relPath);
288
+ if (!dir) return false;
289
+ return policy.adoptedServiceDirs.has(dir) || policy.ownedServiceDirs.has(dir) || policy.oagenOwnedDirs.has(dir);
290
+ }
291
+
292
+ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
293
+ const dir = topLevelDir(relPath);
294
+ return dir !== undefined && policy.ownedServiceDirs.has(dir);
295
+ }
296
+
297
+ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface: LiveSurface): GeneratedFile[] {
298
+ const out: GeneratedFile[] = [];
299
+ const policy = buildLiveSurfacePolicy(ctx, surface);
25
300
  for (const f of files) {
301
+ const ownedPath = isOwnedPath(f.path, policy);
302
+ if (f.integrateTarget === false) continue;
303
+ if (surface.protectedFiles.has(f.path)) continue;
304
+ if (policy.hasExistingSdk && !policy.managedPaths.has(f.path) && !canCreateNewPath(f.path, policy)) continue;
305
+
306
+ // Hand-written files (on disk, no `auto-generated by oagen` header) →
307
+ // drop. The engine would otherwise prepend the header on
308
+ // `skipIfExists: true` files (writeFiles:~4360), and the merger may
309
+ // try to splice generated symbols into hand-written ones.
310
+ if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path) && !ownedPath) continue;
311
+
312
+ // Test specs and fixtures: author-owned after first emission. Once a
313
+ // test file or fixture exists on disk, the user owns it — the emitter
314
+ // re-emitting against new IR would either replace assertions/data the
315
+ // user has hand-tuned, or leave it pointing at sibling files that no
316
+ // longer have the same shape. Treat existing test/fixture files as
317
+ // frozen even when they carry the auto-gen header.
318
+ if (isUserOwnedAfterFirstEmit(f.path)) {
319
+ if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path)) continue;
320
+ if (!ownedPath && !surface.autogenFiles.has(f.path)) continue;
321
+ if (ownedPath && !policy.regenerateOwnedTests) continue;
322
+ }
323
+
324
+ // Previously auto-generated files → fully overwrite so spec changes
325
+ // (e.g. parameter renames like `admin_emails` → `it_contact_emails`)
326
+ // propagate. The engine's default AST merger is additive and would
327
+ // leave the old name on existing methods. Files marked
328
+ // `@oagen-ignore-start`/`@oagen-ignore-end` regions inside the file
329
+ // are still preserved by `overwriteWithPreservedRegions` in the
330
+ // engine.
331
+ if (surface.autogenFiles.has(f.path) || ownedPath) {
332
+ f.overwriteExisting = true;
333
+ f.skipIfExists = false;
334
+ }
335
+
26
336
  if (f.content && !f.content.endsWith('\n')) {
27
337
  f.content += '\n';
28
338
  }
339
+ out.push(f);
29
340
  }
30
- return files;
341
+ return out;
31
342
  }
32
343
 
33
344
  export const nodeEmitter: Emitter = {
34
345
  language: 'node',
35
346
 
36
347
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
37
- return ensureTrailingNewlines(generateModelsAndSerializers(models, ctx));
348
+ const nodeCtx = withNodeOperationOverrides(ctx);
349
+ const surface = getSurface(nodeCtx);
350
+ return applyLiveSurface(generateModelsAndSerializers(models, nodeCtx), nodeCtx, surface);
38
351
  },
39
352
 
40
353
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
41
- return ensureTrailingNewlines(generateEnums(enums, ctx));
354
+ const nodeCtx = withNodeOperationOverrides(ctx);
355
+ const surface = getSurface(nodeCtx);
356
+ return applyLiveSurface(generateEnumFiles(enums, nodeCtx), nodeCtx, surface);
42
357
  },
43
358
 
44
359
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
45
- return ensureTrailingNewlines(generateResources(services, ctx));
360
+ const nodeCtx = withNodeOperationOverrides(ctx);
361
+ const surface = getSurface(nodeCtx);
362
+ return applyLiveSurface(generateResources(services, nodeCtx), nodeCtx, surface);
46
363
  },
47
364
 
48
365
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
49
- return ensureTrailingNewlines(generateClient(spec, ctx));
366
+ const nodeCtx = withNodeOperationOverrides(ctx);
367
+ const surface = getSurface(nodeCtx);
368
+ return applyLiveSurface(generateClient(spec, nodeCtx), nodeCtx, surface);
50
369
  },
51
370
 
52
- generateErrors(ctx: EmitterContext): GeneratedFile[] {
53
- return ensureTrailingNewlines(generateErrors(ctx));
371
+ // workos-node ships its own exception hierarchy under src/common/exceptions/.
372
+ // Re-emitting them would either skip (if files exist) or overwrite hand-edits.
373
+ generateErrors(_ctx: EmitterContext): GeneratedFile[] {
374
+ return [];
54
375
  },
55
376
 
56
377
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
57
- // TypeScript uses inline types — no separate type signature files needed
58
378
  return [];
59
379
  },
60
380
 
381
+ // Test specs and fixtures are hand-maintained except for explicitly-owned
382
+ // service directories.
61
383
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
62
- return ensureTrailingNewlines(generateTests(spec, ctx));
384
+ const nodeCtx = withNodeOperationOverrides(ctx);
385
+ if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
386
+ const surface = getSurface(nodeCtx);
387
+ return applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface);
63
388
  },
64
389
 
65
- buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
66
- return buildOperationsMap(spec, ctx);
390
+ // No operations map needed — the manifest belongs to the staging+target flow,
391
+ // which the literal `--output <SDK>` command does not use.
392
+ buildOperationsMap(): Record<string, never> {
393
+ return {};
67
394
  },
68
395
 
69
396
  fileHeader(): string {
@@ -79,8 +406,6 @@ export const nodeEmitter: Emitter = {
79
406
  fs.existsSync(path.join(targetDir, '.eslintrc.js'));
80
407
 
81
408
  if (hasPrettier && hasEslint) {
82
- // Chain ESLint autofix (e.g. unused-import removal) then prettier.
83
- // ESLint errors are suppressed so formatting still runs on lint failure.
84
409
  return {
85
410
  cmd: 'bash',
86
411
  args: [