@workos/oagen-emitters 0.16.0 → 0.17.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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +20 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-BLnR-FMi.mjs} +3687 -2393
- package/dist/plugin-BLnR-FMi.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/go/index.ts +6 -1
- package/src/kotlin/index.ts +9 -3
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +271 -5
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +166 -3
- package/src/node/utils.ts +140 -22
- package/src/rust/resources.ts +78 -29
- package/src/rust/tests.ts +15 -4
- package/src/shared/union-flatten.ts +201 -0
- package/test/node/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +611 -0
- package/test/node/utils.test.ts +157 -2
- package/test/rust/resources.test.ts +143 -3
- package/test/shared/union-flatten.test.ts +174 -0
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-BLnR-FMi.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@workos/oagen-emitters",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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.
|
|
43
|
+
"@types/node": "^25.9.3",
|
|
44
44
|
"husky": "^9.1.7",
|
|
45
|
-
"oxfmt": "^0.
|
|
46
|
-
"oxlint": "^1.
|
|
47
|
-
"prettier": "^3.8.
|
|
48
|
-
"tsdown": "^0.22.
|
|
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.
|
|
57
|
+
"@workos/oagen": "^0.22.5"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/go/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
|
|
12
12
|
import { generateModels } from './models.js';
|
|
13
13
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
14
|
+
import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
|
|
14
15
|
import { generateEnums } from './enums.js';
|
|
15
16
|
import { generateResources } from './resources.js';
|
|
16
17
|
import { generateClient } from './client.js';
|
|
@@ -47,7 +48,11 @@ export const goEmitter: Emitter = {
|
|
|
47
48
|
}
|
|
48
49
|
return m;
|
|
49
50
|
});
|
|
50
|
-
|
|
51
|
+
// Go has no sum types: a discriminated-union field (e.g. ApiKey.owner)
|
|
52
|
+
// renders as its first variant, dropping fields that only exist on later
|
|
53
|
+
// variants (organization_id on the user owner). Flatten such unions into a
|
|
54
|
+
// single superset struct so every variant field survives.
|
|
55
|
+
return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(goModels), ctx));
|
|
51
56
|
},
|
|
52
57
|
|
|
53
58
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
package/src/kotlin/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { generateClient } from './client.js';
|
|
|
18
18
|
import { generateTests } from './tests.js';
|
|
19
19
|
import { buildOperationsMap } from './manifest.js';
|
|
20
20
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
21
|
+
import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
|
|
21
22
|
|
|
22
23
|
/** Ensure every generated file ends with a trailing newline. */
|
|
23
24
|
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
@@ -49,7 +50,11 @@ export const kotlinEmitter: Emitter = {
|
|
|
49
50
|
}
|
|
50
51
|
return m;
|
|
51
52
|
});
|
|
52
|
-
|
|
53
|
+
// Kotlin renders a discriminated-union field as its first variant's data
|
|
54
|
+
// class, so fields unique to later variants (organization_id on the user
|
|
55
|
+
// owner) are lost. Flatten such unions into one superset data class so
|
|
56
|
+
// every variant field is reachable.
|
|
57
|
+
return ensureTrailingNewlines(generateModels(flattenDiscriminatedUnionFields(kotlinModels), ctx));
|
|
53
58
|
},
|
|
54
59
|
|
|
55
60
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -78,8 +83,9 @@ export const kotlinEmitter: Emitter = {
|
|
|
78
83
|
|
|
79
84
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
80
85
|
// Pass enriched models so round-trip tests see the full field set
|
|
81
|
-
// (including optional oneOf-enriched fields
|
|
82
|
-
|
|
86
|
+
// (including optional oneOf-enriched fields and flattened discriminated-
|
|
87
|
+
// union owner fields) and can filter accurately.
|
|
88
|
+
const enrichedModels = flattenDiscriminatedUnionFields(enrichModelsFromSpec(spec.models));
|
|
83
89
|
const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
|
|
84
90
|
return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
|
|
85
91
|
},
|
package/src/node/enums.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile, Model, Service } from '@workos/oagen';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -17,11 +18,19 @@ import { generateResources, resolveResourceClassName, resolveResourceDir } from
|
|
|
17
18
|
import { generateClient } from './client.js';
|
|
18
19
|
import { generateTests as generateTestFiles } from './tests.js';
|
|
19
20
|
import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
|
|
21
|
+
import { flattenDiscriminatedUnionFields } from '../shared/union-flatten.js';
|
|
20
22
|
import { planDiscriminatedModels, generateDiscriminatedFiles } from './discriminated-models.js';
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
buildLiveSurface,
|
|
25
|
+
emptyLiveSurface,
|
|
26
|
+
mergeGeneratedClassMethodsIntoExisting,
|
|
27
|
+
setActiveLiveSurface,
|
|
28
|
+
type LiveSurface,
|
|
29
|
+
} from './live-surface.js';
|
|
22
30
|
import {
|
|
23
31
|
setBaselineSerializedNames,
|
|
24
32
|
setBaselineInterfaceNames,
|
|
33
|
+
setBaselineDeclaredNames,
|
|
25
34
|
setAdoptedModelNames,
|
|
26
35
|
setDiscriminatedModelNames,
|
|
27
36
|
setStructurallyRenamedDomainNames,
|
|
@@ -31,7 +40,7 @@ import { withNodeOperationOverrides } from './node-overrides.js';
|
|
|
31
40
|
import { isNodeOwnedService, nodeOptions } from './options.js';
|
|
32
41
|
import { setInlineEnumUnions, setDomainNameResolver } from './type-map.js';
|
|
33
42
|
import { groupByMount } from '../shared/resolved-ops.js';
|
|
34
|
-
import { assignModelsToServices, createServiceDirResolver } from './utils.js';
|
|
43
|
+
import { assignModelsToServices, createServiceDirResolver, relativeImport } from './utils.js';
|
|
35
44
|
import { fileName } from './naming.js';
|
|
36
45
|
|
|
37
46
|
/**
|
|
@@ -58,6 +67,24 @@ function getEmittedPaths(ctx: EmitterContext): Set<string> {
|
|
|
58
67
|
return set;
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Every `GeneratedFile` the node emitter has produced so far in this ctx,
|
|
72
|
+
* keyed by path. All emitter hooks share one ctx (and the engine only reads
|
|
73
|
+
* file contents after the last hook returns), so the final hook can run a
|
|
74
|
+
* whole-run pass over files emitted by earlier hooks — see
|
|
75
|
+
* `enforceEmittedImportInvariant`.
|
|
76
|
+
*/
|
|
77
|
+
const emittedFilesCache = new WeakMap<EmitterContext, Map<string, GeneratedFile>>();
|
|
78
|
+
|
|
79
|
+
function getEmittedFiles(ctx: EmitterContext): Map<string, GeneratedFile> {
|
|
80
|
+
let map = emittedFilesCache.get(ctx);
|
|
81
|
+
if (!map) {
|
|
82
|
+
map = new Map();
|
|
83
|
+
emittedFilesCache.set(ctx, map);
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
function getSurface(ctx: EmitterContext): LiveSurface {
|
|
62
89
|
let surface = surfaceCache.get(ctx);
|
|
63
90
|
if (surface) return surface;
|
|
@@ -102,6 +129,18 @@ function getSurface(ctx: EmitterContext): LiveSurface {
|
|
|
102
129
|
for (const name of surface.interfaces.keys()) allInterfaces.add(name);
|
|
103
130
|
setBaselineInterfaceNames(allInterfaces);
|
|
104
131
|
|
|
132
|
+
// Every DECLARED baseline name — interfaces and type aliases, from both
|
|
133
|
+
// the api-surface JSON and the disk walk (whose `interfaces` map already
|
|
134
|
+
// includes `export type` aliases). `resolveInterfaceName` uses this to
|
|
135
|
+
// let exact-name declarations preempt structural renames: an alias-form
|
|
136
|
+
// file (`export type X = Y;`) carries no fields, so the engine's
|
|
137
|
+
// structural matcher would otherwise re-point IR model X at a similarly
|
|
138
|
+
// shaped interface and emit renamed duplicates that flip the file's form
|
|
139
|
+
// on every regeneration.
|
|
140
|
+
const declaredNames = new Set<string>(allInterfaces);
|
|
141
|
+
for (const name of Object.keys(ctx.apiSurface?.typeAliases ?? {})) declaredNames.add(name);
|
|
142
|
+
setBaselineDeclaredNames(declaredNames);
|
|
143
|
+
|
|
105
144
|
// Inline-enum optimization is intentionally disabled. workos-node emits the
|
|
106
145
|
// dual `const X = {...} as const; type X = ...` pattern so callers can use
|
|
107
146
|
// members at runtime (e.g. `GenerateLinkIntent.SSO`). Inlining the type
|
|
@@ -342,6 +381,16 @@ function isOwnedPath(relPath: string, policy: LiveSurfacePolicy): boolean {
|
|
|
342
381
|
return dir !== undefined && policy.ownedServiceDirs.has(dir);
|
|
343
382
|
}
|
|
344
383
|
|
|
384
|
+
/** Read the current on-disk content of a live-surface file, if present. */
|
|
385
|
+
function readExistingSurfaceFile(surface: LiveSurface, relPath: string): string | null {
|
|
386
|
+
if (!surface.rootDir) return null;
|
|
387
|
+
try {
|
|
388
|
+
return fs.readFileSync(path.join(surface.rootDir, relPath), 'utf8');
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
345
394
|
function extractRelativeImportPaths(content: string, fromPath: string): string[] {
|
|
346
395
|
const dir = path.dirname(fromPath);
|
|
347
396
|
const paths: string[] = [];
|
|
@@ -438,7 +487,26 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
|
|
|
438
487
|
// `@oagen-ignore-start`/`@oagen-ignore-end` regions inside the file
|
|
439
488
|
// are still preserved by `overwriteWithPreservedRegions` in the
|
|
440
489
|
// engine.
|
|
490
|
+
//
|
|
491
|
+
// Exception: a NOT-owned, NOT-adopted service can receive a PARTIAL
|
|
492
|
+
// resource emission — resources.ts filters out operations already
|
|
493
|
+
// covered by the baseline class, leaving only the new methods (see
|
|
494
|
+
// generateResourceClass). Forcing a full overwrite with that partial
|
|
495
|
+
// class deletes the existing public methods (workos-node's
|
|
496
|
+
// api-keys.ts lost four methods when the spec gained one operation).
|
|
497
|
+
// When the generated class would drop methods that the on-disk class
|
|
498
|
+
// declares, merge instead: keep the existing file text verbatim and
|
|
499
|
+
// append only the new methods plus the imports they need.
|
|
441
500
|
if (surface.autogenFiles.has(f.path) || ownedPath) {
|
|
501
|
+
const dir = topLevelDir(f.path);
|
|
502
|
+
const isAdoptedDirPath = dir !== undefined && policy.adoptedServiceDirs.has(dir);
|
|
503
|
+
if (!ownedPath && !isAdoptedDirPath && surface.autogenFiles.has(f.path)) {
|
|
504
|
+
const existingText = readExistingSurfaceFile(surface, f.path);
|
|
505
|
+
if (existingText !== null) {
|
|
506
|
+
const merged = mergeGeneratedClassMethodsIntoExisting(existingText, f.content);
|
|
507
|
+
if (merged !== null) f.content = merged;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
442
510
|
f.overwriteExisting = true;
|
|
443
511
|
f.skipIfExists = false;
|
|
444
512
|
}
|
|
@@ -449,10 +517,192 @@ function applyLiveSurface(files: GeneratedFile[], ctx: EmitterContext, surface:
|
|
|
449
517
|
out.push(f);
|
|
450
518
|
}
|
|
451
519
|
const emitted = getEmittedPaths(ctx);
|
|
452
|
-
|
|
520
|
+
const emittedFiles = getEmittedFiles(ctx);
|
|
521
|
+
for (const f of out) {
|
|
522
|
+
emitted.add(f.path);
|
|
523
|
+
emittedFiles.set(f.path, f);
|
|
524
|
+
}
|
|
453
525
|
return out;
|
|
454
526
|
}
|
|
455
527
|
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Import-resolution invariant
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Matches single-line `import`/`export … from './relative'` statements — the
|
|
534
|
+
* only form the node emitter produces. Captures: keyword, optional ` type`
|
|
535
|
+
* modifier, the binding clause (`* [as ns]` or `{ … }`), and the module path.
|
|
536
|
+
*/
|
|
537
|
+
const RELATIVE_FROM_STMT_RE =
|
|
538
|
+
/^(import|export)(\s+type)?\s+(\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"];?\s*$/;
|
|
539
|
+
|
|
540
|
+
const EXPORTED_DECL_RE =
|
|
541
|
+
/^export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:interface|class|enum|function|const|let|var|type)\s+([A-Za-z_$][\w$]*)/gm;
|
|
542
|
+
const EXPORTED_CLAUSE_RE = /^export\s+(?:type\s+)?\{([^}]*)\}/gm;
|
|
543
|
+
|
|
544
|
+
/** Repo-relative paths a relative import specifier may resolve to. */
|
|
545
|
+
function importTargetCandidates(fromPath: string, spec: string): string[] {
|
|
546
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromPath), spec));
|
|
547
|
+
return [base, `${base}.ts`, `${base}/index.ts`];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Index exported symbol → file path across this run's emitted contents. */
|
|
551
|
+
function indexEmittedExports(files: GeneratedFile[]): Map<string, string> {
|
|
552
|
+
const index = new Map<string, string>();
|
|
553
|
+
for (const f of files) {
|
|
554
|
+
if (!f.path.endsWith('.ts') || !f.content) continue;
|
|
555
|
+
for (const m of f.content.matchAll(EXPORTED_DECL_RE)) {
|
|
556
|
+
if (!index.has(m[1])) index.set(m[1], f.path);
|
|
557
|
+
}
|
|
558
|
+
for (const m of f.content.matchAll(EXPORTED_CLAUSE_RE)) {
|
|
559
|
+
for (const raw of m[1].split(',')) {
|
|
560
|
+
const entry = raw.trim();
|
|
561
|
+
if (!entry) continue;
|
|
562
|
+
const parts = entry.split(/\s+as\s+/);
|
|
563
|
+
const exported = (parts[1] ?? parts[0]).replace(/^type\s+/, '').trim();
|
|
564
|
+
if (exported && !index.has(exported)) index.set(exported, f.path);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return index;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Enforce: every relative import/re-export path in emitted code resolves to
|
|
573
|
+
* either (i) a file emitted in the same run, or (ii) a file that already
|
|
574
|
+
* exists on disk in the target SDK.
|
|
575
|
+
*
|
|
576
|
+
* Violations observed in real generations (all TS2307 in otherwise-valid
|
|
577
|
+
* output): serializer imports pointing at canonical paths while the function
|
|
578
|
+
* lives in a legacy hand serializer under a different filename; barrels
|
|
579
|
+
* re-exporting a module-local enum file whose declaration lives under
|
|
580
|
+
* `src/common/interfaces`; barrels exporting interface files that no run
|
|
581
|
+
* emits at all.
|
|
582
|
+
*
|
|
583
|
+
* Repair strategy, per statement whose target is neither emitted nor on
|
|
584
|
+
* disk:
|
|
585
|
+
* 1. Locate each imported symbol (this run's emissions first, then the
|
|
586
|
+
* live-surface declaration maps) and rewrite the path to where the
|
|
587
|
+
* symbol actually lives — splitting into one statement per location
|
|
588
|
+
* when symbols are spread across files.
|
|
589
|
+
* 2. `export * from` / namespace imports carry no symbol list, so derive
|
|
590
|
+
* the expected symbol from the file stem (`foo-bar.interface` →
|
|
591
|
+
* `FooBar`, `foo.serializer` → `deserializeFoo`/`serializeFoo`).
|
|
592
|
+
* 3. When a symbol exists nowhere, drop the statement and warn — a missing
|
|
593
|
+
* named export fails loudly at the usage site instead of as a phantom
|
|
594
|
+
* module, and a barrel line for a never-emitted file is pure noise.
|
|
595
|
+
*
|
|
596
|
+
* Mutates `f.content` in place and returns the warnings issued.
|
|
597
|
+
*/
|
|
598
|
+
export function enforceEmittedImportInvariant(
|
|
599
|
+
files: Iterable<GeneratedFile>,
|
|
600
|
+
emittedPaths: Set<string>,
|
|
601
|
+
surface: LiveSurface,
|
|
602
|
+
): string[] {
|
|
603
|
+
const fileList = [...files];
|
|
604
|
+
const emittedSymbols = indexEmittedExports(fileList);
|
|
605
|
+
const warnings: string[] = [];
|
|
606
|
+
|
|
607
|
+
const targetExists = (fromPath: string, spec: string): boolean =>
|
|
608
|
+
importTargetCandidates(fromPath, spec).some((p) => emittedPaths.has(p) || surface.files.has(p));
|
|
609
|
+
|
|
610
|
+
const locateSymbol = (name: string): string | undefined =>
|
|
611
|
+
emittedSymbols.get(name) ??
|
|
612
|
+
surface.functions.get(name) ??
|
|
613
|
+
surface.interfaces.get(name)?.filePath ??
|
|
614
|
+
surface.classes.get(name)?.filePath;
|
|
615
|
+
|
|
616
|
+
for (const f of fileList) {
|
|
617
|
+
if (!f.path.endsWith('.ts') || !f.content) continue;
|
|
618
|
+
let changed = false;
|
|
619
|
+
const outLines: string[] = [];
|
|
620
|
+
for (const line of f.content.split('\n')) {
|
|
621
|
+
const m = line.match(RELATIVE_FROM_STMT_RE);
|
|
622
|
+
if (!m || targetExists(f.path, m[4])) {
|
|
623
|
+
outLines.push(line);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const [, keyword, typeMod, clause, spec] = m;
|
|
627
|
+
const repaired = repairUnresolvableStatement(f.path, keyword, typeMod ?? '', clause, spec, locateSymbol);
|
|
628
|
+
changed = true;
|
|
629
|
+
outLines.push(...repaired.lines);
|
|
630
|
+
if (repaired.warning) warnings.push(repaired.warning);
|
|
631
|
+
}
|
|
632
|
+
if (changed) f.content = outLines.join('\n');
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const w of warnings) console.warn(w);
|
|
636
|
+
return warnings;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function repairUnresolvableStatement(
|
|
640
|
+
fromPath: string,
|
|
641
|
+
keyword: string,
|
|
642
|
+
typeMod: string,
|
|
643
|
+
clause: string,
|
|
644
|
+
spec: string,
|
|
645
|
+
locateSymbol: (name: string) => string | undefined,
|
|
646
|
+
): { lines: string[]; warning?: string } {
|
|
647
|
+
if (clause.startsWith('{')) {
|
|
648
|
+
const entries = clause
|
|
649
|
+
.slice(1, -1)
|
|
650
|
+
.split(',')
|
|
651
|
+
.map((e) => e.trim())
|
|
652
|
+
.filter(Boolean);
|
|
653
|
+
const byLocation = new Map<string, string[]>();
|
|
654
|
+
const missing: string[] = [];
|
|
655
|
+
for (const entry of entries) {
|
|
656
|
+
const source = entry
|
|
657
|
+
.split(/\s+as\s+/)[0]
|
|
658
|
+
.replace(/^type\s+/, '')
|
|
659
|
+
.trim();
|
|
660
|
+
const location = locateSymbol(source);
|
|
661
|
+
if (!location) {
|
|
662
|
+
missing.push(source);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (location === fromPath) continue; // declared locally — no import needed
|
|
666
|
+
const group = byLocation.get(location);
|
|
667
|
+
if (group) {
|
|
668
|
+
group.push(entry);
|
|
669
|
+
} else {
|
|
670
|
+
byLocation.set(location, [entry]);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Emit the symbols we *can* relocate; only the genuinely-missing ones are
|
|
674
|
+
// dropped (and warned about). Returning [] for the whole clause when any
|
|
675
|
+
// one symbol is missing would also discard the resolvable symbols, failing
|
|
676
|
+
// them with TS2305 at their usage sites — the breakage this pass prevents.
|
|
677
|
+
const lines = [...byLocation].map(
|
|
678
|
+
([location, group]) =>
|
|
679
|
+
`${keyword}${typeMod} { ${group.join(', ')} } from '${relativeImport(fromPath, location)}';`,
|
|
680
|
+
);
|
|
681
|
+
const warning =
|
|
682
|
+
missing.length > 0
|
|
683
|
+
? `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(', ')}`
|
|
684
|
+
: undefined;
|
|
685
|
+
return { lines, warning };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// `* [as ns]` — no symbol list; derive the expected symbol from the stem.
|
|
689
|
+
const stem = spec.split('/').pop() ?? '';
|
|
690
|
+
let location: string | undefined;
|
|
691
|
+
if (stem.endsWith('.interface')) {
|
|
692
|
+
location = locateSymbol(toPascalCase(stem.slice(0, -'.interface'.length)));
|
|
693
|
+
} else if (stem.endsWith('.serializer')) {
|
|
694
|
+
const base = toPascalCase(stem.slice(0, -'.serializer'.length));
|
|
695
|
+
location = locateSymbol(`deserialize${base}`) ?? locateSymbol(`serialize${base}`);
|
|
696
|
+
}
|
|
697
|
+
if (location && location !== fromPath) {
|
|
698
|
+
return { lines: [`${keyword}${typeMod} ${clause} from '${relativeImport(fromPath, location)}';`] };
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
lines: [],
|
|
702
|
+
warning: `oagen(node): dropped unresolvable ${keyword} in ${fromPath}: '${spec}' — module is neither emitted this run nor present in the target SDK`,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
456
706
|
/**
|
|
457
707
|
* Re-declare prior-manifest paths that we did not emit this run so manifest
|
|
458
708
|
* pruning can tell "intentionally removed" from "untouched but still managed."
|
|
@@ -517,7 +767,7 @@ function carryForwardManagedFiles(ctx: EmitterContext, surface: LiveSurface): Ge
|
|
|
517
767
|
function enrichModelsForNode(models: Model[]): Model[] {
|
|
518
768
|
const enriched = enrichModelsFromSpec(models);
|
|
519
769
|
const originalByName = new Map(models.map((m) => [m.name, m]));
|
|
520
|
-
|
|
770
|
+
const restored = enriched.map((m) => {
|
|
521
771
|
if ((m as { discriminator?: unknown }).discriminator && m.fields.length === 0) {
|
|
522
772
|
const original = originalByName.get(m.name);
|
|
523
773
|
if (original && original.fields.length > 0) {
|
|
@@ -526,6 +776,11 @@ function enrichModelsForNode(models: Model[]): Model[] {
|
|
|
526
776
|
}
|
|
527
777
|
return m;
|
|
528
778
|
});
|
|
779
|
+
// Field-level discriminated unions (e.g. ApiKey.owner) otherwise render as
|
|
780
|
+
// `FirstVariant | SecondVariant`; collapse them to one flat superset
|
|
781
|
+
// interface so callers see every variant field (organization_id on the user
|
|
782
|
+
// owner) on a single type — parity with the other flat-emit languages.
|
|
783
|
+
return flattenDiscriminatedUnionFields(restored);
|
|
529
784
|
}
|
|
530
785
|
|
|
531
786
|
export const nodeEmitter: Emitter = {
|
|
@@ -593,7 +848,18 @@ export const nodeEmitter: Emitter = {
|
|
|
593
848
|
const testFiles = nodeOptions(nodeCtx).regenerateOwnedTests
|
|
594
849
|
? applyLiveSurface(generateTestFiles(spec, nodeCtx), nodeCtx, surface)
|
|
595
850
|
: [];
|
|
596
|
-
|
|
851
|
+
const result = [...testFiles, ...carryForwardManagedFiles(nodeCtx, surface)];
|
|
852
|
+
|
|
853
|
+
// Final whole-run pass: this is the last generate hook, every hook shares
|
|
854
|
+
// `nodeCtx`, and the engine reads contents only after all hooks return —
|
|
855
|
+
// so the emitted-files cache now covers the complete run and repairs here
|
|
856
|
+
// reach files produced by earlier hooks. Greenfield runs are exempt: with
|
|
857
|
+
// no SDK on disk, "resolves to an existing file" has no meaning and the
|
|
858
|
+
// SDK core (workos.ts, common/) is intentionally not emitted.
|
|
859
|
+
if (managedPathsFor(nodeCtx, surface).size > 0) {
|
|
860
|
+
enforceEmittedImportInvariant(getEmittedFiles(nodeCtx).values(), getEmittedPaths(nodeCtx), surface);
|
|
861
|
+
}
|
|
862
|
+
return result;
|
|
597
863
|
},
|
|
598
864
|
|
|
599
865
|
// No operations map needed — the manifest belongs to the staging+target flow,
|