@workos/oagen-emitters 0.18.4 → 0.19.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
- package/dist/plugin-DXIciTnN.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +4 -4
- package/src/dotnet/enums.ts +11 -5
- package/src/dotnet/fixtures.ts +28 -7
- package/src/dotnet/index.ts +42 -1
- package/src/dotnet/models.ts +11 -5
- package/src/dotnet/resources.ts +3 -3
- package/src/dotnet/tests.ts +4 -4
- package/src/go/enums.ts +91 -18
- package/src/go/fixtures.ts +25 -3
- package/src/go/flat-merge.ts +253 -0
- package/src/go/models.ts +85 -20
- package/src/go/resources.ts +3 -3
- package/src/go/tests.ts +7 -5
- package/src/kotlin/enums.ts +21 -11
- package/src/kotlin/models.ts +53 -11
- package/src/kotlin/resources.ts +2 -2
- package/src/kotlin/tests.ts +38 -3
- package/src/node/enums.ts +8 -5
- package/src/node/models.ts +29 -21
- package/src/node/resources.ts +12 -1
- package/src/node/tests.ts +7 -2
- package/src/php/enums.ts +18 -5
- package/src/php/index.ts +11 -3
- package/src/php/models.ts +11 -5
- package/src/php/resources.ts +6 -4
- package/src/php/tests.ts +6 -3
- package/src/python/enums.ts +39 -28
- package/src/python/fixtures.ts +34 -6
- package/src/python/models.ts +138 -45
- package/src/python/resources.ts +3 -3
- package/src/python/tests.ts +31 -12
- package/src/ruby/enums.ts +28 -19
- package/src/ruby/models.ts +23 -12
- package/src/ruby/rbi.ts +17 -6
- package/src/ruby/resources.ts +2 -2
- package/src/ruby/tests.ts +37 -4
- package/src/rust/enums.ts +29 -7
- package/src/rust/fixtures.ts +12 -3
- package/src/rust/models.ts +37 -6
- package/src/rust/resources.ts +8 -1
- package/src/rust/tests.ts +3 -3
- package/src/shared/resolved-ops.ts +104 -0
- package/test/dotnet/scoped-aggregates.test.ts +247 -0
- package/test/go/scoping.test.ts +324 -0
- package/test/kotlin/models.test.ts +74 -0
- package/test/kotlin/tests.test.ts +33 -0
- package/test/python/scoped-aggregates.test.ts +205 -0
- package/test/ruby/tests.test.ts +130 -0
- package/test/rust/fixtures.test.ts +13 -7
- package/test/shared/synthetic-enum-seed.test.ts +79 -0
- package/dist/plugin-Cciic50q.mjs.map +0 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { EmitterContext } from '@workos/oagen';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { isScopedRun } from '../shared/resolved-ops.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scoped-run reconciliation for Go's FLAT aggregate files.
|
|
8
|
+
*
|
|
9
|
+
* Unlike Rust (one source file per model/enum), Go inlines every type into a
|
|
10
|
+
* single `models.go` / `enums.go` and every webhook event into one
|
|
11
|
+
* `pkg/events/events.go`. A scoped (`--services`) run regenerates these flat
|
|
12
|
+
* files from the FULL new spec, which breaks scoping two ways:
|
|
13
|
+
*
|
|
14
|
+
* 1. ADDITION — a brand-new model/enum/event that belongs to an OUT-OF-SCOPE
|
|
15
|
+
* service gets inlined, so a scoped batch leaks unrelated changes
|
|
16
|
+
* (violates Option B: a scoped batch should contain ONLY the selected
|
|
17
|
+
* service's changes).
|
|
18
|
+
* 2. REMOVAL / RENAME — a type the new spec renamed away (e.g.
|
|
19
|
+
* `OrganizationDomainStandAlone` → `OrganizationDomain`) is no longer
|
|
20
|
+
* produced, so its block vanishes from `models.go`; but an out-of-scope
|
|
21
|
+
* resource file (NOT regenerated this run) still references the old name →
|
|
22
|
+
* `undefined: OrganizationDomainStandAlone` and the package won't compile.
|
|
23
|
+
*
|
|
24
|
+
* The fix mirrors the Rust principle: in a scoped run, only the selected
|
|
25
|
+
* services' types should reflect the new spec; everything else must be
|
|
26
|
+
* preserved exactly as it is on disk. Because Go's manifest records the flat
|
|
27
|
+
* FILE path (`models.go`) and not per-type paths, we recover the per-type
|
|
28
|
+
* "present before" signal by reading the prior file from `ctx.outputDir`
|
|
29
|
+
* (the emitter already reads `go.mod` from there in tests.ts). A type/const
|
|
30
|
+
* block is then reconciled as:
|
|
31
|
+
*
|
|
32
|
+
* - KEEP a freshly generated block iff it is IN-SCOPE or its name was present
|
|
33
|
+
* in the prior file → drops brand-new out-of-scope additions (fix #1).
|
|
34
|
+
* - CARRY OVER verbatim any prior block whose name(s) the new spec no longer
|
|
35
|
+
* produces → retains renamed/removed types (fix #2).
|
|
36
|
+
*
|
|
37
|
+
* A full (non-scoped) run skips all of this and emits the new spec verbatim.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** A single named top-level declaration block in a flat Go file. */
|
|
41
|
+
export interface NamedBlock {
|
|
42
|
+
/** Every type/const name this block declares (a batched `type (...)` alias block declares several). */
|
|
43
|
+
names: string[];
|
|
44
|
+
/** Verbatim text of the block (no trailing blank line). */
|
|
45
|
+
text: string;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the block's owning model/enum is in scope this run. Set by the
|
|
48
|
+
* generator for freshly produced blocks; omitted (treated as false) for
|
|
49
|
+
* blocks parsed from the prior on-disk file.
|
|
50
|
+
*/
|
|
51
|
+
inScope?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read the prior on-disk content of a generated file, or null when unavailable. */
|
|
55
|
+
export function readPriorFile(relPath: string, ctx: EmitterContext): string | null {
|
|
56
|
+
if (!ctx.outputDir) return null;
|
|
57
|
+
const abs = resolve(ctx.outputDir, relPath);
|
|
58
|
+
if (!existsSync(abs)) return null;
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync(abs, 'utf-8');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a flat Go file into the set of top-level type names it declares, mapped
|
|
68
|
+
* to the verbatim text of each declaration block (including its leading doc
|
|
69
|
+
* comment). Used to recover the per-type "present before" signal a scoped run
|
|
70
|
+
* needs. Recognizes the exact shapes the Go emitter produces:
|
|
71
|
+
* - `type Name struct { ... }` (brace-balanced)
|
|
72
|
+
* - `type Name = Other` (single-line alias)
|
|
73
|
+
* - `type Name string` + `const ( ... )` (string enum)
|
|
74
|
+
* - `type ( A = X\n B = X )` (batched alias block — declares many)
|
|
75
|
+
* The leading `package` clause and any standalone trailing `const (...)` block
|
|
76
|
+
* (e.g. the events file) are returned separately by {@link parseFlatGoBlocks}.
|
|
77
|
+
*/
|
|
78
|
+
export function parseFlatGoBlocks(content: string): {
|
|
79
|
+
blocks: NamedBlock[];
|
|
80
|
+
byName: Map<string, NamedBlock>;
|
|
81
|
+
} {
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
const blocks: NamedBlock[] = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
|
|
86
|
+
// Skip the generated header / package clause / leading blanks; those are
|
|
87
|
+
// re-emitted by the generator, not carried over.
|
|
88
|
+
while (i < lines.length) {
|
|
89
|
+
const t = lines[i].trim();
|
|
90
|
+
if (t.startsWith('package ') || t === '' || t.startsWith('// Code generated')) {
|
|
91
|
+
i++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
while (i < lines.length) {
|
|
98
|
+
// Collect a leading run of `//` doc-comment lines.
|
|
99
|
+
const start = i;
|
|
100
|
+
while (i < lines.length && lines[i].trim().startsWith('//')) i++;
|
|
101
|
+
|
|
102
|
+
if (i >= lines.length) break;
|
|
103
|
+
const line = lines[i];
|
|
104
|
+
const trimmed = line.trim();
|
|
105
|
+
|
|
106
|
+
if (trimmed === 'type (') {
|
|
107
|
+
// Batched alias block: `type (` ... `)`. Each inner line is `Name = Other`.
|
|
108
|
+
const names: string[] = [];
|
|
109
|
+
i++;
|
|
110
|
+
while (i < lines.length && lines[i].trim() !== ')') {
|
|
111
|
+
const m = lines[i].trim().match(/^(\w+)\s*=/);
|
|
112
|
+
if (m) names.push(m[1]);
|
|
113
|
+
i++;
|
|
114
|
+
}
|
|
115
|
+
i++; // consume ')'
|
|
116
|
+
blocks.push({ names, text: lines.slice(start, i).join('\n') });
|
|
117
|
+
} else if (/^type\s+(\w+)\s+struct\s*\{/.test(trimmed)) {
|
|
118
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
119
|
+
// Brace-balanced struct body.
|
|
120
|
+
let depth = 0;
|
|
121
|
+
let sawOpen = false;
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
for (const ch of lines[i]) {
|
|
124
|
+
if (ch === '{') {
|
|
125
|
+
depth++;
|
|
126
|
+
sawOpen = true;
|
|
127
|
+
} else if (ch === '}') depth--;
|
|
128
|
+
}
|
|
129
|
+
i++;
|
|
130
|
+
if (sawOpen && depth === 0) break;
|
|
131
|
+
}
|
|
132
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
133
|
+
} else if (/^type\s+(\w+)\s+\w+\s+string\b/.test(trimmed) || /^type\s+(\w+)\s+string\b/.test(trimmed)) {
|
|
134
|
+
// String enum: `type Name string` possibly followed by a `const ( ... )`.
|
|
135
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
136
|
+
i++;
|
|
137
|
+
// Skip blank lines then an optional const block.
|
|
138
|
+
let j = i;
|
|
139
|
+
while (j < lines.length && lines[j].trim() === '') j++;
|
|
140
|
+
if (j < lines.length && lines[j].trim() === 'const (') {
|
|
141
|
+
i = j;
|
|
142
|
+
while (i < lines.length && lines[i].trim() !== ')') i++;
|
|
143
|
+
i++; // consume ')'
|
|
144
|
+
}
|
|
145
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
146
|
+
} else if (/^type\s+(\w+)\s*=/.test(trimmed)) {
|
|
147
|
+
// Single-line alias: `type Name = Other`.
|
|
148
|
+
const name = trimmed.match(/^type\s+(\w+)/)![1];
|
|
149
|
+
i++;
|
|
150
|
+
blocks.push({ names: [name], text: lines.slice(start, i).join('\n') });
|
|
151
|
+
} else {
|
|
152
|
+
// Unrecognized top-level construct (e.g. a standalone `const (...)`).
|
|
153
|
+
// Skip the line so parsing stays robust; such constructs are never
|
|
154
|
+
// carried over by name.
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
// Skip trailing blank lines between blocks (re-added on reassembly).
|
|
158
|
+
while (i < lines.length && lines[i].trim() === '') i++;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const byName = new Map<string, NamedBlock>();
|
|
162
|
+
for (const b of blocks) for (const n of b.names) byName.set(n, b);
|
|
163
|
+
return { blocks, byName };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reconcile freshly generated named blocks against the prior on-disk file for a
|
|
168
|
+
* scoped run. Returns the ordered list of block texts to emit. See file-level
|
|
169
|
+
* docs for the keep/carry-over rules. In a scoped run a block is emitted as:
|
|
170
|
+
* - IN-SCOPE → the freshly generated text (apply the new spec).
|
|
171
|
+
* - out-of-scope, existed before → the PRIOR on-disk text, FROZEN, so an
|
|
172
|
+
* unrelated change to that type in the same spec delta doesn't leak into a
|
|
173
|
+
* scoped batch (Option B). (Falls back to fresh text only when the new
|
|
174
|
+
* block's names span multiple prior blocks — a batched-alias regrouping —
|
|
175
|
+
* which can't be frozen 1:1.)
|
|
176
|
+
* - out-of-scope, brand-new → dropped (the addition that broke the build).
|
|
177
|
+
* Then any prior block the new spec no longer produces at all is carried over
|
|
178
|
+
* verbatim (renamed/removed types still referenced by un-regenerated code).
|
|
179
|
+
*
|
|
180
|
+
* @param newBlocks Per-type blocks the current spec produced (in emit order),
|
|
181
|
+
* each tagged with `inScope`.
|
|
182
|
+
* @param relPath Flat file path (e.g. `models.go`) for reading the prior file.
|
|
183
|
+
* @param ctx Emitter context (provides `outputDir` + scope sets).
|
|
184
|
+
* @param alsoEmitted Names this file emits OUTSIDE `newBlocks` (e.g. the fixed
|
|
185
|
+
* `PaginationParams` struct). They must be excluded from the
|
|
186
|
+
* carry-over, or the prior copy would be re-appended and
|
|
187
|
+
* redeclare the separately-emitted one.
|
|
188
|
+
*/
|
|
189
|
+
export function reconcileFlatBlocks(
|
|
190
|
+
newBlocks: NamedBlock[],
|
|
191
|
+
relPath: string,
|
|
192
|
+
ctx: EmitterContext,
|
|
193
|
+
alsoEmitted: Set<string> = new Set<string>(),
|
|
194
|
+
): string[] {
|
|
195
|
+
// Full run: emit everything the new spec produced, unchanged.
|
|
196
|
+
if (!isScopedRun(ctx)) return newBlocks.map((b) => b.text);
|
|
197
|
+
|
|
198
|
+
const prior = readPriorFile(relPath, ctx);
|
|
199
|
+
// No prior file to reconcile against (first generation / missing output dir):
|
|
200
|
+
// fall back to scope-only gating so we never leak brand-new out-of-scope
|
|
201
|
+
// types, but there's nothing on disk to retain.
|
|
202
|
+
const priorParsed = prior ? parseFlatGoBlocks(prior) : { blocks: [], byName: new Map<string, NamedBlock>() };
|
|
203
|
+
|
|
204
|
+
const out: string[] = [];
|
|
205
|
+
const emittedNames = new Set<string>();
|
|
206
|
+
|
|
207
|
+
for (const block of newBlocks) {
|
|
208
|
+
if (block.inScope) {
|
|
209
|
+
out.push(block.text);
|
|
210
|
+
for (const n of block.names) emittedNames.add(n);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Out of scope: freeze to the prior on-disk text when this block maps 1:1
|
|
214
|
+
// to a single prior block; that keeps an out-of-scope type byte-identical to
|
|
215
|
+
// disk even if the new spec changed it. A brand-new out-of-scope block has
|
|
216
|
+
// no prior block → it is dropped.
|
|
217
|
+
const priorBlocks = block.names.map((n) => priorParsed.byName.get(n)).filter((b): b is NamedBlock => !!b);
|
|
218
|
+
const uniquePrior = new Set(priorBlocks);
|
|
219
|
+
if (uniquePrior.size === 1) {
|
|
220
|
+
const pb = priorBlocks[0];
|
|
221
|
+
if (!pb.names.some((n) => emittedNames.has(n))) {
|
|
222
|
+
out.push(pb.text);
|
|
223
|
+
for (const n of pb.names) emittedNames.add(n);
|
|
224
|
+
}
|
|
225
|
+
} else if (uniquePrior.size > 1) {
|
|
226
|
+
// A regrouping spread this out-of-scope block's names across several prior
|
|
227
|
+
// blocks. NEVER regenerate out-of-scope content (the fresh text could
|
|
228
|
+
// re-point an alias at a renamed canonical the scoped run didn't emit);
|
|
229
|
+
// instead freeze every distinct prior block verbatim. All names are
|
|
230
|
+
// on-disk here (the generator only puts in-scope ∪ on-disk names in an
|
|
231
|
+
// out-of-scope block), so this fully retains them.
|
|
232
|
+
for (const pb of uniquePrior) {
|
|
233
|
+
if (pb.names.some((n) => emittedNames.has(n))) continue;
|
|
234
|
+
out.push(pb.text);
|
|
235
|
+
for (const n of pb.names) emittedNames.add(n);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// uniquePrior.size === 0 → brand-new out-of-scope → drop.
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Carry over prior blocks the new spec no longer produces at all (renamed /
|
|
242
|
+
// removed types still referenced by out-of-scope, un-regenerated code),
|
|
243
|
+
// excluding any name this file emits elsewhere (e.g. PaginationParams).
|
|
244
|
+
for (const block of priorParsed.blocks) {
|
|
245
|
+
if (block.names.some((n) => alsoEmitted.has(n))) continue;
|
|
246
|
+
if (block.names.every((n) => !emittedNames.has(n))) {
|
|
247
|
+
out.push(block.text);
|
|
248
|
+
for (const n of block.names) emittedNames.add(n);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return out;
|
|
253
|
+
}
|
package/src/go/models.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { walkTypeRef } from '@workos/oagen';
|
|
|
3
3
|
import { mapTypeRef } from './type-map.js';
|
|
4
4
|
import { className, domainFieldName } from './naming.js';
|
|
5
5
|
import { lowerFirstForDoc, fieldDocComment, articleFor } from '../shared/naming-utils.js';
|
|
6
|
+
import { isModelInScope, isScopedRun } from '../shared/resolved-ops.js';
|
|
7
|
+
import { reconcileFlatBlocks, readPriorFile, parseFlatGoBlocks, type NamedBlock } from './flat-merge.js';
|
|
6
8
|
|
|
7
9
|
// Import and re-export shared model detection utilities
|
|
8
10
|
import {
|
|
@@ -114,6 +116,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
114
116
|
// Pick canonical for each duplicate group.
|
|
115
117
|
// Empty structs (hash '') are now properly populated by oneOf flattening,
|
|
116
118
|
// so we still skip aliasing them to avoid aliasing truly empty structs.
|
|
119
|
+
// For the batched-alias block below: in a scoped run, only emit an alias that
|
|
120
|
+
// is in scope OR already on disk (present in the prior models.go). Otherwise a
|
|
121
|
+
// `>= 5` structural group containing a single in-scope alias would drag every
|
|
122
|
+
// brand-new out-of-scope alias in the group into the file. A full run keeps
|
|
123
|
+
// every alias (isScopedRun is false).
|
|
124
|
+
const priorModelNames = isScopedRun(ctx)
|
|
125
|
+
? new Set(parseFlatGoBlocks(readPriorFile('models.go', ctx) ?? '').blocks.flatMap((b) => b.names))
|
|
126
|
+
: new Set<string>();
|
|
127
|
+
const aliasEmitted = (name: string): boolean =>
|
|
128
|
+
!isScopedRun(ctx) || isModelInScope(name, ctx) || priorModelNames.has(className(name));
|
|
129
|
+
|
|
117
130
|
const aliasOf = new Map<string, string>();
|
|
118
131
|
for (const [hash, names] of hashGroups) {
|
|
119
132
|
if (names.length <= 1) continue;
|
|
@@ -125,6 +138,30 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
// The structural-dedup canonical is chosen alphabetically, independent of
|
|
142
|
+
// scope, so an in-scope alias can point at a canonical that is itself out of
|
|
143
|
+
// scope and brand-new — which the reconciler would drop, leaving the alias
|
|
144
|
+
// `type InScopeModel = Canonical` dangling (`undefined: Canonical`). Force-
|
|
145
|
+
// retain any canonical referenced by an in-scope alias. The canonical is
|
|
146
|
+
// structurally identical to that alias, so its field types are reachable from
|
|
147
|
+
// the in-scope service and therefore also emitted. In a mixed batched-alias
|
|
148
|
+
// block the in-scope member forces the shared canonical, covering its on-disk
|
|
149
|
+
// siblings that emit fresh in the same block.
|
|
150
|
+
const forcedCanonicals = new Set<string>();
|
|
151
|
+
if (isScopedRun(ctx)) {
|
|
152
|
+
for (const [aliasName, canonical] of aliasOf) {
|
|
153
|
+
if (isModelInScope(aliasName, ctx)) forcedCanonicals.add(canonical);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Build one NamedBlock per emitted model type. In a scoped run these blocks
|
|
158
|
+
// are reconciled against the prior models.go (see reconcileFlatBlocks): blocks
|
|
159
|
+
// for out-of-scope models that didn't exist before are dropped, and prior
|
|
160
|
+
// blocks for renamed/removed types are carried over verbatim. A full run emits
|
|
161
|
+
// every block unchanged. Tracking the IR model name(s) per block (a batched
|
|
162
|
+
// alias group declares several) is what lets the reconciler gate by scope.
|
|
163
|
+
const modelBlocks: NamedBlock[] = [];
|
|
164
|
+
|
|
128
165
|
const batchedAliases = new Set<string>();
|
|
129
166
|
for (const model of models) {
|
|
130
167
|
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
@@ -149,38 +186,51 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
149
186
|
const groupNames = hashGroups.get(hash) ?? [];
|
|
150
187
|
const aliases = groupNames.filter((n) => aliasOf.has(n) && className(n) !== className(aliasOf.get(n)!));
|
|
151
188
|
|
|
189
|
+
const blockLines: string[] = [];
|
|
190
|
+
const blockNames: string[] = [];
|
|
191
|
+
let blockInScope = false;
|
|
152
192
|
if (aliases.length >= 5) {
|
|
153
|
-
//
|
|
193
|
+
// Mark the whole group consumed so its members aren't re-emitted as
|
|
194
|
+
// singles, but only EMIT aliases that are in scope or already on disk —
|
|
195
|
+
// dropping brand-new out-of-scope aliases (scope leak fix). On-disk
|
|
196
|
+
// aliases are retained so out-of-scope code that references them still
|
|
197
|
+
// compiles.
|
|
154
198
|
for (const aliasName of aliases) {
|
|
155
199
|
batchedAliases.add(aliasName);
|
|
156
200
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
201
|
+
const emittedAliases = aliases.filter(aliasEmitted);
|
|
202
|
+
if (emittedAliases.length === 0) continue;
|
|
203
|
+
blockLines.push(`// The following types are structurally identical to ${canonicalStruct}.`);
|
|
204
|
+
blockLines.push('type (');
|
|
205
|
+
for (const aliasName of emittedAliases) {
|
|
206
|
+
blockLines.push(`\t${className(aliasName)} = ${canonicalStruct}`);
|
|
207
|
+
blockNames.push(className(aliasName));
|
|
208
|
+
if (isModelInScope(aliasName, ctx)) blockInScope = true;
|
|
161
209
|
}
|
|
162
|
-
|
|
163
|
-
lines.push('');
|
|
210
|
+
blockLines.push(')');
|
|
164
211
|
} else {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
212
|
+
blockLines.push(`// ${structName} is an alias for ${canonicalStruct}.`);
|
|
213
|
+
blockLines.push(`type ${structName} = ${canonicalStruct}`);
|
|
214
|
+
blockNames.push(structName);
|
|
215
|
+
if (isModelInScope(model.name, ctx)) blockInScope = true;
|
|
168
216
|
}
|
|
217
|
+
modelBlocks.push({ names: blockNames, text: blockLines.join('\n'), inScope: blockInScope });
|
|
169
218
|
continue;
|
|
170
219
|
}
|
|
171
220
|
|
|
172
221
|
// Emit struct
|
|
222
|
+
const blockLines: string[] = [];
|
|
173
223
|
if (model.description) {
|
|
174
224
|
const descLines = model.description.split('\n').filter((l) => l.trim());
|
|
175
|
-
|
|
225
|
+
blockLines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
|
|
176
226
|
for (let i = 1; i < descLines.length; i++) {
|
|
177
|
-
|
|
227
|
+
blockLines.push(`// ${descLines[i].trim()}`);
|
|
178
228
|
}
|
|
179
229
|
} else {
|
|
180
230
|
const humanized = humanize(model.name);
|
|
181
|
-
|
|
231
|
+
blockLines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
|
|
182
232
|
}
|
|
183
|
-
|
|
233
|
+
blockLines.push(`type ${structName} struct {`);
|
|
184
234
|
|
|
185
235
|
// Deduplicate fields by Go field name
|
|
186
236
|
const seenFieldNames = new Set<string>();
|
|
@@ -198,20 +248,35 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
198
248
|
|
|
199
249
|
if (field.description) {
|
|
200
250
|
const fdLines = field.description.split('\n').filter((l) => l.trim());
|
|
201
|
-
|
|
251
|
+
blockLines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
|
|
202
252
|
for (let i = 1; i < fdLines.length; i++) {
|
|
203
|
-
|
|
253
|
+
blockLines.push(`\t// ${fdLines[i].trim()}`);
|
|
204
254
|
}
|
|
205
255
|
}
|
|
206
256
|
if (field.deprecated) {
|
|
207
|
-
if (field.description)
|
|
257
|
+
if (field.description) blockLines.push(`\t//`);
|
|
208
258
|
const deprecationReason = extractDeprecationReason(field.description);
|
|
209
|
-
|
|
259
|
+
blockLines.push(`\t// Deprecated: ${deprecationReason}`);
|
|
210
260
|
}
|
|
211
|
-
|
|
261
|
+
blockLines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
|
|
212
262
|
}
|
|
213
263
|
|
|
214
|
-
|
|
264
|
+
blockLines.push('}');
|
|
265
|
+
modelBlocks.push({
|
|
266
|
+
names: [structName],
|
|
267
|
+
text: blockLines.join('\n'),
|
|
268
|
+
inScope: isModelInScope(model.name, ctx) || forcedCanonicals.has(model.name),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Scoped runs: drop brand-new out-of-scope blocks; carry over prior blocks the
|
|
273
|
+
// new spec renamed/removed (still referenced by un-regenerated resource code).
|
|
274
|
+
// Full runs return every block unchanged.
|
|
275
|
+
// `PaginationParams` is emitted separately below (not part of modelBlocks),
|
|
276
|
+
// so exclude it from carry-over or the prior copy would be redeclared.
|
|
277
|
+
const reconciled = reconcileFlatBlocks(modelBlocks, 'models.go', ctx, new Set(['PaginationParams']));
|
|
278
|
+
for (const text of reconciled) {
|
|
279
|
+
lines.push(text);
|
|
215
280
|
lines.push('');
|
|
216
281
|
}
|
|
217
282
|
|
package/src/go/resources.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
buildResolvedLookup,
|
|
23
23
|
lookupResolved,
|
|
24
|
-
|
|
24
|
+
scopedMountGroups,
|
|
25
25
|
getOpDefaults,
|
|
26
26
|
getOpInferFromClient,
|
|
27
27
|
buildHiddenParams,
|
|
@@ -60,11 +60,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
|
|
|
60
60
|
if (services.length === 0) return [];
|
|
61
61
|
|
|
62
62
|
const files: GeneratedFile[] = [];
|
|
63
|
-
const mountGroups =
|
|
63
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
64
64
|
|
|
65
65
|
// If no resolved operations, fall back to raw services
|
|
66
66
|
const entries: Array<{ name: string; operations: Operation[] }> =
|
|
67
|
-
mountGroups.size > 0
|
|
67
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
68
68
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
69
69
|
: services.map((s) => ({ name: resolveResourceClassName(s, ctx), operations: s.operations }));
|
|
70
70
|
|
package/src/go/tests.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { resolveResourceClassName, paramsStructName, sortPathParamsByTemplateOrd
|
|
|
5
5
|
import { buildServiceAccessPaths } from './client.js';
|
|
6
6
|
import { generateFixtures } from './fixtures.js';
|
|
7
7
|
import { isListWrapperModel } from './models.js';
|
|
8
|
-
import {
|
|
8
|
+
import { scopedMountGroups, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
|
|
9
9
|
import { existsSync, readFileSync } from 'node:fs';
|
|
10
10
|
import { resolve } from 'node:path';
|
|
11
11
|
|
|
@@ -71,8 +71,10 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
71
71
|
overwriteExisting: true,
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
// Generate fixture JSON files
|
|
75
|
-
|
|
74
|
+
// Generate fixture JSON files. Pass ctx so a scoped run only emits fixtures
|
|
75
|
+
// for in-scope models (or ones already on disk), dropping brand-new
|
|
76
|
+
// out-of-scope fixtures while leaving prior fixtures untouched.
|
|
77
|
+
const { files: fixtures, pathRewrites: fixtureRewrites } = generateFixtures(spec, ctx);
|
|
76
78
|
for (const fixture of fixtures) {
|
|
77
79
|
files.push({
|
|
78
80
|
path: fixture.path,
|
|
@@ -85,9 +87,9 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
|
|
|
85
87
|
const accessPaths = buildServiceAccessPaths(spec.services, ctx);
|
|
86
88
|
|
|
87
89
|
// Generate per-mount-target test files
|
|
88
|
-
const mountGroups =
|
|
90
|
+
const mountGroups = scopedMountGroups(ctx);
|
|
89
91
|
const testEntries: Array<{ name: string; operations: Operation[] }> =
|
|
90
|
-
mountGroups.size > 0
|
|
92
|
+
mountGroups.size > 0 || ctx.scopedServices?.size
|
|
91
93
|
? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
|
|
92
94
|
: spec.services.map((s) => ({
|
|
93
95
|
name: resolveResourceClassName(s, ctx),
|
package/src/kotlin/enums.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
2
2
|
import { className, ktStringLiteral } from './naming.js';
|
|
3
|
+
import { isEnumInScope } from '../shared/resolved-ops.js';
|
|
3
4
|
|
|
4
5
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
5
6
|
const ENUMS_PACKAGE = 'com.workos.types';
|
|
@@ -24,7 +25,7 @@ export const enumCanonicalMap = new Map<string, string>();
|
|
|
24
25
|
* shortest PascalCase name becomes canonical and the rest emit `typealias`
|
|
25
26
|
* files pointing at the canonical class.
|
|
26
27
|
*/
|
|
27
|
-
export function generateEnums(enums: Enum[],
|
|
28
|
+
export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
28
29
|
if (enums.length === 0) return [];
|
|
29
30
|
|
|
30
31
|
// Reset the canonical map on every generation run (guards against re-entry).
|
|
@@ -74,6 +75,11 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
74
75
|
|
|
75
76
|
const typeName = canonicalEnumTypeName(enumDef);
|
|
76
77
|
|
|
78
|
+
// FR-1.4: write per-enum FILES only when in scope. Each enum is its own
|
|
79
|
+
// `.kt` file (no barrel), so an out-of-scope enum left untouched on disk
|
|
80
|
+
// stays importable.
|
|
81
|
+
const enumInScope = isEnumInScope(enumDef.name, ctx);
|
|
82
|
+
|
|
77
83
|
// Non-canonical enum: emit a typealias instead of a full enum class.
|
|
78
84
|
const sharedSortEmitter = sharedSortEmitters.has(enumDef.name);
|
|
79
85
|
const canonicalName = sharedSortEmitter
|
|
@@ -94,11 +100,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
94
100
|
aliasLine,
|
|
95
101
|
'',
|
|
96
102
|
].join('\n');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
if (enumInScope) {
|
|
104
|
+
files.push({
|
|
105
|
+
path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
|
|
106
|
+
content: aliasContent,
|
|
107
|
+
overwriteExisting: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
102
110
|
continue;
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -175,11 +183,13 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
|
|
|
175
183
|
lines.push('}');
|
|
176
184
|
lines.push('');
|
|
177
185
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
if (enumInScope) {
|
|
187
|
+
files.push({
|
|
188
|
+
path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
|
|
189
|
+
content: lines.join('\n'),
|
|
190
|
+
overwriteExisting: true,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
return files;
|
package/src/kotlin/models.ts
CHANGED
|
@@ -8,11 +8,23 @@ import {
|
|
|
8
8
|
collectNonPaginatedResponseModelNames,
|
|
9
9
|
collectReferencedListMetadataModels,
|
|
10
10
|
} from '../shared/model-utils.js';
|
|
11
|
+
import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
|
|
11
12
|
|
|
12
13
|
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
13
14
|
const MODELS_PACKAGE = 'com.workos.models';
|
|
14
15
|
const MODELS_DIR = 'com/workos/models';
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The relative path (target-root-relative, matching the prior manifest) of the
|
|
19
|
+
* per-model `.kt` FILE the emitter writes for a model. The aggregate gate
|
|
20
|
+
* ({@link fileExistsAfterRun}) checks this exact path against the freshly-emitted
|
|
21
|
+
* in-scope set and the prior manifest. Must stay in sync with the path used in
|
|
22
|
+
* {@link emitDataClass} / {@link emitSealedUnion} / the typealias branch.
|
|
23
|
+
*/
|
|
24
|
+
function modelFilePath(modelName: string): string {
|
|
25
|
+
return `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${className(modelName)}.kt`;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Some specs leave string fields without `format: date-time` even though the
|
|
18
30
|
* description (or the example) makes clear they carry an ISO-8601 timestamp.
|
|
@@ -123,10 +135,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
123
135
|
for (const model of models) {
|
|
124
136
|
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
125
137
|
const typeName = className(model.name);
|
|
138
|
+
// FR-1.4: write per-model FILES only when in scope. Each model is its own
|
|
139
|
+
// `.kt` file (no barrel), so an out-of-scope model left untouched on disk
|
|
140
|
+
// stays importable. The WorkOSEvent sealed interface below is an aggregate
|
|
141
|
+
// built from many event models, so it is NOT gated.
|
|
142
|
+
const modelInScope = isModelInScope(model.name, ctx);
|
|
126
143
|
|
|
127
144
|
// Parent of a discriminated union: emit a sealed class.
|
|
128
145
|
if (model.fields.length === 0 && discriminatedUnions.has(typeName)) {
|
|
129
|
-
|
|
146
|
+
if (modelInScope) {
|
|
147
|
+
files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!, ctx));
|
|
148
|
+
}
|
|
130
149
|
continue;
|
|
131
150
|
}
|
|
132
151
|
|
|
@@ -142,25 +161,40 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
142
161
|
`typealias ${typeName} = ${canonicalType}`,
|
|
143
162
|
'',
|
|
144
163
|
].join('\n');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
164
|
+
if (modelInScope) {
|
|
165
|
+
files.push({
|
|
166
|
+
path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
|
|
167
|
+
content: aliasContent,
|
|
168
|
+
overwriteExisting: true,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
150
171
|
continue;
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
174
|
+
if (modelInScope) {
|
|
175
|
+
files.push(emitDataClass(model));
|
|
176
|
+
}
|
|
154
177
|
}
|
|
155
178
|
|
|
156
179
|
// Generate the sealed WorkOSEvent interface. Collect all event envelope
|
|
157
180
|
// models that have a literal `event` field and build the @JsonSubTypes
|
|
158
181
|
// mapping so Jackson can deserialize directly to the correct concrete type.
|
|
182
|
+
//
|
|
183
|
+
// This is an AGGREGATE: it enumerates many event models by name. A scoped
|
|
184
|
+
// (`--services`) run emits per-model `.kt` files only for in-scope models, so
|
|
185
|
+
// listing a brand-new OUT-OF-SCOPE event model here would reference a
|
|
186
|
+
// `ModelName::class` whose file is never written → "Unresolved reference"
|
|
187
|
+
// (the WorkOSEvent.kt build break). Gate each entry so it appears only if its
|
|
188
|
+
// model file will EXIST on disk after the run = in-scope (emitted now) ∪
|
|
189
|
+
// already-on-disk (prior manifest; scoped runs never prune). Renamed/
|
|
190
|
+
// removed-but-on-disk models still present under the same name in the spec are
|
|
191
|
+
// retained; full runs include everything (gate is inert).
|
|
159
192
|
const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
|
|
160
193
|
for (const model of models) {
|
|
161
194
|
if (skipAsListWrapper(model) || skipAsListMetadata(model)) continue;
|
|
162
195
|
if (aliasOf.has(model.name)) continue;
|
|
163
196
|
if (!isEventEnvelopeModel(model)) continue;
|
|
197
|
+
if (!fileExistsAfterRun(modelFilePath(model.name), isModelInScope(model.name, ctx), ctx)) continue;
|
|
164
198
|
const eventField = model.fields.find((f) => f.name === 'event');
|
|
165
199
|
if (eventField && eventField.type.kind === 'literal' && typeof eventField.type.value === 'string') {
|
|
166
200
|
eventMapping.push({ wireValue: eventField.type.value, modelName: model.name });
|
|
@@ -242,6 +276,7 @@ function emitDataClass(model: Model): GeneratedFile {
|
|
|
242
276
|
function emitSealedUnion(
|
|
243
277
|
typeName: string,
|
|
244
278
|
disc: { property: string; mapping: Record<string, string>; variantTypes: string[] },
|
|
279
|
+
ctx: EmitterContext,
|
|
245
280
|
): GeneratedFile {
|
|
246
281
|
const lines: string[] = [];
|
|
247
282
|
lines.push(`package ${MODELS_PACKAGE}`);
|
|
@@ -249,11 +284,19 @@ function emitSealedUnion(
|
|
|
249
284
|
lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
|
|
250
285
|
lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
|
|
251
286
|
lines.push('');
|
|
287
|
+
// AGGREGATE gate: @JsonSubTypes enumerates each variant model by name
|
|
288
|
+
// (`VariantClass::class`). The sealed parent itself is in scope here, but a
|
|
289
|
+
// scoped run may not emit a brand-new OUT-OF-SCOPE variant's `.kt` file — so
|
|
290
|
+
// only list variants whose model file will exist on disk after the run
|
|
291
|
+
// (in-scope ∪ prior manifest). A full run keeps every variant (gate is inert).
|
|
292
|
+
const entries = Object.entries(disc.mapping).filter(([, modelName]) =>
|
|
293
|
+
fileExistsAfterRun(modelFilePath(modelName), isModelInScope(modelName, ctx), ctx),
|
|
294
|
+
);
|
|
252
295
|
// KDoc with worked Kotlin + Java consumption examples. These unions are
|
|
253
296
|
// returned by Jackson; callers branch on the concrete subtype to access
|
|
254
|
-
// variant-specific data.
|
|
255
|
-
|
|
256
|
-
const exampleVariantType =
|
|
297
|
+
// variant-specific data. Use a surviving variant so the example never names a
|
|
298
|
+
// class whose file the scoped run skipped.
|
|
299
|
+
const exampleVariantType = entries.length > 0 ? className(entries[0][1]) : null;
|
|
257
300
|
lines.push('/**');
|
|
258
301
|
lines.push(` * Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`);
|
|
259
302
|
if (exampleVariantType) {
|
|
@@ -282,7 +325,6 @@ function emitSealedUnion(
|
|
|
282
325
|
lines.push(' visible = true');
|
|
283
326
|
lines.push(')');
|
|
284
327
|
lines.push('@JsonSubTypes(');
|
|
285
|
-
const entries = Object.entries(disc.mapping);
|
|
286
328
|
for (let i = 0; i < entries.length; i++) {
|
|
287
329
|
const [wireValue, modelName] = entries[i];
|
|
288
330
|
const variantType = className(modelName);
|