@workos/oagen-emitters 0.12.1 → 0.12.3

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 (45) 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-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  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/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
package/src/node/index.ts CHANGED
@@ -12,58 +12,394 @@ 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
+ //
319
+ // Adopted-service directories are treated like owned dirs for this
320
+ // purpose: adoption means oagen created the directory from scratch, so
321
+ // by construction there is no hand-written content to preserve and
322
+ // emitting tests/fixtures is safe. Rule (a) still drops files that
323
+ // somehow exist hand-written.
324
+ if (isUserOwnedAfterFirstEmit(f.path)) {
325
+ const dir = topLevelDir(f.path);
326
+ const isAdoptedDir = dir !== undefined && policy.adoptedServiceDirs.has(dir);
327
+ const isManagedDir = ownedPath || isAdoptedDir;
328
+ if (surface.files.has(f.path) && !surface.autogenFiles.has(f.path)) continue;
329
+ if (!isManagedDir && !surface.autogenFiles.has(f.path)) continue;
330
+ if (isManagedDir && !policy.regenerateOwnedTests) continue;
331
+ }
332
+
333
+ // Previously auto-generated files → fully overwrite so spec changes
334
+ // (e.g. parameter renames like `admin_emails` → `it_contact_emails`)
335
+ // propagate. The engine's default AST merger is additive and would
336
+ // leave the old name on existing methods. Files marked
337
+ // `@oagen-ignore-start`/`@oagen-ignore-end` regions inside the file
338
+ // are still preserved by `overwriteWithPreservedRegions` in the
339
+ // engine.
340
+ if (surface.autogenFiles.has(f.path) || ownedPath) {
341
+ f.overwriteExisting = true;
342
+ f.skipIfExists = false;
343
+ }
344
+
26
345
  if (f.content && !f.content.endsWith('\n')) {
27
346
  f.content += '\n';
28
347
  }
348
+ out.push(f);
29
349
  }
30
- return files;
350
+ return out;
31
351
  }
32
352
 
33
353
  export const nodeEmitter: Emitter = {
34
354
  language: 'node',
35
355
 
36
356
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
37
- return ensureTrailingNewlines(generateModelsAndSerializers(models, ctx));
357
+ const nodeCtx = withNodeOperationOverrides(ctx);
358
+ const surface = getSurface(nodeCtx);
359
+ return applyLiveSurface(generateModelsAndSerializers(models, nodeCtx), nodeCtx, surface);
38
360
  },
39
361
 
40
362
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
41
- return ensureTrailingNewlines(generateEnums(enums, ctx));
363
+ const nodeCtx = withNodeOperationOverrides(ctx);
364
+ const surface = getSurface(nodeCtx);
365
+ return applyLiveSurface(generateEnumFiles(enums, nodeCtx), nodeCtx, surface);
42
366
  },
43
367
 
44
368
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
45
- return ensureTrailingNewlines(generateResources(services, ctx));
369
+ const nodeCtx = withNodeOperationOverrides(ctx);
370
+ const surface = getSurface(nodeCtx);
371
+ return applyLiveSurface(generateResources(services, nodeCtx), nodeCtx, surface);
46
372
  },
47
373
 
48
374
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
49
- return ensureTrailingNewlines(generateClient(spec, ctx));
375
+ const nodeCtx = withNodeOperationOverrides(ctx);
376
+ const surface = getSurface(nodeCtx);
377
+ return applyLiveSurface(generateClient(spec, nodeCtx), nodeCtx, surface);
50
378
  },
51
379
 
52
- generateErrors(ctx: EmitterContext): GeneratedFile[] {
53
- return ensureTrailingNewlines(generateErrors(ctx));
380
+ // workos-node ships its own exception hierarchy under src/common/exceptions/.
381
+ // Re-emitting them would either skip (if files exist) or overwrite hand-edits.
382
+ generateErrors(_ctx: EmitterContext): GeneratedFile[] {
383
+ return [];
54
384
  },
55
385
 
56
386
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
57
- // TypeScript uses inline types — no separate type signature files needed
58
387
  return [];
59
388
  },
60
389
 
390
+ // Test specs and fixtures are hand-maintained except for explicitly-owned
391
+ // service directories.
61
392
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
62
- return ensureTrailingNewlines(generateTests(spec, ctx));
393
+ const nodeCtx = withNodeOperationOverrides(ctx);
394
+ if (!nodeOptions(nodeCtx).regenerateOwnedTests) return [];
395
+ const surface = getSurface(nodeCtx);
396
+ return applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface);
63
397
  },
64
398
 
65
- buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
66
- return buildOperationsMap(spec, ctx);
399
+ // No operations map needed — the manifest belongs to the staging+target flow,
400
+ // which the literal `--output <SDK>` command does not use.
401
+ buildOperationsMap(): Record<string, never> {
402
+ return {};
67
403
  },
68
404
 
69
405
  fileHeader(): string {
@@ -79,8 +415,6 @@ export const nodeEmitter: Emitter = {
79
415
  fs.existsSync(path.join(targetDir, '.eslintrc.js'));
80
416
 
81
417
  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
418
  return {
85
419
  cmd: 'bash',
86
420
  args: [