@workos/oagen-emitters 0.16.0 → 0.16.1

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.
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-DuB1UozS.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-CpO8rePT.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -40,12 +40,12 @@
40
40
  "devDependencies": {
41
41
  "@commitlint/cli": "^21.0.2",
42
42
  "@commitlint/config-conventional": "^21.0.2",
43
- "@types/node": "^25.9.1",
43
+ "@types/node": "^25.9.3",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.53.0",
46
- "oxlint": "^1.68.0",
47
- "prettier": "^3.8.3",
48
- "tsdown": "^0.22.1",
45
+ "oxfmt": "^0.54.0",
46
+ "oxlint": "^1.69.0",
47
+ "prettier": "^3.8.4",
48
+ "tsdown": "^0.22.2",
49
49
  "tsx": "^4.22.4",
50
50
  "typescript": "^6.0.3",
51
51
  "vitest": "^4.1.8"
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.22.0"
57
+ "@workos/oagen": "^0.22.5"
58
58
  }
59
59
  }
package/src/node/enums.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile, Model, Service } from '@workos/oagen';
2
- import { assignModelsToServices, collectFieldDependencies, toPascalCase, walkTypeRef } from '@workos/oagen';
2
+ import { collectFieldDependencies, toPascalCase, walkTypeRef } from '@workos/oagen';
3
3
  import { fileName, resolveServiceDir, buildServiceNameMap } from './naming.js';
4
- import { docComment } from './utils.js';
4
+ import { docComment, assignModelsToEmittableServices } from './utils.js';
5
5
  import { isInlineEnum } from './type-map.js';
6
+ import { isNodeOwnedService } from './options.js';
6
7
  import { liveSurfaceConstEnumMembers, liveSurfaceInterfacePath } from './live-surface.js';
7
8
 
8
9
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -30,7 +31,15 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
30
31
  if (dirName === 'common' && !baselineSourceFile && (ctx.outputDir || ctx.targetDir || ctx.apiSurface)) {
31
32
  continue;
32
33
  }
33
- if (baselineSourceFile && baselineSourceFile !== generatedPath) {
34
+ // The declared-elsewhere skip must not fire for OWNED services: the
35
+ // "elsewhere" is typically the very interface file the owned-service
36
+ // regeneration is simultaneously overwriting (e.g. legacy enums declared
37
+ // inline in organization-domain.interface.ts), so skipping here leaves
38
+ // the names referenced by generated code but declared nowhere. Under
39
+ // ownership the canonical per-service module is the source of truth —
40
+ // emit it; the model emitter plans its import from the same path.
41
+ const isOwnedEnumService = isNodeOwnedService(ctx, service, service ? serviceNameMap.get(service) : undefined);
42
+ if (baselineSourceFile && baselineSourceFile !== generatedPath && !isOwnedEnumService) {
34
43
  continue;
35
44
  }
36
45
 
@@ -160,7 +169,11 @@ export function assignEnumsToServices(
160
169
  }
161
170
 
162
171
  if (models.length > 0) {
163
- const modelToService = assignModelsToServices(models, services, ctx?.modelHints);
172
+ // Use the emittable-services assignment (not the raw engine one) so an
173
+ // enum referenced through a model that was re-homed into an owned
174
+ // service directory follows the model — `generateEnums` emission and the
175
+ // model's enum imports must agree on the directory.
176
+ const modelToService = assignModelsToEmittableServices(models, services, ctx);
164
177
  for (const model of models) {
165
178
  const service = modelToService.get(model.name);
166
179
  if (!service) continue;
package/src/node/index.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  Enum,
9
9
  Service,
10
10
  } from '@workos/oagen';
11
+ import { toPascalCase } from '@workos/oagen';
11
12
  import * as fs from 'node:fs';
12
13
  import * as path from 'node:path';
13
14
 
@@ -18,10 +19,17 @@ import { generateClient } from './client.js';
18
19
  import { generateTests as generateTestFiles } from './tests.js';
19
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
20
21
  import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
21
- import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface, type LiveSurface } from './live-surface.js';
22
+ import {
23
+ buildLiveSurface,
24
+ emptyLiveSurface,
25
+ mergeGeneratedClassMethodsIntoExisting,
26
+ setActiveLiveSurface,
27
+ type LiveSurface,
28
+ } from './live-surface.js';
22
29
  import {
23
30
  setBaselineSerializedNames,
24
31
  setBaselineInterfaceNames,
32
+ setBaselineDeclaredNames,
25
33
  setAdoptedModelNames,
26
34
  setDiscriminatedModelNames,
27
35
  setStructurallyRenamedDomainNames,
@@ -31,7 +39,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
31
39
  import { isNodeOwnedService, nodeOptions } from './options.js';
32
40
  import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
33
41
  import { groupByMount } from '../shared/resolved-ops.js';
34
- import { assignModelsToServices, createServiceDirResolver } from './utils.js';
42
+ import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
35
43
  import { fileName } from './naming.js';
36
44
 
37
45
  /**
@@ -58,6 +66,24 @@ function getEmittedPaths(ctx: EmitterContext): Set<string> {
58
66
  return set;
59
67
  }
60
68
 
69
+ /**
70
+ * Every `GeneratedFile` the node emitter has produced so far in this ctx,
71
+ * keyed by path. All emitter hooks share one ctx (and the engine only reads
72
+ * file contents after the last hook returns), so the final hook can run a
73
+ * whole-run pass over files emitted by earlier hooks — see
74
+ * `enforceEmittedImportInvariant`.
75
+ */
76
+ const emittedFilesCache = new WeakMap<EmitterContext, Map<string, GeneratedFile>>();
77
+
78
+ function getEmittedFiles(ctx: EmitterContext): Map<string, GeneratedFile> {
79
+ let map = emittedFilesCache.get(ctx);
80
+ if (!map) {
81
+ map = new Map();
82
+ emittedFilesCache.set(ctx, map);
83
+ }
84
+ return map;
85
+ }
86
+
61
87
  function getSurface(ctx: EmitterContext): LiveSurface {
62
88
  let surface = surfaceCache.get(ctx);
63
89
  if (surface) return surface;
@@ -102,6 +128,18 @@ function getSurface(ctx: EmitterContext): LiveSurface {
102
128
  for (const name of surface.interfaces.keys()) allInterfaces.add(name);
103
129
  setBaselineInterfaceNames(allInterfaces);
104
130
 
131
+ // Every DECLARED baseline name — interfaces and type aliases, from both
132
+ // the api-surface JSON and the disk walk (whose `interfaces` map already
133
+ // includes `export type` aliases). `resolveInterfaceName` uses this to
134
+ // let exact-name declarations preempt structural renames: an alias-form
135
+ // file (`export type X = Y;`) carries no fields, so the engine's
136
+ // structural matcher would otherwise re-point IR model X at a similarly
137
+ // shaped interface and emit renamed duplicates that flip the file's form
138
+ // on every regeneration.
139
+ const declaredNames = new Set<string>(allInterfaces);
140
+ for (const name of Object.keys(ctx.apiSurface?.typeAliases ?? {})) declaredNames.add(name);
141
+ setBaselineDeclaredNames(declaredNames);
142
+
105
143
  // Inline-enum optimization is intentionally disabled. workos-node emits the
106
144
  // dual `const X = {...} as const; type X = ...` pattern so callers can use
107
145
  // members at runtime (e.g. `GenerateLinkIntent.SSO`). Inlining the type
@@ -342,6 +380,16 @@ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
342
380
  return dir !== undefined && policy.ownedServiceDirs.has(dir);
343
381
  }
344
382
 
383
+ /** Read the current on-disk content of a live-surface file, if present. */
384
+ function readExistingSurfaceFile(surface: LiveSurface, relPath: string): string | null {
385
+ if (!surface.rootDir) return null;
386
+ try {
387
+ return fs.readFileSync(path.join(surface.rootDir, relPath), 'utf8');
388
+ } catch {
389
+ return null;
390
+ }
391
+ }
392
+
345
393
  function extractRelativeImportPaths(content: string, fromPath: string): string[] {
346
394
  const dir = path.dirname(fromPath);
347
395
  const paths: string[] = [];
@@ -438,7 +486,26 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
438
486
  // `@oagen-ignore-start`/`@oagen-ignore-end` regions inside the file
439
487
  // are still preserved by `overwriteWithPreservedRegions` in the
440
488
  // engine.
489
+ //
490
+ // Exception: a NOT-owned, NOT-adopted service can receive a PARTIAL
491
+ // resource emission — resources.ts filters out operations already
492
+ // covered by the baseline class, leaving only the new methods (see
493
+ // generateResourceClass). Forcing a full overwrite with that partial
494
+ // class deletes the existing public methods (workos-node's
495
+ // api-keys.ts lost four methods when the spec gained one operation).
496
+ // When the generated class would drop methods that the on-disk class
497
+ // declares, merge instead: keep the existing file text verbatim and
498
+ // append only the new methods plus the imports they need.
441
499
  if (surface.autogenFiles.has(f.path) || ownedPath) {
500
+ const dir = topLevelDir(f.path);
501
+ const isAdoptedDirPath = dir !== undefined && policy.adoptedServiceDirs.has(dir);
502
+ if (!ownedPath && !isAdoptedDirPath && surface.autogenFiles.has(f.path)) {
503
+ const existingText = readExistingSurfaceFile(surface, f.path);
504
+ if (existingText !== null) {
505
+ const merged = mergeGeneratedClassMethodsIntoExisting(existingText, f.content);
506
+ if (merged !== null) f.content = merged;
507
+ }
508
+ }
442
509
  f.overwriteExisting = true;
443
510
  f.skipIfExists = false;
444
511
  }
@@ -449,10 +516,192 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
449
516
  out.push(f);
450
517
  }
451
518
  const emitted = getEmittedPaths(ctx);
452
- for (const f of out) emitted.add(f.path);
519
+ const emittedFiles = getEmittedFiles(ctx);
520
+ for (const f of out) {
521
+ emitted.add(f.path);
522
+ emittedFiles.set(f.path, f);
523
+ }
453
524
  return out;
454
525
  }
455
526
 
527
+ // ---------------------------------------------------------------------------
528
+ // Import-resolution invariant
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /**
532
+ * Matches single-line `import`/`export … from './relative'` statements — the
533
+ * only form the node emitter produces. Captures: keyword, optional ` type`
534
+ * modifier, the binding clause (`* [as ns]` or `{ … }`), and the module path.
535
+ */
536
+ const RELATIVE_FROM_STMT_RE =
537
+ /^(import|export)(\s+type)?\s+(\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"];?\s*$/;
538
+
539
+ const EXPORTED_DECL_RE =
540
+ /^export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:interface|class|enum|function|const|let|var|type)\s+([A-Za-z_$][\w$]*)/gm;
541
+ const EXPORTED_CLAUSE_RE = /^export\s+(?:type\s+)?\{([^}]*)\}/gm;
542
+
543
+ /** Repo-relative paths a relative import specifier may resolve to. */
544
+ function importTargetCandidates(fromPath: string, spec: string): string[] {
545
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromPath), spec));
546
+ return [base, `${base}.ts`, `${base}/index.ts`];
547
+ }
548
+
549
+ /** Index exported symbol → file path across this run's emitted contents. */
550
+ function indexEmittedExports(files: GeneratedFile[]): Map<string, string> {
551
+ const index = new Map<string, string>();
552
+ for (const f of files) {
553
+ if (!f.path.endsWith('.ts') || !f.content) continue;
554
+ for (const m of f.content.matchAll(EXPORTED_DECL_RE)) {
555
+ if (!index.has(m[1])) index.set(m[1], f.path);
556
+ }
557
+ for (const m of f.content.matchAll(EXPORTED_CLAUSE_RE)) {
558
+ for (const raw of m[1].split(',')) {
559
+ const entry = raw.trim();
560
+ if (!entry) continue;
561
+ const parts = entry.split(/\s+as\s+/);
562
+ const exported = (parts[1] ?? parts[0]).replace(/^type\s+/, '').trim();
563
+ if (exported && !index.has(exported)) index.set(exported, f.path);
564
+ }
565
+ }
566
+ }
567
+ return index;
568
+ }
569
+
570
+ /**
571
+ * Enforce: every relative import/re-export path in emitted code resolves to
572
+ * either (i) a file emitted in the same run, or (ii) a file that already
573
+ * exists on disk in the target SDK.
574
+ *
575
+ * Violations observed in real generations (all TS2307 in otherwise-valid
576
+ * output): serializer imports pointing at canonical paths while the function
577
+ * lives in a legacy hand serializer under a different filename; barrels
578
+ * re-exporting a module-local enum file whose declaration lives under
579
+ * `src/common/interfaces`; barrels exporting interface files that no run
580
+ * emits at all.
581
+ *
582
+ * Repair strategy, per statement whose target is neither emitted nor on
583
+ * disk:
584
+ * 1. Locate each imported symbol (this run's emissions first, then the
585
+ * live-surface declaration maps) and rewrite the path to where the
586
+ * symbol actually lives — splitting into one statement per location
587
+ * when symbols are spread across files.
588
+ * 2. `export * from` / namespace imports carry no symbol list, so derive
589
+ * the expected symbol from the file stem (`foo-bar.interface` →
590
+ * `FooBar`, `foo.serializer` → `deserializeFoo`/`serializeFoo`).
591
+ * 3. When a symbol exists nowhere, drop the statement and warn — a missing
592
+ * named export fails loudly at the usage site instead of as a phantom
593
+ * module, and a barrel line for a never-emitted file is pure noise.
594
+ *
595
+ * Mutates `f.content` in place and returns the warnings issued.
596
+ */
597
+ export function enforceEmittedImportInvariant(
598
+ files: Iterable<GeneratedFile>,
599
+ emittedPaths: Set<string>,
600
+ surface: LiveSurface,
601
+ ): string[] {
602
+ const fileList = [...files];
603
+ const emittedSymbols = indexEmittedExports(fileList);
604
+ const warnings: string[] = [];
605
+
606
+ const targetExists = (fromPath: string, spec: string): boolean =>
607
+ importTargetCandidates(fromPath, spec).some((p) => emittedPaths.has(p) || surface.files.has(p));
608
+
609
+ const locateSymbol = (name: string): string | undefined =>
610
+ emittedSymbols.get(name) ??
611
+ surface.functions.get(name) ??
612
+ surface.interfaces.get(name)?.filePath ??
613
+ surface.classes.get(name)?.filePath;
614
+
615
+ for (const f of fileList) {
616
+ if (!f.path.endsWith('.ts') || !f.content) continue;
617
+ let changed = false;
618
+ const outLines: string[] = [];
619
+ for (const line of f.content.split('\n')) {
620
+ const m = line.match(RELATIVE_FROM_STMT_RE);
621
+ if (!m || targetExists(f.path, m[4])) {
622
+ outLines.push(line);
623
+ continue;
624
+ }
625
+ const [, keyword, typeMod, clause, spec] = m;
626
+ const repaired = repairUnresolvableStatement(f.path, keyword, typeMod ?? '', clause, spec, locateSymbol);
627
+ changed = true;
628
+ outLines.push(...repaired.lines);
629
+ if (repaired.warning) warnings.push(repaired.warning);
630
+ }
631
+ if (changed) f.content = outLines.join('\n');
632
+ }
633
+
634
+ for (const w of warnings) console.warn(w);
635
+ return warnings;
636
+ }
637
+
638
+ function repairUnresolvableStatement(
639
+ fromPath: string,
640
+ keyword: string,
641
+ typeMod: string,
642
+ clause: string,
643
+ spec: string,
644
+ locateSymbol: (name: string) => string | undefined,
645
+ ): { lines: string[]; warning?: string } {
646
+ if (clause.startsWith('{')) {
647
+ const entries = clause
648
+ .slice(1, -1)
649
+ .split(',')
650
+ .map((e) => e.trim())
651
+ .filter(Boolean);
652
+ const byLocation = new Map<string, string[]>();
653
+ const missing: string[] = [];
654
+ for (const entry of entries) {
655
+ const source = entry
656
+ .split(/\s+as\s+/)[0]
657
+ .replace(/^type\s+/, '')
658
+ .trim();
659
+ const location = locateSymbol(source);
660
+ if (!location) {
661
+ missing.push(source);
662
+ continue;
663
+ }
664
+ if (location === fromPath) continue; // declared locally — no import needed
665
+ const group = byLocation.get(location);
666
+ if (group) {
667
+ group.push(entry);
668
+ } else {
669
+ byLocation.set(location, [entry]);
670
+ }
671
+ }
672
+ // Emit the symbols we *can* relocate; only the genuinely-missing ones are
673
+ // dropped (and warned about). Returning [] for the whole clause when any
674
+ // one symbol is missing would also discard the resolvable symbols, failing
675
+ // them with TS2305 at their usage sites — the breakage this pass prevents.
676
+ const lines = [...byLocation].map(
677
+ ([location, group]) =>
678
+ `${keyword}${typeMod} { ${group.join(', ')} } from '${relativeImport(fromPath, location)}';`,
679
+ );
680
+ const warning =
681
+ missing.length > 0
682
+ ? `oagen(node): dropped unresolvable symbol(s) from ${keyword} in ${fromPath}: '${spec}' — found neither in this run's output nor in the target SDK: ${missing.join(', ')}`
683
+ : undefined;
684
+ return { lines, warning };
685
+ }
686
+
687
+ // `* [as ns]` — no symbol list; derive the expected symbol from the stem.
688
+ const stem = spec.split('/').pop() ?? '';
689
+ let location: string | undefined;
690
+ if (stem.endsWith('.interface')) {
691
+ location = locateSymbol(toPascalCase(stem.slice(0, -'.interface'.length)));
692
+ } else if (stem.endsWith('.serializer')) {
693
+ const base = toPascalCase(stem.slice(0, -'.serializer'.length));
694
+ location = locateSymbol(`deserialize${base}`) ?? locateSymbol(`serialize${base}`);
695
+ }
696
+ if (location && location !== fromPath) {
697
+ return { lines: [`${keyword}${typeMod} ${clause} from '${relativeImport(fromPath, location)}';`] };
698
+ }
699
+ return {
700
+ lines: [],
701
+ warning: `oagen(node): dropped unresolvable ${keyword} in ${fromPath}: '${spec}' — module is neither emitted this run nor present in the target SDK`,
702
+ };
703
+ }
704
+
456
705
  /**
457
706
  * Re-declare prior-manifest paths that we did not emit this run so manifest
458
707
  * pruning can tell "intentionally removed" from "untouched but still managed."
@@ -593,7 +842,18 @@ export const nodeEmitter: Emitter = {
593
842
  const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
594
843
  ? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
595
844
  : [];
596
- return [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
845
+ const result = [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
846
+
847
+ // Final whole-run pass: this is the last generate hook, every hook shares
848
+ // `nodeCtx`, and the engine reads contents only after all hooks return —
849
+ // so the emitted-files cache now covers the complete run and repairs here
850
+ // reach files produced by earlier hooks. Greenfield runs are exempt: with
851
+ // no SDK on disk, "resolves to an existing file" has no meaning and the
852
+ // SDK core (workos.ts, common/) is intentionally not emitted.
853
+ if (managedPathsFor(nodeCtx, surface).size > 0) {
854
+ enforceEmittedImportInvariant(getEmittedFiles(nodeCtx).values(), getEmittedPaths(nodeCtx), surface);
855
+ }
856
+ return result;
597
857
  },
598
858
 
599
859
  // No operations map needed — the manifest belongs to the staging+target flow,