@workos/oagen-emitters 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -225,7 +225,12 @@ function ignoredResourceMethodNames(ctx: EmitterContext, resourcePath: string):
225
225
  const methods = new Set<string>();
226
226
  for (const block of content.matchAll(/@oagen-ignore-start[\s\S]*?@oagen-ignore-end/g)) {
227
227
  for (const line of block[0].split('\n')) {
228
- const match = line.match(/^\s{2}(?:(?:public|private|protected)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/);
228
+ // Match the method name followed by `(` or by `<` — the latter covers
229
+ // generic methods (`getProfile<T extends ...>(...)`), including
230
+ // multi-line type-parameter lists where the line ends right after `<`.
231
+ // Matching only up to the opening bracket sidesteps balancing nested
232
+ // angle brackets like `<T extends Record<string, unknown> = ...>`.
233
+ const match = line.match(/^\s{2}(?:(?:public|private|protected)\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*[<(]/);
229
234
  if (match) methods.add(match[1]);
230
235
  }
231
236
  }
@@ -310,6 +315,18 @@ function renderOptionsParam(param: OptionsObjectParam): string {
310
315
  return `options${param.optional ? '?' : ''}: ${param.type}`;
311
316
  }
312
317
 
318
+ /**
319
+ * Whether a baseline-derived type reference is a plain TypeScript identifier
320
+ * that can appear in a named import. Baseline (live-SDK) method params can
321
+ * carry inline object-literal TYPES (e.g. `{ intent: GenerateLinkIntent; ... }`);
322
+ * treating that literal text as a type NAME would slugify it into a filename
323
+ * and emit a named import of a brace-expression — both invalid. Literal types
324
+ * are kept inline in the emitted signature instead and never imported.
325
+ */
326
+ function isValidTypeIdentifier(name: string): boolean {
327
+ return /^[A-Za-z_$][\w$]*$/.test(name);
328
+ }
329
+
313
330
  function autoPaginatableItemType(returnType: string | undefined): string | undefined {
314
331
  // Match both AutoPaginatable<T> and the legacy List<T> pattern so baseline
315
332
  // item types are extracted even when the hand-written code predates AutoPaginatable.
@@ -933,6 +950,9 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
933
950
 
934
951
  const importedTypeNames = new Set<string>();
935
952
  for (const optionType of optionObjectTypes) {
953
+ // Inline object-literal types from the baseline surface are rendered
954
+ // inline in the method signature — they have no importable name or file.
955
+ if (!isValidTypeIdentifier(optionType)) continue;
936
956
  if (importedTypeNames.has(optionType)) continue;
937
957
  importedTypeNames.add(optionType);
938
958
  const sourceFile = baselineTypeSourceFile(ctx, optionType);
@@ -1675,7 +1695,17 @@ function renderOptionsObjectMethod(
1675
1695
  const wireType = wireInterfaceName(itemType);
1676
1696
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
1677
1697
  const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
1678
- const paginationType = needsWireSerializer ? 'PaginationOptions' : optionParam.type;
1698
+ // When path params are destructured out of the options object, the value
1699
+ // passed to AutoPaginatable (and to fetchAndDeserialize) is the REST
1700
+ // object — typed Omit<FullOptions, pathFields> — not the full options
1701
+ // interface. Declaring the full interface as the second type argument
1702
+ // fails TS2322 because the rest object lacks the required path-param
1703
+ // fields, so parameterize over the rest type actually passed.
1704
+ const restOptionsType =
1705
+ pathBindings.length > 0
1706
+ ? `Omit<${optionParam.type}, ${pathBindings.map((b) => `'${b}'`).join(' | ')}>`
1707
+ : optionParam.type;
1708
+ const paginationType = needsWireSerializer ? 'PaginationOptions' : restOptionsType;
1679
1709
  const returnType = needsWireSerializer
1680
1710
  ? `Promise<AutoPaginatable<${itemType}, ${paginationType}>>`
1681
1711
  : (preferredBaselineReturnType(ctx, baselineMethod?.returnType) ??
@@ -1793,7 +1823,13 @@ function renderOptionsObjectMethod(
1793
1823
 
1794
1824
  lines.push(` async ${method}(${renderOptionsParam(optionParam)}): ${methodReturnType} {`);
1795
1825
  renderOptionsObjectDestructure(lines, pathBindings);
1796
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${queryOptionsArg});`);
1826
+ // POST/PUT/PATCH require the entity argument even without a request body —
1827
+ // the client signature is `post(path, entity, options?)`, so a body-less
1828
+ // call must still pass `{}` or the generated code fails with TS2554.
1829
+ const emptyBodyArg = httpMethodNeedsBody(op.httpMethod) ? ', {}' : '';
1830
+ lines.push(
1831
+ ` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}${emptyBodyArg}${queryOptionsArg});`,
1832
+ );
1797
1833
  if (override?.returnExpression) {
1798
1834
  lines.push(` const result = ${returnExpr};`);
1799
1835
  lines.push(` return ${override.returnExpression};`);
package/src/node/utils.ts CHANGED
@@ -11,12 +11,15 @@ import { mapTypeRef } from './type-map.js';
11
11
  import {
12
12
  resolveInterfaceName,
13
13
  fieldName,
14
+ fileName,
14
15
  resolveServiceDir,
15
16
  resolveMethodName,
16
17
  buildServiceNameMap,
17
18
  } from './naming.js';
18
- import { getMountTarget } from '../shared/resolved-ops.js';
19
- import { assignModelsToServices } from '@workos/oagen';
19
+ import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
20
+ import { assignModelsToServices, collectModelRefs, collectFieldDependencies } from '@workos/oagen';
21
+ import { isNodeOwnedService } from './options.js';
22
+ import { liveSurfaceHasExistingSdk, liveSurfaceHasFile } from './live-surface.js';
20
23
 
21
24
  /**
22
25
  * Compute a relative import path between two files within the generated SDK.
@@ -203,7 +206,7 @@ export function createServiceDirResolver(
203
206
  serviceNameMap: Map<string, string>;
204
207
  resolveDir: (irService: string | undefined) => string;
205
208
  } {
206
- const modelToService = assignModelsToServices(models, services, ctx.modelHints);
209
+ const modelToService = assignModelsToEmittableServices(models, services, ctx);
207
210
  const serviceNameMap = buildServiceNameMap(services, ctx);
208
211
 
209
212
  // Per-name → directory override, harvested from the live SDK surface.
@@ -212,25 +215,7 @@ export function createServiceDirResolver(
212
215
  // baseline directory string (e.g., "user-management"). The override map is
213
216
  // attached by tagging the model name with a directory prefix that bypasses
214
217
  // the IR-service lookup. Concretely we keep a side map.
215
- const baselineDirByModel = new Map<string, string>();
216
- const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
217
- const sourceFile = info?.sourceFile;
218
- if (!sourceFile) return;
219
- const m = sourceFile.match(/^src\/([^/]+)\//);
220
- if (!m) return;
221
- baselineDirByModel.set(name, m[1]);
222
- };
223
- // Both interfaces and type aliases can shadow IR model names — e.g.
224
- // `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
225
- // canonical Role definition even though the IR represents Role as a model.
226
- for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
227
- recordSource(name, info as { sourceFile?: string });
228
- }
229
- for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
230
- if (!baselineDirByModel.has(name)) {
231
- recordSource(name, info as { sourceFile?: string });
232
- }
233
- }
218
+ const baselineDirByModel = harvestBaselineDirByModel(ctx);
234
219
 
235
220
  // Override modelToService for any IR model that has a baseline sourceFile.
236
221
  // We invent a synthetic IR-service key that maps directly to the baseline
@@ -255,6 +240,139 @@ export function createServiceDirResolver(
255
240
  return { modelToService, serviceNameMap, resolveDir };
256
241
  }
257
242
 
243
+ /**
244
+ * Map baseline interface / type-alias names to the top-level `src/<dir>/`
245
+ * their `sourceFile` lives in. Both kinds can shadow IR model names — e.g.
246
+ * `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
247
+ * canonical Role definition even though the IR represents Role as a model.
248
+ */
249
+ function harvestBaselineDirByModel(ctx: EmitterContext): Map<string, string> {
250
+ const baselineDirByModel = new Map<string, string>();
251
+ const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
252
+ const sourceFile = info?.sourceFile;
253
+ if (!sourceFile) return;
254
+ const m = sourceFile.match(/^src\/([^/]+)\//);
255
+ if (!m) return;
256
+ baselineDirByModel.set(name, m[1]);
257
+ };
258
+ for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
259
+ recordSource(name, info as { sourceFile?: string });
260
+ }
261
+ for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
262
+ if (!baselineDirByModel.has(name)) {
263
+ recordSource(name, info as { sourceFile?: string });
264
+ }
265
+ }
266
+ return baselineDirByModel;
267
+ }
268
+
269
+ /**
270
+ * `assignModelsToServices` plus an owned-service correction pass.
271
+ *
272
+ * The engine's assignment is first-reference-wins: a model referenced by both
273
+ * Organizations and AuditLogs lands in `organizations/` even when only
274
+ * AuditLogs is owned this run. Against an existing SDK, `applyLiveSurface`
275
+ * then drops the model file (a non-owned, non-adopted directory cannot
276
+ * receive new paths) while the owned resource still imports
277
+ * `../organizations/interfaces/<model>.interface` — an import that resolves
278
+ * to nothing (TS2307).
279
+ *
280
+ * The correction re-homes such models to the owned service that references
281
+ * them, so emission and import planning agree on a directory that is allowed
282
+ * to receive files. It only fires when:
283
+ * 1. `ownedServices` is configured and the run targets an existing SDK;
284
+ * 2. the model has no baseline `sourceFile` (otherwise the baseline-dir
285
+ * override keeps imports pointing at the on-disk location);
286
+ * 3. the model's computed interface path does not already exist on disk;
287
+ * 4. the assigned service is neither owned itself nor sharing a directory
288
+ * with an owned service.
289
+ */
290
+ export function assignModelsToEmittableServices(
291
+ models: Model[],
292
+ services: Service[],
293
+ ctx?: EmitterContext,
294
+ ): Map<string, string> {
295
+ const modelToService = assignModelsToServices(models, services, ctx?.modelHints);
296
+ if (ctx) {
297
+ reassignOwnedServiceDependencies(modelToService, models, services, ctx);
298
+ }
299
+ return modelToService;
300
+ }
301
+
302
+ function reassignOwnedServiceDependencies(
303
+ modelToService: Map<string, string>,
304
+ models: Model[],
305
+ services: Service[],
306
+ ctx: EmitterContext,
307
+ ): void {
308
+ const serviceNameMap = buildServiceNameMap(services, ctx);
309
+ // Ownership is a property of the MOUNT target, not the IR service: an op
310
+ // can live on a non-owned IR service (e.g. Organizations, because its path
311
+ // starts with /organizations) while being mounted on an owned service via
312
+ // `resolvedOperations` (e.g. AuditLogs' retention endpoints). Walking only
313
+ // IR services misses such ops entirely, so the models they reference stay
314
+ // assigned to the unemittable IR directory and are never emitted anywhere.
315
+ // Regroup by mount target when resolved operations exist — same as
316
+ // `buildGeneratedResourceModelUsage` and `computeOwnedServiceDirs`.
317
+ const mountGroups = groupByMount(ctx);
318
+ const candidateServices: Service[] =
319
+ mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
320
+ const ownedServices = candidateServices.filter((s) => isNodeOwnedService(ctx, s.name, serviceNameMap.get(s.name)));
321
+ if (ownedServices.length === 0) return;
322
+ // Greenfield generation emits every directory; nothing is unemittable.
323
+ if (!liveSurfaceHasExistingSdk()) return;
324
+
325
+ const dirOf = (irService: string | undefined): string =>
326
+ irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
327
+ const ownedDirs = new Set(ownedServices.map((s) => dirOf(s.name)));
328
+ const baselineDirByModel = harvestBaselineDirByModel(ctx);
329
+ const modelsByName = new Map(models.map((m) => [m.name, m]));
330
+
331
+ for (const service of ownedServices) {
332
+ for (const name of collectServiceModelClosure(service, modelsByName)) {
333
+ if (!modelsByName.has(name)) continue;
334
+ if (baselineDirByModel.has(name)) continue; // declared in the baseline → on disk
335
+ const assigned = modelToService.get(name);
336
+ if (assigned && isNodeOwnedService(ctx, assigned, serviceNameMap.get(assigned))) continue;
337
+ const assignedDir = dirOf(assigned);
338
+ if (ownedDirs.has(assignedDir)) continue;
339
+ if (liveSurfaceHasFile(`src/${assignedDir}/interfaces/${fileName(name)}.interface.ts`)) continue;
340
+ modelToService.set(name, service.name);
341
+ }
342
+ }
343
+ }
344
+
345
+ /** Model names referenced by a service's operations, expanded through fields. */
346
+ function collectServiceModelClosure(service: Service, modelsByName: Map<string, Model>): Set<string> {
347
+ const referenced = new Set<string>();
348
+ const add = (ref: TypeRef | undefined): void => {
349
+ if (!ref) return;
350
+ for (const name of collectModelRefs(ref)) referenced.add(name);
351
+ };
352
+ for (const op of service.operations) {
353
+ add(op.requestBody);
354
+ add(op.response);
355
+ for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
356
+ add(param.type);
357
+ }
358
+ if (op.pagination) add(op.pagination.itemType);
359
+ }
360
+
361
+ const queue = [...referenced];
362
+ while (queue.length > 0) {
363
+ const name = queue.pop()!;
364
+ const model = modelsByName.get(name);
365
+ if (!model) continue;
366
+ for (const dep of collectFieldDependencies(model).models) {
367
+ if (!referenced.has(dep)) {
368
+ referenced.add(dep);
369
+ queue.push(dep);
370
+ }
371
+ }
372
+ }
373
+ return referenced;
374
+ }
375
+
258
376
  /**
259
377
  * Check if baseline interface fields appear to contain generic type parameters.
260
378
  *
@@ -1,7 +1,13 @@
1
- import { describe, it, expect } from 'vitest';
2
- import type { EmitterContext, ApiSpec, Enum } from '@workos/oagen';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import type { EmitterContext, ApiSpec, Enum, Model } from '@workos/oagen';
3
3
  import { defaultSdkBehavior } from '@workos/oagen';
4
4
  import { generateEnums } from '../../src/node/enums.js';
5
+ import { nodeEmitter } from '../../src/node/index.js';
6
+ import { emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
5
11
 
6
12
  const emptySpec: ApiSpec = {
7
13
  name: 'Test',
@@ -126,3 +132,234 @@ describe('generateEnums', () => {
126
132
  expect(result[0].content).toContain('/** @deprecated */');
127
133
  });
128
134
  });
135
+
136
+ describe('assignEnumsToServices owned-service dependency reassignment', () => {
137
+ it('follows a reassigned dependency model into the owned service', () => {
138
+ // The enum is referenced only through `AuditLogsRetention`, whose
139
+ // first-reference assignment is Organizations (unemittable this run).
140
+ // When the owned AuditLogs service pulls the model into `audit-logs/`,
141
+ // the enum must follow — otherwise the model file imports an enum file
142
+ // that is emitted nowhere.
143
+ const surface = emptyLiveSurface();
144
+ surface.files.add('src/workos.ts'); // existing SDK
145
+ setActiveLiveSurface(surface);
146
+ try {
147
+ const enums: Enum[] = [
148
+ {
149
+ name: 'RetentionPeriod',
150
+ values: [
151
+ { name: 'THIRTY_DAYS', value: '30d' },
152
+ { name: 'NINETY_DAYS', value: '90d' },
153
+ ],
154
+ },
155
+ ];
156
+ const models = [
157
+ {
158
+ name: 'AuditLogsRetention',
159
+ fields: [
160
+ {
161
+ name: 'period',
162
+ type: { kind: 'enum' as const, name: 'RetentionPeriod', values: ['30d', '90d'] },
163
+ required: true,
164
+ },
165
+ ],
166
+ },
167
+ ];
168
+ const retentionOp = (name: string, path: string) => ({
169
+ name,
170
+ httpMethod: 'get' as const,
171
+ path,
172
+ pathParams: [],
173
+ queryParams: [],
174
+ headerParams: [],
175
+ response: { kind: 'model' as const, name: 'AuditLogsRetention' },
176
+ errors: [],
177
+ injectIdempotencyKey: false,
178
+ });
179
+ const services = [
180
+ { name: 'Organizations', operations: [retentionOp('getRetention', '/organizations/{id}/retention')] },
181
+ { name: 'AuditLogs', operations: [retentionOp('getAuditLogsRetention', '/audit_logs/retention')] },
182
+ ];
183
+ const ctxOwned: EmitterContext = {
184
+ ...ctx,
185
+ spec: { ...emptySpec, enums, models, services },
186
+ emitterOptions: { ownedServices: ['AuditLogs'] },
187
+ } as EmitterContext;
188
+
189
+ const result = generateEnums(enums, ctxOwned);
190
+ expect(result).toHaveLength(1);
191
+ expect(result[0].path).toBe('src/audit-logs/interfaces/retention-period.interface.ts');
192
+ } finally {
193
+ setActiveLiveSurface(emptyLiveSurface());
194
+ }
195
+ });
196
+ });
197
+
198
+ describe('owned-service enum emission under the live-surface skip', () => {
199
+ function ownedDomainSpec(enums: Enum[], models: Model[]): ApiSpec {
200
+ return {
201
+ ...emptySpec,
202
+ enums,
203
+ models,
204
+ services: [
205
+ {
206
+ name: 'OrganizationDomains',
207
+ operations: [
208
+ {
209
+ name: 'getOrganizationDomain',
210
+ httpMethod: 'get',
211
+ path: '/organization_domains/{id}',
212
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
213
+ queryParams: [],
214
+ headerParams: [],
215
+ response: { kind: 'model', name: 'OrganizationDomain' },
216
+ errors: [],
217
+ injectIdempotencyKey: false,
218
+ },
219
+ ],
220
+ },
221
+ ],
222
+ };
223
+ }
224
+
225
+ const stateEnum: Enum = {
226
+ name: 'OrganizationDomainState',
227
+ values: [
228
+ { name: 'VERIFIED', value: 'verified' },
229
+ { name: 'PENDING', value: 'pending' },
230
+ ],
231
+ };
232
+ const domainModel: Model = {
233
+ name: 'OrganizationDomain',
234
+ fields: [
235
+ { name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
236
+ {
237
+ name: 'state',
238
+ type: { kind: 'enum', name: 'OrganizationDomainState', values: ['verified', 'pending'] },
239
+ required: true,
240
+ },
241
+ ],
242
+ };
243
+
244
+ it('still emits the union module when the name is declared in a file the owned regeneration overwrites', () => {
245
+ // Real instance (OrganizationDomains rebuild, service OWNED): the
246
+ // on-disk organization-domain.interface.ts declared the enum names, so
247
+ // the live-surface skip suppressed emitting the canonical modules — but
248
+ // that very file was simultaneously being OVERWRITTEN by the owned
249
+ // regeneration, leaving the names declared nowhere.
250
+ const surface = emptyLiveSurface();
251
+ surface.files.add('src/workos.ts'); // existing SDK
252
+ surface.files.add('src/organization-domains/interfaces/organization-domain.interface.ts');
253
+ surface.interfaces.set('OrganizationDomainState', {
254
+ filePath: 'src/organization-domains/interfaces/organization-domain.interface.ts',
255
+ fields: new Set(),
256
+ });
257
+ setActiveLiveSurface(surface);
258
+ try {
259
+ const ctxOwned: EmitterContext = {
260
+ ...ctx,
261
+ spec: ownedDomainSpec([stateEnum], [domainModel]),
262
+ emitterOptions: { ownedServices: ['OrganizationDomains'] },
263
+ } as EmitterContext;
264
+
265
+ const result = generateEnums([stateEnum], ctxOwned);
266
+ const enumFile = result.find(
267
+ (f) => f.path === 'src/organization-domains/interfaces/organization-domain-state.interface.ts',
268
+ );
269
+ expect(enumFile).toBeDefined();
270
+ expect(enumFile!.content).toContain('export const OrganizationDomainState = {');
271
+ expect(enumFile!.content).toContain('export type OrganizationDomainState =');
272
+ } finally {
273
+ setActiveLiveSurface(emptyLiveSurface());
274
+ }
275
+ });
276
+
277
+ it('keeps the skip for non-owned services whose enum genuinely lives elsewhere', () => {
278
+ const surface = emptyLiveSurface();
279
+ surface.files.add('src/workos.ts');
280
+ surface.interfaces.set('OrganizationDomainState', {
281
+ filePath: 'src/common/interfaces/organization-domain-state.interface.ts',
282
+ fields: new Set(),
283
+ });
284
+ setActiveLiveSurface(surface);
285
+ try {
286
+ const ctxNotOwned: EmitterContext = {
287
+ ...ctx,
288
+ spec: ownedDomainSpec([stateEnum], [domainModel]),
289
+ } as EmitterContext;
290
+
291
+ const result = generateEnums([stateEnum], ctxNotOwned);
292
+ expect(result).toHaveLength(0);
293
+ } finally {
294
+ setActiveLiveSurface(emptyLiveSurface());
295
+ }
296
+ });
297
+
298
+ it('emits the module, resolves the barrel export, and imports the name in the model file', () => {
299
+ // End-to-end shape of the OrganizationDomains failure: generated
300
+ // organization-domain.interface.ts used `OrganizationDomainState` with
301
+ // NO import, and interfaces/index.ts exported
302
+ // ./organization-domain-state.interface — a module no hook ever emitted.
303
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-enum-'));
304
+ try {
305
+ const ifaceDir = path.join(tmpRoot, 'src', 'organization-domains', 'interfaces');
306
+ fs.mkdirSync(ifaceDir, { recursive: true });
307
+ fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
308
+ fs.writeFileSync(
309
+ path.join(ifaceDir, 'organization-domain.interface.ts'),
310
+ [
311
+ "export type OrganizationDomainState = 'verified' | 'pending';",
312
+ '',
313
+ 'export interface OrganizationDomain {',
314
+ ' id: string;',
315
+ ' state: OrganizationDomainState;',
316
+ '}',
317
+ ].join('\n'),
318
+ );
319
+ execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
320
+ execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
321
+
322
+ const spec = ownedDomainSpec([stateEnum], [domainModel]);
323
+ const runCtx = {
324
+ ...ctx,
325
+ spec,
326
+ outputDir: tmpRoot,
327
+ emitterOptions: { ownedServices: ['OrganizationDomains'] },
328
+ } as EmitterContext;
329
+
330
+ const modelFiles = nodeEmitter.generateModels([domainModel], runCtx);
331
+ const enumFiles = nodeEmitter.generateEnums([stateEnum], runCtx);
332
+ const clientFiles = nodeEmitter.generateClient(spec, runCtx);
333
+
334
+ // The canonical union module IS emitted…
335
+ const enumPath = 'src/organization-domains/interfaces/organization-domain-state.interface.ts';
336
+ expect(enumFiles.some((f) => f.path === enumPath)).toBe(true);
337
+
338
+ // …the model file imports the name from it…
339
+ const modelFile = modelFiles.find(
340
+ (f) => f.path === 'src/organization-domains/interfaces/organization-domain.interface.ts',
341
+ );
342
+ expect(modelFile).toBeDefined();
343
+ expect(modelFile!.content).toContain(
344
+ "import type { OrganizationDomainState } from './organization-domain-state.interface';",
345
+ );
346
+
347
+ // …and the barrel export resolves to an emitted module instead of a
348
+ // phantom the import-invariant pass has to drop.
349
+ const barrel = clientFiles.find((f) => f.path === 'src/organization-domains/interfaces/index.ts');
350
+ expect(barrel).toBeDefined();
351
+ expect(barrel!.content).toContain("export * from './organization-domain-state.interface';");
352
+
353
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
354
+ try {
355
+ nodeEmitter.generateTests(spec, runCtx);
356
+ const dropped = warnSpy.mock.calls.filter((call) => String(call[0]).includes('dropped unresolvable'));
357
+ expect(dropped).toEqual([]);
358
+ } finally {
359
+ warnSpy.mockRestore();
360
+ }
361
+ } finally {
362
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
363
+ }
364
+ });
365
+ });