@workos/oagen-emitters 0.19.0 → 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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-BXDPA9pJ.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-DXIciTnN.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -1,7 +1,16 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { fixtureFileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames } from '../shared/model-utils.js';
5
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
6
+
7
+ /**
8
+ * Prefix the per-model fixture path with the .NET test project layout so the
9
+ * scoped-run gate can compare it against the prefixed paths recorded in the
10
+ * prior manifest. Must mirror `TEST_PREFIX` in index.ts; the fixture path
11
+ * itself (`testdata/<name>.json`) is what `prefixTestPaths` later prepends to.
12
+ */
13
+ const TEST_PREFIX = 'test/WorkOSTests/';
5
14
 
6
15
  /**
7
16
  * Prefix mapping for generating realistic ID fixture values.
@@ -25,11 +34,14 @@ export const ID_PREFIXES: Record<string, string> = {
25
34
  /**
26
35
  * Generate JSON fixture files for test data.
27
36
  */
28
- export function generateFixtures(spec: {
29
- models: Model[];
30
- enums: Enum[];
31
- services: any[];
32
- }): { path: string; content: string }[] {
37
+ export function generateFixtures(
38
+ spec: {
39
+ models: Model[];
40
+ enums: Enum[];
41
+ services: any[];
42
+ },
43
+ ctx: EmitterContext,
44
+ ): { path: string; content: string }[] {
33
45
  if (spec.models.length === 0) return [];
34
46
 
35
47
  const modelMap = new Map(spec.models.map((m) => [m.name, m]));
@@ -50,10 +62,19 @@ export function generateFixtures(spec: {
50
62
  if (isListMetadataModel(model)) continue;
51
63
  if (isListWrapperModel(model) && !nonPaginatedWrapperRefs.has(model.name)) continue;
52
64
 
65
+ // Scoped-run gate: only emit a per-model fixture whose file will exist on
66
+ // disk after the run (in-scope, or already present from a prior manifest).
67
+ // A brand-new out-of-scope model's fixture would be a stray file for a
68
+ // model no in-scope test can reference; a renamed/removed-but-on-disk one
69
+ // is retained. Full runs emit everything. `relPath` carries the
70
+ // `test/WorkOSTests/` prefix so it matches the prefixed prior-manifest paths.
71
+ const fixturePath = `testdata/${fixtureFileName(model.name)}.json`;
72
+ if (!fileExistsAfterRun(`${TEST_PREFIX}${fixturePath}`, isModelInScope(model.name, ctx), ctx)) continue;
73
+
53
74
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
54
75
 
55
76
  files.push({
56
- path: `testdata/${fixtureFileName(model.name)}.json`,
77
+ path: fixturePath,
57
78
  content: JSON.stringify(fixture, null, 2),
58
79
  });
59
80
 
@@ -19,7 +19,7 @@ import { generateClient } from './client.js';
19
19
  import { generateTests } from './tests.js';
20
20
  import { buildOperationsMap } from './manifest.js';
21
21
  import { generateWrapperOptionsClasses } from './wrappers.js';
22
- import { groupByMount } from '../shared/resolved-ops.js';
22
+ import { groupByMount, isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
23
23
  import { AUTOGEN_NOTICE } from '../shared/file-header.js';
24
24
  import { discriminatedUnions, resolveModelName } from './type-map.js';
25
25
  import { modelClassName } from './naming.js';
@@ -66,6 +66,28 @@ function prefixTestPaths(files: GeneratedFile[]): GeneratedFile[] {
66
66
  return files;
67
67
  }
68
68
 
69
+ /**
70
+ * Scoped-run gate for a discriminated-union variant referenced by a generated
71
+ * converter (the event-registry / polymorphic dispatch aggregate). A converter
72
+ * may only name a variant whose per-model `Entities/<Class>.cs` file will exist
73
+ * on disk after the run — otherwise the `new <Class>()` / `ToObject<<Class>>`
74
+ * arm references a type whose file is never emitted (CS0246). The set that
75
+ * exists after the run is: in-scope variants (emitted this run) ∪ variants
76
+ * already on disk from a prior manifest (scoped runs never prune), so a
77
+ * renamed/removed-but-still-on-disk variant is RETAINED while a brand-new
78
+ * out-of-scope variant is EXCLUDED. A full run includes everything.
79
+ *
80
+ * The variant name is resolved through the model-alias map first, exactly as
81
+ * the converter emits it (`modelClassName(resolveModelName(name))`), and the
82
+ * candidate path carries the `src/WorkOS.net/` prefix so it matches the
83
+ * prefixed paths recorded in `priorTargetManifestPaths`.
84
+ */
85
+ function variantFileExistsAfterRun(variantModelName: string, ctx: EmitterContext): boolean {
86
+ const canonical = resolveModelName(variantModelName);
87
+ const relPath = `${SRC_PREFIX}Entities/${modelClassName(canonical)}.cs`;
88
+ return fileExistsAfterRun(relPath, isModelInScope(canonical, ctx), ctx);
89
+ }
90
+
69
91
  export const dotnetEmitter: Emitter = {
70
92
  language: 'dotnet',
71
93
 
@@ -146,6 +168,11 @@ export const dotnetEmitter: Emitter = {
146
168
  lines.push(' switch (discriminatorValue)');
147
169
  lines.push(' {');
148
170
  for (const [value, modelName] of Object.entries(disc.mapping)) {
171
+ // Scoped-run gate: only dispatch to a variant whose `Entities/*.cs`
172
+ // file will exist after the run. A brand-new out-of-scope variant is
173
+ // skipped (its `.cs` is never emitted ⇒ CS0246); unmatched discriminator
174
+ // values fall through to the `default` arm below.
175
+ if (!variantFileExistsAfterRun(modelName, c)) continue;
149
176
  const csName = modelClassName(resolveModelName(modelName));
150
177
  lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
151
178
  }
@@ -177,6 +204,12 @@ export const dotnetEmitter: Emitter = {
177
204
  // CanWrite is false so serialization uses the default path and never
178
205
  // re-enters WriteJson.
179
206
  for (const [baseName, disc] of modelDiscriminators) {
207
+ // Skip the converter entirely when the base model's own `Entities/*.cs`
208
+ // won't exist after a scoped run (brand-new out-of-scope base): its
209
+ // `new <baseClass>()` default arm and `[JsonConverter]` attribute would
210
+ // both dangle. When the base is already on disk we still emit it — the
211
+ // untouched base class references this converter by name.
212
+ if (!variantFileExistsAfterRun(baseName, c)) continue;
180
213
  const baseClass = modelClassName(baseName);
181
214
  const converterName = `${baseClass}DiscriminatorConverter`;
182
215
  const lines: string[] = [];
@@ -210,6 +243,14 @@ export const dotnetEmitter: Emitter = {
210
243
  lines.push(' switch (discriminatorValue)');
211
244
  lines.push(' {');
212
245
  for (const [value, variantModelName] of Object.entries(disc.mapping)) {
246
+ // Scoped-run gate: only construct a variant whose `Entities/*.cs` file
247
+ // will exist after the run. This is the event-registry aggregate
248
+ // (`EventSchema` enumerates every event payload model): a brand-new
249
+ // out-of-scope event type is skipped (its `.cs` is never emitted ⇒
250
+ // CS0246), while a renamed/removed-but-still-on-disk one is retained.
251
+ // Unmatched discriminator values fall through to the `default` arm,
252
+ // which deserializes into the base class.
253
+ if (!variantFileExistsAfterRun(variantModelName, c)) continue;
213
254
  const csName = modelClassName(variantModelName);
214
255
  lines.push(` case "${value}": target = new ${csName}(); break;`);
215
256
  }
@@ -30,7 +30,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
30
30
  const files: GeneratedFile[] = [];
31
31
 
32
32
  // Generate fixture JSON files
33
- const fixtures = generateFixtures(spec);
33
+ const fixtures = generateFixtures(spec, ctx);
34
34
  for (const fixture of fixtures) {
35
35
  files.push({
36
36
  path: fixture.path,
package/src/go/enums.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
2
  import { walkTypeRef } from '@workos/oagen';
3
3
  import { className } from './naming.js';
4
+ import { isEnumInScope, isScopedRun } from '../shared/resolved-ops.js';
5
+ import { reconcileFlatBlocks, readPriorFile, type NamedBlock } from './flat-merge.js';
4
6
 
5
7
  /**
6
8
  * Generate Go typed string enum constants from IR Enum definitions.
@@ -16,6 +18,16 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
16
18
  if (enums.length === 0) return [];
17
19
 
18
20
  const aliasOf = collectEnumAliasOf(enums);
21
+ // An in-scope enum alias emits `type Alias = Canonical` against the current
22
+ // spec; the canonical may itself be out of scope and brand-new, which the
23
+ // reconciler would drop, leaving the alias dangling. Force-retain any
24
+ // canonical referenced by an in-scope alias.
25
+ const forcedCanonicals = new Set<string>();
26
+ if (isScopedRun(ctx)) {
27
+ for (const [aliasName, canonical] of aliasOf) {
28
+ if (isEnumInScope(aliasName, ctx)) forcedCanonicals.add(canonical);
29
+ }
30
+ }
19
31
  const files: GeneratedFile[] = [];
20
32
 
21
33
  // Group all enums into a single file per SDK
@@ -23,6 +35,12 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
23
35
  lines.push(`package ${ctx.namespace}`);
24
36
  lines.push('');
25
37
 
38
+ // Build one NamedBlock per emitted enum type so a scoped run can reconcile
39
+ // them against the prior enums.go (drop brand-new out-of-scope enums, retain
40
+ // renamed/removed ones still referenced by un-regenerated code). A full run
41
+ // emits every block unchanged.
42
+ const enumBlocks: NamedBlock[] = [];
43
+
26
44
  for (const enumDef of enums) {
27
45
  // If this enum is an alias, emit a simple type alias
28
46
  const canonicalName = aliasOf.get(enumDef.name);
@@ -33,9 +51,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
33
51
  // enums from enrichModelsFromSpec whose underscore names collapse to the
34
52
  // same PascalCase as the original enum).
35
53
  if (aliasType === canonicalType) continue;
36
- lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
37
- lines.push(`type ${aliasType} = ${canonicalType}`);
38
- lines.push('');
54
+ enumBlocks.push({
55
+ names: [aliasType],
56
+ text: [`// ${aliasType} is an alias for ${canonicalType}.`, `type ${aliasType} = ${canonicalType}`].join('\n'),
57
+ inScope: isEnumInScope(enumDef.name, ctx),
58
+ });
39
59
  continue;
40
60
  }
41
61
 
@@ -43,9 +63,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
43
63
 
44
64
  if (enumDef.values.length === 0) {
45
65
  const humanized = humanize(enumDef.name);
46
- lines.push(`// ${typeName} represents ${humanized} values.`);
47
- lines.push(`type ${typeName} = string`);
48
- lines.push('');
66
+ enumBlocks.push({
67
+ names: [typeName],
68
+ text: [`// ${typeName} represents ${humanized} values.`, `type ${typeName} = string`].join('\n'),
69
+ inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
70
+ });
49
71
  continue;
50
72
  }
51
73
 
@@ -61,10 +83,11 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
61
83
  }
62
84
 
63
85
  const humanized = humanize(enumDef.name);
64
- lines.push(`// ${typeName} represents ${humanized} values.`);
65
- lines.push(`type ${typeName} string`);
66
- lines.push('');
67
- lines.push('const (');
86
+ const blockLines: string[] = [];
87
+ blockLines.push(`// ${typeName} represents ${humanized} values.`);
88
+ blockLines.push(`type ${typeName} string`);
89
+ blockLines.push('');
90
+ blockLines.push('const (');
68
91
 
69
92
  const usedNames = new Set<string>();
70
93
  for (const v of uniqueValues) {
@@ -79,15 +102,25 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
79
102
  usedNames.add(constName);
80
103
  const valueStr = typeof v.value === 'string' ? `"${v.value}"` : String(v.value);
81
104
  if (v.description) {
82
- lines.push(`\t// ${constName} is ${v.description}.`);
105
+ blockLines.push(`\t// ${constName} is ${v.description}.`);
83
106
  }
84
107
  if (v.deprecated) {
85
- if (v.description) lines.push(`\t//`);
86
- lines.push(`\t// Deprecated: this value is deprecated.`);
108
+ if (v.description) blockLines.push(`\t//`);
109
+ blockLines.push(`\t// Deprecated: this value is deprecated.`);
87
110
  }
88
- lines.push(`\t${constName} ${typeName} = ${valueStr}`);
111
+ blockLines.push(`\t${constName} ${typeName} = ${valueStr}`);
89
112
  }
90
- lines.push(')');
113
+ blockLines.push(')');
114
+ enumBlocks.push({
115
+ names: [typeName],
116
+ text: blockLines.join('\n'),
117
+ inScope: isEnumInScope(enumDef.name, ctx) || forcedCanonicals.has(enumDef.name),
118
+ });
119
+ }
120
+
121
+ const reconciled = reconcileFlatBlocks(enumBlocks, 'enums.go', ctx);
122
+ for (const text of reconciled) {
123
+ lines.push(text);
91
124
  lines.push('');
92
125
  }
93
126
 
@@ -96,7 +129,7 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
96
129
  content: lines.join('\n'),
97
130
  overwriteExisting: true,
98
131
  });
99
- const eventConstantsFile = generateEventConstantsFile(enums);
132
+ const eventConstantsFile = generateEventConstantsFile(enums, ctx);
100
133
  if (eventConstantsFile) files.push(eventConstantsFile);
101
134
 
102
135
  return files;
@@ -175,10 +208,28 @@ function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
175
208
  return aliasOf;
176
209
  }
177
210
 
178
- function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
211
+ const EVENTS_FILE_PATH = 'pkg/events/events.go';
212
+
213
+ function generateEventConstantsFile(enums: Enum[], ctx: EmitterContext): GeneratedFile | null {
179
214
  const enumDef = findWebhookEventEnum(enums);
180
215
  if (!enumDef) return null;
181
216
 
217
+ // Scoped-run gate for the flat events file. Unlike models/enums, an event
218
+ // value can't be mapped to a single selected service, so there is no
219
+ // per-event "in scope" signal. The correct Option-B behavior is therefore to
220
+ // keep this aggregate byte-stable in a scoped run: emit only event constants
221
+ // that already existed in the prior events.go (dropping brand-new additions
222
+ // such as `session.reauthenticated` from an out-of-scope service) and never
223
+ // drop a constant that was present before. Equivalent to: in a scoped run,
224
+ // emit exactly the prior file's constant set.
225
+ let priorEventConsts: Set<string> | null = null;
226
+ if (isScopedRun(ctx)) {
227
+ const prior = readPriorFile(EVENTS_FILE_PATH, ctx);
228
+ if (prior !== null) {
229
+ priorEventConsts = collectPriorEventConstNames(prior);
230
+ }
231
+ }
232
+
182
233
  const lines: string[] = [];
183
234
  lines.push('package events');
184
235
  lines.push('');
@@ -195,6 +246,13 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
195
246
  seenValues.add(valueStr);
196
247
 
197
248
  const constName = uniqueEventConstantName(valueStr, usedNames);
249
+
250
+ // Scoped run: skip a constant that wasn't in the prior file (a brand-new,
251
+ // out-of-scope event). When there's no prior file to compare against, fall
252
+ // through and emit everything (first generation / non-target run).
253
+ if (priorEventConsts !== null && !priorEventConsts.has(constName)) {
254
+ continue;
255
+ }
198
256
  usedNames.add(constName);
199
257
 
200
258
  if (value.description) {
@@ -213,12 +271,27 @@ function generateEventConstantsFile(enums: Enum[]): GeneratedFile | null {
213
271
  lines.push('');
214
272
 
215
273
  return {
216
- path: 'pkg/events/events.go',
274
+ path: EVENTS_FILE_PATH,
217
275
  content: lines.join('\n'),
218
276
  overwriteExisting: true,
219
277
  };
220
278
  }
221
279
 
280
+ /**
281
+ * Collect the constant names declared in a prior `pkg/events/events.go`. Each
282
+ * event constant is emitted as `\tConstName = "wire.value"` inside the single
283
+ * `const (...)` block, so a simple line scan recovers the prior name set used
284
+ * to keep the file byte-stable across scoped runs.
285
+ */
286
+ function collectPriorEventConstNames(content: string): Set<string> {
287
+ const names = new Set<string>();
288
+ for (const raw of content.split('\n')) {
289
+ const m = raw.trim().match(/^(\w+)\s*=\s*"/);
290
+ if (m) names.add(m[1]);
291
+ }
292
+ return names;
293
+ }
294
+
222
295
  function findWebhookEventEnum(enums: Enum[]): Enum | null {
223
296
  return (
224
297
  enums.find((enumDef) => enumDef.name === 'CreateWebhookEndpointEvents') ??
@@ -1,7 +1,8 @@
1
- import type { Model, TypeRef, Enum } from '@workos/oagen';
1
+ import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
2
2
  import { fileName, domainFieldName } from './naming.js';
3
3
  import { isListMetadataModel, isListWrapperModel } from './models.js';
4
4
  import { collectNonPaginatedResponseModelNames, collectReferencedListMetadataModels } from '../shared/model-utils.js';
5
+ import { isModelInScope, fileExistsAfterRun } from '../shared/resolved-ops.js';
5
6
 
6
7
  /**
7
8
  * Prefix mapping for generating realistic ID fixture values.
@@ -24,8 +25,18 @@ export const ID_PREFIXES: Record<string, string> = {
24
25
 
25
26
  /**
26
27
  * Generate JSON fixture files for test data.
28
+ *
29
+ * Scoped runs only emit a fixture for a model whose per-model fixture FILE will
30
+ * exist on disk after the run (in-scope, or already present from a prior run —
31
+ * the fixture path is per-model so the prior manifest records it directly).
32
+ * This drops brand-new out-of-scope fixtures (the round-trip ADDITION case) and
33
+ * keeps prior fixtures untouched, mirroring the Rust fixtures fix. `ctx` is
34
+ * optional so unit tests that assert raw content can call it for a full run.
27
35
  */
28
- export function generateFixtures(spec: { models: Model[]; enums: Enum[]; services: any[] }): {
36
+ export function generateFixtures(
37
+ spec: { models: Model[]; enums: Enum[]; services: any[] },
38
+ ctx?: EmitterContext,
39
+ ): {
29
40
  files: { path: string; content: string }[];
30
41
  pathRewrites: Map<string, string>;
31
42
  } {
@@ -38,14 +49,21 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
38
49
  const nonPaginatedRefs = collectNonPaginatedResponseModelNames(spec.services);
39
50
  const listMetadataNeeded = collectReferencedListMetadataModels(spec.models, nonPaginatedRefs);
40
51
 
52
+ // Full run (no ctx / no scoping): every fixture is in scope.
53
+ const fixtureInScope = (relPath: string, modelName: string): boolean =>
54
+ !ctx || fileExistsAfterRun(relPath, isModelInScope(modelName, ctx), ctx);
55
+
41
56
  for (const model of spec.models) {
42
57
  if (isListMetadataModel(model) && !listMetadataNeeded.has(model.name)) continue;
43
58
  if (isListWrapperModel(model) && !nonPaginatedRefs.has(model.name)) continue;
44
59
 
60
+ const path = `testdata/${fileName(model.name)}.json`;
61
+ if (!fixtureInScope(path, model.name)) continue;
62
+
45
63
  const fixture = model.fields.length === 0 ? {} : generateModelFixture(model, modelMap, enumMap);
46
64
 
47
65
  files.push({
48
- path: `testdata/${fileName(model.name)}.json`,
66
+ path,
49
67
  content: JSON.stringify(fixture, null, 2),
50
68
  });
51
69
  }
@@ -67,6 +85,10 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
67
85
  const path = `testdata/list_${fileName(itemModel.name)}.json`;
68
86
  if (seenListPaths.has(path)) continue;
69
87
  seenListPaths.add(path);
88
+ // Scoped run: only emit a list fixture whose file will exist after the
89
+ // run (in-scope item model, or already on disk). Keyed on the item
90
+ // model so an out-of-scope paginated endpoint doesn't add a new file.
91
+ if (!fixtureInScope(path, itemModel.name)) continue;
70
92
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
71
93
  const listFixture = {
72
94
  data: [fixture],
@@ -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
+ }