@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.
Files changed (58) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/{plugin-Cciic50q.mjs → plugin-DXIciTnN.mjs} +668 -164
  6. package/dist/plugin-DXIciTnN.mjs.map +1 -0
  7. package/dist/plugin.mjs +1 -1
  8. package/package.json +4 -4
  9. package/src/dotnet/enums.ts +11 -5
  10. package/src/dotnet/fixtures.ts +28 -7
  11. package/src/dotnet/index.ts +42 -1
  12. package/src/dotnet/models.ts +11 -5
  13. package/src/dotnet/resources.ts +3 -3
  14. package/src/dotnet/tests.ts +4 -4
  15. package/src/go/enums.ts +91 -18
  16. package/src/go/fixtures.ts +25 -3
  17. package/src/go/flat-merge.ts +253 -0
  18. package/src/go/models.ts +85 -20
  19. package/src/go/resources.ts +3 -3
  20. package/src/go/tests.ts +7 -5
  21. package/src/kotlin/enums.ts +21 -11
  22. package/src/kotlin/models.ts +53 -11
  23. package/src/kotlin/resources.ts +2 -2
  24. package/src/kotlin/tests.ts +38 -3
  25. package/src/node/enums.ts +8 -5
  26. package/src/node/models.ts +29 -21
  27. package/src/node/resources.ts +12 -1
  28. package/src/node/tests.ts +7 -2
  29. package/src/php/enums.ts +18 -5
  30. package/src/php/index.ts +11 -3
  31. package/src/php/models.ts +11 -5
  32. package/src/php/resources.ts +6 -4
  33. package/src/php/tests.ts +6 -3
  34. package/src/python/enums.ts +39 -28
  35. package/src/python/fixtures.ts +34 -6
  36. package/src/python/models.ts +138 -45
  37. package/src/python/resources.ts +3 -3
  38. package/src/python/tests.ts +31 -12
  39. package/src/ruby/enums.ts +28 -19
  40. package/src/ruby/models.ts +23 -12
  41. package/src/ruby/rbi.ts +17 -6
  42. package/src/ruby/resources.ts +2 -2
  43. package/src/ruby/tests.ts +37 -4
  44. package/src/rust/enums.ts +29 -7
  45. package/src/rust/fixtures.ts +12 -3
  46. package/src/rust/models.ts +37 -6
  47. package/src/rust/resources.ts +8 -1
  48. package/src/rust/tests.ts +3 -3
  49. package/src/shared/resolved-ops.ts +104 -0
  50. package/test/dotnet/scoped-aggregates.test.ts +247 -0
  51. package/test/go/scoping.test.ts +324 -0
  52. package/test/kotlin/models.test.ts +74 -0
  53. package/test/kotlin/tests.test.ts +33 -0
  54. package/test/python/scoped-aggregates.test.ts +205 -0
  55. package/test/ruby/tests.test.ts +130 -0
  56. package/test/rust/fixtures.test.ts +13 -7
  57. package/test/shared/synthetic-enum-seed.test.ts +79 -0
  58. 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
- // Batch emit all aliases for this group at once
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
- lines.push(`// The following types are structurally identical to ${canonicalStruct}.`);
158
- lines.push('type (');
159
- for (const aliasName of aliases) {
160
- lines.push(`\t${className(aliasName)} = ${canonicalStruct}`);
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
- lines.push(')');
163
- lines.push('');
210
+ blockLines.push(')');
164
211
  } else {
165
- lines.push(`// ${structName} is an alias for ${canonicalStruct}.`);
166
- lines.push(`type ${structName} = ${canonicalStruct}`);
167
- lines.push('');
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
- lines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
225
+ blockLines.push(`// ${structName} ${lowerFirst(descLines[0])}`);
176
226
  for (let i = 1; i < descLines.length; i++) {
177
- lines.push(`// ${descLines[i].trim()}`);
227
+ blockLines.push(`// ${descLines[i].trim()}`);
178
228
  }
179
229
  } else {
180
230
  const humanized = humanize(model.name);
181
- lines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
231
+ blockLines.push(`// ${structName} represents ${articleFor(humanized)} ${humanized}.`);
182
232
  }
183
- lines.push(`type ${structName} struct {`);
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
- lines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
251
+ blockLines.push(`\t// ${fieldDocComment(goFieldName, fdLines[0])}`);
202
252
  for (let i = 1; i < fdLines.length; i++) {
203
- lines.push(`\t// ${fdLines[i].trim()}`);
253
+ blockLines.push(`\t// ${fdLines[i].trim()}`);
204
254
  }
205
255
  }
206
256
  if (field.deprecated) {
207
- if (field.description) lines.push(`\t//`);
257
+ if (field.description) blockLines.push(`\t//`);
208
258
  const deprecationReason = extractDeprecationReason(field.description);
209
- lines.push(`\t// Deprecated: ${deprecationReason}`);
259
+ blockLines.push(`\t// Deprecated: ${deprecationReason}`);
210
260
  }
211
- lines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
261
+ blockLines.push(`\t${goFieldName} ${goType} \`${jsonTag}\``);
212
262
  }
213
263
 
214
- lines.push('}');
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
 
@@ -21,7 +21,7 @@ import {
21
21
  import {
22
22
  buildResolvedLookup,
23
23
  lookupResolved,
24
- groupByMount,
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 = groupByMount(ctx);
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 { groupByMount, buildResolvedLookup, lookupResolved, buildHiddenParams } from '../shared/resolved-ops.js';
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
- const { files: fixtures, pathRewrites: fixtureRewrites } = generateFixtures(spec);
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 = groupByMount(ctx);
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),
@@ -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[], _ctx: EmitterContext): GeneratedFile[] {
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
- files.push({
98
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
99
- content: aliasContent,
100
- overwriteExisting: true,
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
- files.push({
179
- path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
180
- content: lines.join('\n'),
181
- overwriteExisting: true,
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;
@@ -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
- files.push(emitSealedUnion(typeName, discriminatedUnions.get(typeName)!));
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
- files.push({
146
- path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/${typeName}.kt`,
147
- content: aliasContent,
148
- overwriteExisting: true,
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
- files.push(emitDataClass(model));
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
- const exampleVariantWire = Object.keys(disc.mapping)[0];
256
- const exampleVariantType = exampleVariantWire ? className(disc.mapping[exampleVariantWire]) : null;
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);