@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/{plugin-DuB1UozS.mjs → plugin-CpO8rePT.mjs} +1164 -490
- package/dist/plugin-CpO8rePT.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +7 -7
- package/src/node/enums.ts +17 -4
- package/src/node/index.ts +264 -4
- package/src/node/live-surface.ts +309 -0
- package/src/node/models.ts +69 -3
- package/src/node/naming.ts +204 -23
- package/src/node/resources.ts +39 -3
- package/src/node/utils.ts +140 -22
- package/test/node/enums.test.ts +239 -2
- package/test/node/live-surface.test.ts +771 -1
- package/test/node/models.test.ts +738 -3
- package/test/node/naming.test.ts +159 -0
- package/test/node/resources.test.ts +464 -0
- package/test/node/utils.test.ts +157 -2
- package/dist/plugin-DuB1UozS.mjs.map +0 -1
package/test/node/models.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import type { EmitterContext, ApiSpec, Model } from '@workos/oagen';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import type { EmitterContext, ApiSpec, Model, GeneratedFile } from '@workos/oagen';
|
|
3
3
|
import { defaultSdkBehavior } from '@workos/oagen';
|
|
4
4
|
import { generateModels, generateSerializers } from '../../src/node/models.js';
|
|
5
|
-
import { nodeEmitter } from '../../src/node/index.js';
|
|
5
|
+
import { nodeEmitter, enforceEmittedImportInvariant } from '../../src/node/index.js';
|
|
6
6
|
import { buildLiveSurface, emptyLiveSurface, setActiveLiveSurface } from '../../src/node/live-surface.js';
|
|
7
7
|
import { setBaselineInterfaceNames, setBaselineSerializedNames } from '../../src/node/naming.js';
|
|
8
8
|
import * as fs from 'node:fs';
|
|
@@ -152,6 +152,312 @@ describe('generateModels', () => {
|
|
|
152
152
|
expect(file.content).toContain('export interface PortalSessionsCreateResponseWire {');
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
+
it('re-derives enum-typed fields instead of copying a degraded baseline `any`', () => {
|
|
156
|
+
// Regression: a prior generation referenced inline-enum names before the
|
|
157
|
+
// enum files existed, so api-surface extraction typed the fields as `any`.
|
|
158
|
+
// On the next regen the baseline `any` must not shadow the enum name the
|
|
159
|
+
// emitter knows from the IR — otherwise `state: any` persists forever.
|
|
160
|
+
const models: Model[] = [
|
|
161
|
+
{
|
|
162
|
+
name: 'OrganizationDomain',
|
|
163
|
+
fields: [
|
|
164
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
165
|
+
{
|
|
166
|
+
name: 'state',
|
|
167
|
+
type: { kind: 'enum', name: 'OrganizationDomainState', values: ['pending', 'verified'] },
|
|
168
|
+
required: true,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'verification_strategy',
|
|
172
|
+
type: { kind: 'enum', name: 'OrganizationDomainVerificationStrategy', values: ['dns', 'manual'] },
|
|
173
|
+
required: true,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
const spec: ApiSpec = {
|
|
180
|
+
...makeSpec(models, [
|
|
181
|
+
{
|
|
182
|
+
name: 'OrganizationDomains',
|
|
183
|
+
operations: [
|
|
184
|
+
{
|
|
185
|
+
name: 'getOrganizationDomain',
|
|
186
|
+
httpMethod: 'get',
|
|
187
|
+
path: '/organization_domains/{id}',
|
|
188
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
189
|
+
queryParams: [],
|
|
190
|
+
headerParams: [],
|
|
191
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
192
|
+
errors: [],
|
|
193
|
+
injectIdempotencyKey: false,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
]),
|
|
198
|
+
enums: [
|
|
199
|
+
{
|
|
200
|
+
name: 'OrganizationDomainState',
|
|
201
|
+
values: [
|
|
202
|
+
{ name: 'PENDING', value: 'pending' },
|
|
203
|
+
{ name: 'VERIFIED', value: 'verified' },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'OrganizationDomainVerificationStrategy',
|
|
208
|
+
values: [
|
|
209
|
+
{ name: 'DNS', value: 'dns' },
|
|
210
|
+
{ name: 'MANUAL', value: 'manual' },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const ctxWithModels: EmitterContext = {
|
|
217
|
+
...ctx,
|
|
218
|
+
spec,
|
|
219
|
+
emitterOptions: { ownedServices: ['OrganizationDomains'] },
|
|
220
|
+
apiSurface: {
|
|
221
|
+
language: 'node',
|
|
222
|
+
extractedFrom: '/tmp/sdk',
|
|
223
|
+
extractedAt: '2026-06-09T00:00:00Z',
|
|
224
|
+
classes: {},
|
|
225
|
+
interfaces: {
|
|
226
|
+
OrganizationDomain: {
|
|
227
|
+
name: 'OrganizationDomain',
|
|
228
|
+
fields: {
|
|
229
|
+
id: { type: 'string', optional: false },
|
|
230
|
+
state: { type: 'any', optional: false },
|
|
231
|
+
verificationStrategy: { type: 'any', optional: false },
|
|
232
|
+
},
|
|
233
|
+
extends: [],
|
|
234
|
+
sourceFile: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
235
|
+
},
|
|
236
|
+
OrganizationDomainResponse: {
|
|
237
|
+
name: 'OrganizationDomainResponse',
|
|
238
|
+
fields: {
|
|
239
|
+
id: { type: 'string', optional: false },
|
|
240
|
+
state: { type: 'any', optional: false },
|
|
241
|
+
verification_strategy: { type: 'any', optional: false },
|
|
242
|
+
},
|
|
243
|
+
extends: [],
|
|
244
|
+
sourceFile: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
typeAliases: {},
|
|
248
|
+
enums: {},
|
|
249
|
+
exports: {},
|
|
250
|
+
} as any,
|
|
251
|
+
} as EmitterContext;
|
|
252
|
+
|
|
253
|
+
const result = generateModels(models, ctxWithModels);
|
|
254
|
+
const file = result.find((f) => f.path.endsWith('organization-domain.interface.ts'));
|
|
255
|
+
expect(file).toBeDefined();
|
|
256
|
+
|
|
257
|
+
// Domain interface re-derives the enum names from the IR.
|
|
258
|
+
expect(file!.content).toContain('state: OrganizationDomainState;');
|
|
259
|
+
expect(file!.content).toContain('verificationStrategy: OrganizationDomainVerificationStrategy;');
|
|
260
|
+
expect(file!.content).not.toContain(': any;');
|
|
261
|
+
|
|
262
|
+
// Wire interface too.
|
|
263
|
+
expect(file!.content).toContain('state: OrganizationDomainState;');
|
|
264
|
+
expect(file!.content).toContain('verification_strategy: OrganizationDomainVerificationStrategy;');
|
|
265
|
+
|
|
266
|
+
// And the imports are planned so the references resolve.
|
|
267
|
+
expect(file!.content).toContain(
|
|
268
|
+
"import type { OrganizationDomainState } from './organization-domain-state.interface';",
|
|
269
|
+
);
|
|
270
|
+
expect(file!.content).toContain(
|
|
271
|
+
"import type { OrganizationDomainVerificationStrategy } from './organization-domain-verification-strategy.interface';",
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('plans enum imports against the live-surface declaration path when it differs from the canonical one', () => {
|
|
276
|
+
// `generateEnums` skips emitting an enum whose declaration already lives
|
|
277
|
+
// elsewhere in the SDK (e.g. hand-written under src/common/interfaces).
|
|
278
|
+
// The interface emitter must point its import at that same location, not
|
|
279
|
+
// at the canonical per-service path that will never be emitted.
|
|
280
|
+
const surface = emptyLiveSurface();
|
|
281
|
+
surface.interfaces.set('OrganizationDomainState', {
|
|
282
|
+
filePath: 'src/common/interfaces/organization-domain-state.interface.ts',
|
|
283
|
+
fields: new Set(),
|
|
284
|
+
});
|
|
285
|
+
setActiveLiveSurface(surface);
|
|
286
|
+
try {
|
|
287
|
+
const models: Model[] = [
|
|
288
|
+
{
|
|
289
|
+
name: 'OrganizationDomain',
|
|
290
|
+
fields: [
|
|
291
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
292
|
+
{
|
|
293
|
+
name: 'state',
|
|
294
|
+
type: { kind: 'enum', name: 'OrganizationDomainState', values: ['pending', 'verified'] },
|
|
295
|
+
required: true,
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
const spec: ApiSpec = {
|
|
301
|
+
...makeSpec(models, [
|
|
302
|
+
{
|
|
303
|
+
name: 'OrganizationDomains',
|
|
304
|
+
operations: [
|
|
305
|
+
{
|
|
306
|
+
name: 'getOrganizationDomain',
|
|
307
|
+
httpMethod: 'get',
|
|
308
|
+
path: '/organization_domains/{id}',
|
|
309
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
310
|
+
queryParams: [],
|
|
311
|
+
headerParams: [],
|
|
312
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
313
|
+
errors: [],
|
|
314
|
+
injectIdempotencyKey: false,
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
},
|
|
318
|
+
]),
|
|
319
|
+
enums: [
|
|
320
|
+
{
|
|
321
|
+
name: 'OrganizationDomainState',
|
|
322
|
+
values: [
|
|
323
|
+
{ name: 'PENDING', value: 'pending' },
|
|
324
|
+
{ name: 'VERIFIED', value: 'verified' },
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
const ctxWithModels: EmitterContext = { ...ctx, spec };
|
|
330
|
+
const result = generateModels(models, ctxWithModels);
|
|
331
|
+
const file = result.find((f) => f.path.endsWith('organization-domain.interface.ts'));
|
|
332
|
+
expect(file?.content).toContain(
|
|
333
|
+
"import type { OrganizationDomainState } from '../../common/interfaces/organization-domain-state.interface';",
|
|
334
|
+
);
|
|
335
|
+
} finally {
|
|
336
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('does not preserve an owned-service enum inline next to its canonical import', () => {
|
|
341
|
+
// Companion to the owned-service enum emission fix (see enums.test.ts):
|
|
342
|
+
// once `generateEnums` emits the canonical module and this file imports
|
|
343
|
+
// the name, the targetDir preservation pass must not also copy the
|
|
344
|
+
// legacy inline declaration forward — `import type { X }` plus a local
|
|
345
|
+
// `export type X` is a TS2440 collision.
|
|
346
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-enum-preserve-'));
|
|
347
|
+
try {
|
|
348
|
+
const ifaceDir = path.join(tmpRoot, 'src', 'organization-domains', 'interfaces');
|
|
349
|
+
fs.mkdirSync(ifaceDir, { recursive: true });
|
|
350
|
+
fs.writeFileSync(
|
|
351
|
+
path.join(ifaceDir, 'organization-domain.interface.ts'),
|
|
352
|
+
[
|
|
353
|
+
"export type OrganizationDomainState = 'verified' | 'pending';",
|
|
354
|
+
'',
|
|
355
|
+
'export interface OrganizationDomain {',
|
|
356
|
+
' id: string;',
|
|
357
|
+
' state: OrganizationDomainState;',
|
|
358
|
+
'}',
|
|
359
|
+
].join('\n'),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const models: Model[] = [
|
|
363
|
+
{
|
|
364
|
+
name: 'OrganizationDomain',
|
|
365
|
+
fields: [
|
|
366
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
367
|
+
{
|
|
368
|
+
name: 'state',
|
|
369
|
+
type: { kind: 'enum', name: 'OrganizationDomainState', values: ['verified', 'pending'] },
|
|
370
|
+
required: true,
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
const spec: ApiSpec = {
|
|
376
|
+
...makeSpec(models, [
|
|
377
|
+
{
|
|
378
|
+
name: 'OrganizationDomains',
|
|
379
|
+
operations: [
|
|
380
|
+
{
|
|
381
|
+
name: 'getOrganizationDomain',
|
|
382
|
+
httpMethod: 'get',
|
|
383
|
+
path: '/organization_domains/{id}',
|
|
384
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
385
|
+
queryParams: [],
|
|
386
|
+
headerParams: [],
|
|
387
|
+
response: { kind: 'model', name: 'OrganizationDomain' },
|
|
388
|
+
errors: [],
|
|
389
|
+
injectIdempotencyKey: false,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
]),
|
|
394
|
+
enums: [
|
|
395
|
+
{
|
|
396
|
+
name: 'OrganizationDomainState',
|
|
397
|
+
values: [
|
|
398
|
+
{ name: 'VERIFIED', value: 'verified' },
|
|
399
|
+
{ name: 'PENDING', value: 'pending' },
|
|
400
|
+
],
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
};
|
|
404
|
+
const runCtx = {
|
|
405
|
+
...ctx,
|
|
406
|
+
spec,
|
|
407
|
+
targetDir: tmpRoot,
|
|
408
|
+
emitterOptions: { ownedServices: ['OrganizationDomains'] },
|
|
409
|
+
apiSurface: {
|
|
410
|
+
language: 'node',
|
|
411
|
+
extractedFrom: tmpRoot,
|
|
412
|
+
extractedAt: '2026-06-10T00:00:00Z',
|
|
413
|
+
classes: {},
|
|
414
|
+
interfaces: {
|
|
415
|
+
OrganizationDomain: {
|
|
416
|
+
name: 'OrganizationDomain',
|
|
417
|
+
fields: {
|
|
418
|
+
id: { type: 'string', optional: false },
|
|
419
|
+
state: { type: 'OrganizationDomainState', optional: false },
|
|
420
|
+
},
|
|
421
|
+
extends: [],
|
|
422
|
+
sourceFile: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
typeAliases: {
|
|
426
|
+
OrganizationDomainState: {
|
|
427
|
+
name: 'OrganizationDomainState',
|
|
428
|
+
value: "'verified' | 'pending'",
|
|
429
|
+
sourceFile: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
enums: {},
|
|
433
|
+
exports: {},
|
|
434
|
+
},
|
|
435
|
+
} as unknown as EmitterContext;
|
|
436
|
+
|
|
437
|
+
const surface = emptyLiveSurface();
|
|
438
|
+
surface.files.add('src/workos.ts');
|
|
439
|
+
surface.files.add('src/organization-domains/interfaces/organization-domain.interface.ts');
|
|
440
|
+
surface.interfaces.set('OrganizationDomainState', {
|
|
441
|
+
filePath: 'src/organization-domains/interfaces/organization-domain.interface.ts',
|
|
442
|
+
fields: new Set(),
|
|
443
|
+
});
|
|
444
|
+
setActiveLiveSurface(surface);
|
|
445
|
+
try {
|
|
446
|
+
const files = generateModels(models, runCtx);
|
|
447
|
+
const modelFile = files.find((f) => f.path.endsWith('organization-domain.interface.ts'));
|
|
448
|
+
expect(modelFile).toBeDefined();
|
|
449
|
+
expect(modelFile!.content).toContain(
|
|
450
|
+
"import type { OrganizationDomainState } from './organization-domain-state.interface';",
|
|
451
|
+
);
|
|
452
|
+
expect(modelFile!.content).not.toContain("export type OrganizationDomainState = 'verified' | 'pending';");
|
|
453
|
+
} finally {
|
|
454
|
+
setActiveLiveSurface(emptyLiveSurface());
|
|
455
|
+
}
|
|
456
|
+
} finally {
|
|
457
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
155
461
|
it('renders @deprecated on fields', () => {
|
|
156
462
|
const models: Model[] = [
|
|
157
463
|
{
|
|
@@ -647,3 +953,432 @@ describe('generateSerializers', () => {
|
|
|
647
953
|
expect(orgSerializer!.content).toContain('export const deserializeOrganization');
|
|
648
954
|
});
|
|
649
955
|
});
|
|
956
|
+
|
|
957
|
+
describe('owned-service dependency model emission', () => {
|
|
958
|
+
it('emits dependency models into the owned service directory instead of an unemittable one', () => {
|
|
959
|
+
// Real instance: the AuditLogs ownership pass generated audit-logs.ts
|
|
960
|
+
// importing `../organizations/interfaces/audit-logs-retention.interface`,
|
|
961
|
+
// but the retention models were assigned to the (non-owned) Organizations
|
|
962
|
+
// dir and therefore never emitted — an unresolvable import (TS2307).
|
|
963
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-dep-'));
|
|
964
|
+
try {
|
|
965
|
+
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
|
|
966
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
|
|
967
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
968
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
969
|
+
|
|
970
|
+
const models: Model[] = [
|
|
971
|
+
{
|
|
972
|
+
name: 'AuditLogsRetention',
|
|
973
|
+
fields: [{ name: 'retention_period_in_days', type: { kind: 'primitive', type: 'integer' }, required: true }],
|
|
974
|
+
},
|
|
975
|
+
];
|
|
976
|
+
const retentionOp = (name: string, opPath: string) => ({
|
|
977
|
+
name,
|
|
978
|
+
httpMethod: 'get' as const,
|
|
979
|
+
path: opPath,
|
|
980
|
+
pathParams: [],
|
|
981
|
+
queryParams: [],
|
|
982
|
+
headerParams: [],
|
|
983
|
+
response: { kind: 'model' as const, name: 'AuditLogsRetention' },
|
|
984
|
+
errors: [],
|
|
985
|
+
injectIdempotencyKey: false,
|
|
986
|
+
});
|
|
987
|
+
const spec = makeSpec(models, [
|
|
988
|
+
{ name: 'Organizations', operations: [retentionOp('getRetention', '/organizations/{id}/retention')] },
|
|
989
|
+
{ name: 'AuditLogs', operations: [retentionOp('getAuditLogsRetention', '/audit_logs/retention')] },
|
|
990
|
+
]);
|
|
991
|
+
|
|
992
|
+
const files = nodeEmitter.generateModels(models, {
|
|
993
|
+
...ctx,
|
|
994
|
+
spec,
|
|
995
|
+
outputDir: tmpRoot,
|
|
996
|
+
emitterOptions: { ownedServices: ['AuditLogs'] },
|
|
997
|
+
} as EmitterContext);
|
|
998
|
+
|
|
999
|
+
// The dependency model lands in the importing (owned) service's dir…
|
|
1000
|
+
expect(files.some((f) => f.path === 'src/audit-logs/interfaces/audit-logs-retention.interface.ts')).toBe(true);
|
|
1001
|
+
// …and nothing is planned for the unemittable organizations dir.
|
|
1002
|
+
expect(files.some((f) => f.path.startsWith('src/organizations/'))).toBe(false);
|
|
1003
|
+
} finally {
|
|
1004
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('emits interfaces AND serializers for the full closure of ops re-mounted onto an owned service', () => {
|
|
1009
|
+
// Real instance (AuditLogs rebuild): GET/PUT
|
|
1010
|
+
// /organizations/{organizationId}/audit_logs_retention live on the IR
|
|
1011
|
+
// Organizations service but are MOUNTED on AuditLogs via
|
|
1012
|
+
// resolvedOperations. Walking only IR services missed them, so
|
|
1013
|
+
// `AuditLogsRetention` / `UpdateAuditLogsRetention` stayed assigned to
|
|
1014
|
+
// the (non-owned) Organizations dir: their interfaces and serializers
|
|
1015
|
+
// were never emitted ANYWHERE, while the generated retention methods
|
|
1016
|
+
// referenced `deserializeAuditLogsRetention` /
|
|
1017
|
+
// `serializeUpdateAuditLogsRetention` — imports the invariant pass then
|
|
1018
|
+
// had to drop, leaving non-compiling method bodies.
|
|
1019
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-owned-mount-dep-'));
|
|
1020
|
+
try {
|
|
1021
|
+
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
|
|
1022
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
|
|
1023
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
1024
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
1025
|
+
|
|
1026
|
+
const models: Model[] = [
|
|
1027
|
+
{
|
|
1028
|
+
name: 'AuditLogsRetention',
|
|
1029
|
+
fields: [
|
|
1030
|
+
{ name: 'retention_period_in_days', type: { kind: 'primitive', type: 'integer' }, required: true },
|
|
1031
|
+
// Nested dependency: the closure must not stop at the
|
|
1032
|
+
// directly-referenced model.
|
|
1033
|
+
{ name: 'policy', type: { kind: 'model', name: 'RetentionPolicy' }, required: true },
|
|
1034
|
+
],
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
name: 'RetentionPolicy',
|
|
1038
|
+
fields: [{ name: 'kind', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
name: 'UpdateAuditLogsRetention',
|
|
1042
|
+
fields: [{ name: 'retention_period_in_days', type: { kind: 'primitive', type: 'integer' }, required: true }],
|
|
1043
|
+
},
|
|
1044
|
+
];
|
|
1045
|
+
const listOrgsOp = {
|
|
1046
|
+
name: 'listOrganizations',
|
|
1047
|
+
httpMethod: 'get' as const,
|
|
1048
|
+
path: '/organizations',
|
|
1049
|
+
pathParams: [],
|
|
1050
|
+
queryParams: [],
|
|
1051
|
+
headerParams: [],
|
|
1052
|
+
response: { kind: 'primitive' as const, type: 'unknown' as const },
|
|
1053
|
+
errors: [],
|
|
1054
|
+
injectIdempotencyKey: false,
|
|
1055
|
+
};
|
|
1056
|
+
const orgIdParam = {
|
|
1057
|
+
name: 'organizationId',
|
|
1058
|
+
type: { kind: 'primitive' as const, type: 'string' as const },
|
|
1059
|
+
required: true,
|
|
1060
|
+
};
|
|
1061
|
+
const getRetentionOp = {
|
|
1062
|
+
name: 'getAuditLogsRetention',
|
|
1063
|
+
httpMethod: 'get' as const,
|
|
1064
|
+
path: '/organizations/{organizationId}/audit_logs_retention',
|
|
1065
|
+
pathParams: [orgIdParam],
|
|
1066
|
+
queryParams: [],
|
|
1067
|
+
headerParams: [],
|
|
1068
|
+
response: { kind: 'model' as const, name: 'AuditLogsRetention' },
|
|
1069
|
+
errors: [],
|
|
1070
|
+
injectIdempotencyKey: false,
|
|
1071
|
+
};
|
|
1072
|
+
const updateRetentionOp = {
|
|
1073
|
+
name: 'updateAuditLogsRetention',
|
|
1074
|
+
httpMethod: 'put' as const,
|
|
1075
|
+
path: '/organizations/{organizationId}/audit_logs_retention',
|
|
1076
|
+
pathParams: [orgIdParam],
|
|
1077
|
+
queryParams: [],
|
|
1078
|
+
headerParams: [],
|
|
1079
|
+
requestBody: { kind: 'model' as const, name: 'UpdateAuditLogsRetention' },
|
|
1080
|
+
response: { kind: 'model' as const, name: 'AuditLogsRetention' },
|
|
1081
|
+
errors: [],
|
|
1082
|
+
injectIdempotencyKey: false,
|
|
1083
|
+
};
|
|
1084
|
+
const orgService = { name: 'Organizations', operations: [listOrgsOp, getRetentionOp, updateRetentionOp] };
|
|
1085
|
+
const spec = { ...emptySpec, models, services: [orgService] };
|
|
1086
|
+
const resolved = (operation: unknown, methodName: string, mountOn: string) => ({
|
|
1087
|
+
operation,
|
|
1088
|
+
service: orgService,
|
|
1089
|
+
methodName,
|
|
1090
|
+
mountOn,
|
|
1091
|
+
defaults: {},
|
|
1092
|
+
inferFromClient: [],
|
|
1093
|
+
urlBuilder: false,
|
|
1094
|
+
});
|
|
1095
|
+
const runCtx = {
|
|
1096
|
+
...ctx,
|
|
1097
|
+
spec,
|
|
1098
|
+
outputDir: tmpRoot,
|
|
1099
|
+
emitterOptions: { ownedServices: ['AuditLogs'] },
|
|
1100
|
+
resolvedOperations: [
|
|
1101
|
+
resolved(listOrgsOp, 'list_organizations', 'Organizations'),
|
|
1102
|
+
resolved(getRetentionOp, 'get_audit_logs_retention', 'AuditLogs'),
|
|
1103
|
+
resolved(updateRetentionOp, 'update_audit_logs_retention', 'AuditLogs'),
|
|
1104
|
+
],
|
|
1105
|
+
} as unknown as EmitterContext;
|
|
1106
|
+
|
|
1107
|
+
const modelFiles = nodeEmitter.generateModels(models, runCtx);
|
|
1108
|
+
const paths = modelFiles.map((f) => f.path);
|
|
1109
|
+
|
|
1110
|
+
// Interfaces for M, its nested dependency N, and the request body —
|
|
1111
|
+
// all in the owned service's dir.
|
|
1112
|
+
expect(paths).toContain('src/audit-logs/interfaces/audit-logs-retention.interface.ts');
|
|
1113
|
+
expect(paths).toContain('src/audit-logs/interfaces/retention-policy.interface.ts');
|
|
1114
|
+
expect(paths).toContain('src/audit-logs/interfaces/update-audit-logs-retention.interface.ts');
|
|
1115
|
+
// …and serializer emission follows the re-homed assignment.
|
|
1116
|
+
expect(paths).toContain('src/audit-logs/serializers/audit-logs-retention.serializer.ts');
|
|
1117
|
+
expect(paths).toContain('src/audit-logs/serializers/retention-policy.serializer.ts');
|
|
1118
|
+
expect(paths).toContain('src/audit-logs/serializers/update-audit-logs-retention.serializer.ts');
|
|
1119
|
+
expect(paths.some((p) => p.startsWith('src/organizations/'))).toBe(false);
|
|
1120
|
+
|
|
1121
|
+
// The resource's serializer/interface imports must resolve: run the
|
|
1122
|
+
// remaining hooks so the final whole-run import-invariant pass sees
|
|
1123
|
+
// everything, and assert it drops nothing.
|
|
1124
|
+
const resourceFiles = nodeEmitter.generateResources(spec.services, runCtx);
|
|
1125
|
+
const resourceFile = resourceFiles.find((f) => f.path === 'src/audit-logs/audit-logs.ts');
|
|
1126
|
+
expect(resourceFile).toBeDefined();
|
|
1127
|
+
expect(resourceFile!.content).toContain('deserializeAuditLogsRetention');
|
|
1128
|
+
expect(resourceFile!.content).toContain('serializeUpdateAuditLogsRetention');
|
|
1129
|
+
|
|
1130
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
1131
|
+
try {
|
|
1132
|
+
nodeEmitter.generateTests(spec, runCtx);
|
|
1133
|
+
const dropped = warnSpy.mock.calls.filter((call) => String(call[0]).includes('dropped unresolvable'));
|
|
1134
|
+
expect(dropped).toEqual([]);
|
|
1135
|
+
} finally {
|
|
1136
|
+
warnSpy.mockRestore();
|
|
1137
|
+
}
|
|
1138
|
+
const emittedPaths = new Set([...modelFiles, ...resourceFiles].map((f) => f.path));
|
|
1139
|
+
const resolvable = (relPath: string) => emittedPaths.has(relPath) || fs.existsSync(path.join(tmpRoot, relPath));
|
|
1140
|
+
for (const importMatch of resourceFile!.content.matchAll(/from '(\.[^']+)'/g)) {
|
|
1141
|
+
const resolvedPath = path.posix.normalize(path.posix.join('src/audit-logs', importMatch[1]));
|
|
1142
|
+
const candidates = [`${resolvedPath}.ts`, `${resolvedPath}/index.ts`];
|
|
1143
|
+
expect(
|
|
1144
|
+
candidates.some(resolvable),
|
|
1145
|
+
`resource import '${importMatch[1]}' resolves to an emitted or on-disk file`,
|
|
1146
|
+
).toBe(true);
|
|
1147
|
+
}
|
|
1148
|
+
} finally {
|
|
1149
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
describe('enforceEmittedImportInvariant', () => {
|
|
1155
|
+
it('rewrites serializer imports to the legacy on-disk file exporting the function', () => {
|
|
1156
|
+
// Real instance: audit-logs.ts imported the canonical (never-emitted)
|
|
1157
|
+
// `./serializers/audit-log-schema-input.serializer` while the function
|
|
1158
|
+
// lives in a legacy hand serializer under a different filename.
|
|
1159
|
+
const surface = emptyLiveSurface();
|
|
1160
|
+
surface.files.add('src/audit-logs/serializers/audit-log-schema.serializer.ts');
|
|
1161
|
+
surface.functions.set('serializeAuditLogSchemaInput', 'src/audit-logs/serializers/audit-log-schema.serializer.ts');
|
|
1162
|
+
|
|
1163
|
+
const file: GeneratedFile = {
|
|
1164
|
+
path: 'src/audit-logs/audit-logs.ts',
|
|
1165
|
+
content: [
|
|
1166
|
+
"import { serializeAuditLogSchemaInput } from './serializers/audit-log-schema-input.serializer';",
|
|
1167
|
+
'',
|
|
1168
|
+
'export class AuditLogs {',
|
|
1169
|
+
' use = serializeAuditLogSchemaInput;',
|
|
1170
|
+
'}',
|
|
1171
|
+
].join('\n'),
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const warnings = enforceEmittedImportInvariant([file], new Set([file.path]), surface);
|
|
1175
|
+
expect(warnings).toEqual([]);
|
|
1176
|
+
expect(file.content).toContain(
|
|
1177
|
+
"import { serializeAuditLogSchemaInput } from './serializers/audit-log-schema.serializer';",
|
|
1178
|
+
);
|
|
1179
|
+
expect(file.content).not.toContain('audit-log-schema-input.serializer');
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it('rewrites barrel re-exports to the on-disk declaration of the symbol', () => {
|
|
1183
|
+
// Real instance: admin-portal's interfaces/index.ts exported the
|
|
1184
|
+
// module-local `./generate-link-intent.interface`, but the enum lives in
|
|
1185
|
+
// src/common/interfaces and the module-local file is never emitted.
|
|
1186
|
+
const surface = emptyLiveSurface();
|
|
1187
|
+
surface.files.add('src/common/interfaces/generate-link-intent.interface.ts');
|
|
1188
|
+
surface.interfaces.set('GenerateLinkIntent', {
|
|
1189
|
+
filePath: 'src/common/interfaces/generate-link-intent.interface.ts',
|
|
1190
|
+
fields: new Set(),
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
const barrel: GeneratedFile = {
|
|
1194
|
+
path: 'src/admin-portal/interfaces/index.ts',
|
|
1195
|
+
content: "export * from './generate-link-intent.interface';\n",
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
const warnings = enforceEmittedImportInvariant([barrel], new Set([barrel.path]), surface);
|
|
1199
|
+
expect(warnings).toEqual([]);
|
|
1200
|
+
expect(barrel.content).toContain("export * from '../../common/interfaces/generate-link-intent.interface';");
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it('drops barrel exports whose target exists nowhere, with a warning', () => {
|
|
1204
|
+
// Real instance: the MultiFactorAuth barrel exported
|
|
1205
|
+
// `./authentication-challenge.interface` — never emitted, not on disk.
|
|
1206
|
+
const surface = emptyLiveSurface();
|
|
1207
|
+
|
|
1208
|
+
const barrel: GeneratedFile = {
|
|
1209
|
+
path: 'src/mfa/interfaces/index.ts',
|
|
1210
|
+
content: [
|
|
1211
|
+
"export * from './factor.interface';",
|
|
1212
|
+
"export * from './authentication-challenge.interface';",
|
|
1213
|
+
'',
|
|
1214
|
+
].join('\n'),
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
const warnings = enforceEmittedImportInvariant(
|
|
1218
|
+
[barrel],
|
|
1219
|
+
new Set([barrel.path, 'src/mfa/interfaces/factor.interface.ts']),
|
|
1220
|
+
surface,
|
|
1221
|
+
);
|
|
1222
|
+
expect(barrel.content).toContain("export * from './factor.interface';");
|
|
1223
|
+
expect(barrel.content).not.toContain('authentication-challenge');
|
|
1224
|
+
expect(warnings).toHaveLength(1);
|
|
1225
|
+
expect(warnings[0]).toContain('authentication-challenge.interface');
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
it('leaves imports that resolve to same-run emissions or on-disk files untouched', () => {
|
|
1229
|
+
const surface = emptyLiveSurface();
|
|
1230
|
+
surface.files.add('src/workos.ts');
|
|
1231
|
+
surface.files.add('src/common/interfaces/pagination-options.interface.ts');
|
|
1232
|
+
|
|
1233
|
+
const content = [
|
|
1234
|
+
"import type { WorkOS } from '../workos';",
|
|
1235
|
+
"import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';",
|
|
1236
|
+
"import type { Widget } from './interfaces/widget.interface';",
|
|
1237
|
+
'',
|
|
1238
|
+
'export class Widgets {}',
|
|
1239
|
+
].join('\n');
|
|
1240
|
+
const file: GeneratedFile = { path: 'src/widgets/widgets.ts', content };
|
|
1241
|
+
|
|
1242
|
+
const warnings = enforceEmittedImportInvariant(
|
|
1243
|
+
[file],
|
|
1244
|
+
new Set([file.path, 'src/widgets/interfaces/widget.interface.ts']),
|
|
1245
|
+
surface,
|
|
1246
|
+
);
|
|
1247
|
+
expect(warnings).toEqual([]);
|
|
1248
|
+
expect(file.content).toBe(content);
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
it('splits an unresolvable import whose symbols live in different existing files', () => {
|
|
1252
|
+
const surface = emptyLiveSurface();
|
|
1253
|
+
surface.files.add('src/audit-logs/serializers/audit-log-event.serializer.ts');
|
|
1254
|
+
surface.files.add('src/audit-logs/serializers/audit-log-export.serializer.ts');
|
|
1255
|
+
surface.functions.set('serializeEvent', 'src/audit-logs/serializers/audit-log-event.serializer.ts');
|
|
1256
|
+
surface.functions.set('deserializeExport', 'src/audit-logs/serializers/audit-log-export.serializer.ts');
|
|
1257
|
+
|
|
1258
|
+
const file: GeneratedFile = {
|
|
1259
|
+
path: 'src/audit-logs/audit-logs.ts',
|
|
1260
|
+
content: [
|
|
1261
|
+
"import { serializeEvent, deserializeExport } from './serializers/combined.serializer';",
|
|
1262
|
+
'',
|
|
1263
|
+
'export const x = [serializeEvent, deserializeExport];',
|
|
1264
|
+
].join('\n'),
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const warnings = enforceEmittedImportInvariant([file], new Set([file.path]), surface);
|
|
1268
|
+
expect(warnings).toEqual([]);
|
|
1269
|
+
expect(file.content).toContain("import { serializeEvent } from './serializers/audit-log-event.serializer';");
|
|
1270
|
+
expect(file.content).toContain("import { deserializeExport } from './serializers/audit-log-export.serializer';");
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
it('preserves the relocatable symbols of a clause when only some are missing', () => {
|
|
1274
|
+
// A clause mixing a relocatable symbol with a genuinely-missing one must
|
|
1275
|
+
// still emit the import for the relocatable symbol; dropping the whole
|
|
1276
|
+
// clause would fail the resolvable symbol with TS2305 at its usage site.
|
|
1277
|
+
const surface = emptyLiveSurface();
|
|
1278
|
+
surface.files.add('src/audit-logs/serializers/audit-log-event.serializer.ts');
|
|
1279
|
+
surface.functions.set('serializeEvent', 'src/audit-logs/serializers/audit-log-event.serializer.ts');
|
|
1280
|
+
|
|
1281
|
+
const file: GeneratedFile = {
|
|
1282
|
+
path: 'src/audit-logs/audit-logs.ts',
|
|
1283
|
+
content: [
|
|
1284
|
+
"import { serializeEvent, serializeGhost } from './serializers/combined.serializer';",
|
|
1285
|
+
'',
|
|
1286
|
+
'export const x = [serializeEvent, serializeGhost];',
|
|
1287
|
+
].join('\n'),
|
|
1288
|
+
};
|
|
1289
|
+
|
|
1290
|
+
const warnings = enforceEmittedImportInvariant([file], new Set([file.path]), surface);
|
|
1291
|
+
expect(file.content).toContain("import { serializeEvent } from './serializers/audit-log-event.serializer';");
|
|
1292
|
+
expect(file.content).not.toContain('combined.serializer');
|
|
1293
|
+
// The missing symbol is dropped from the import but left in the body so it
|
|
1294
|
+
// fails at its usage site, not as a phantom-module error.
|
|
1295
|
+
expect(file.content).not.toMatch(/import[^\n]*serializeGhost/);
|
|
1296
|
+
expect(file.content).toContain('export const x = [serializeEvent, serializeGhost];');
|
|
1297
|
+
expect(warnings).toHaveLength(1);
|
|
1298
|
+
expect(warnings[0]).toContain('serializeGhost');
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
it('runs as a final pass over all files emitted during the run (wired via generateTests)', () => {
|
|
1302
|
+
// Stale api-surface scenario: the baseline claims a dependency interface
|
|
1303
|
+
// lives at a sourceFile that is not on disk (and is never emitted). The
|
|
1304
|
+
// planned import would be unresolvable; the end-of-run pass must repair
|
|
1305
|
+
// or drop it — across emitter hooks, since imports are planned in one
|
|
1306
|
+
// hook and the dependency may be (not) emitted in another.
|
|
1307
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-import-invariant-'));
|
|
1308
|
+
try {
|
|
1309
|
+
fs.mkdirSync(path.join(tmpRoot, 'src'), { recursive: true });
|
|
1310
|
+
fs.writeFileSync(path.join(tmpRoot, 'src', 'workos.ts'), 'export class WorkOS {}\n');
|
|
1311
|
+
execFileSync('git', ['init'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
1312
|
+
execFileSync('git', ['add', 'src'], { cwd: tmpRoot, stdio: 'ignore' });
|
|
1313
|
+
|
|
1314
|
+
const models: Model[] = [
|
|
1315
|
+
{
|
|
1316
|
+
name: 'Widget',
|
|
1317
|
+
fields: [
|
|
1318
|
+
{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true },
|
|
1319
|
+
{ name: 'part', type: { kind: 'model', name: 'WidgetPart' }, required: false },
|
|
1320
|
+
],
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
name: 'WidgetPart',
|
|
1324
|
+
fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1325
|
+
},
|
|
1326
|
+
];
|
|
1327
|
+
const spec = makeSpec(models, [
|
|
1328
|
+
{
|
|
1329
|
+
name: 'Widgets',
|
|
1330
|
+
operations: [
|
|
1331
|
+
{
|
|
1332
|
+
name: 'getWidget',
|
|
1333
|
+
httpMethod: 'get',
|
|
1334
|
+
path: '/widgets/{id}',
|
|
1335
|
+
pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
|
|
1336
|
+
queryParams: [],
|
|
1337
|
+
headerParams: [],
|
|
1338
|
+
response: { kind: 'model', name: 'Widget' },
|
|
1339
|
+
errors: [],
|
|
1340
|
+
injectIdempotencyKey: false,
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
},
|
|
1344
|
+
]);
|
|
1345
|
+
const runCtx = {
|
|
1346
|
+
...ctx,
|
|
1347
|
+
spec,
|
|
1348
|
+
outputDir: tmpRoot,
|
|
1349
|
+
emitterOptions: { ownedServices: ['Widgets'] },
|
|
1350
|
+
apiSurface: {
|
|
1351
|
+
language: 'node',
|
|
1352
|
+
extractedFrom: tmpRoot,
|
|
1353
|
+
extractedAt: '2026-06-09T00:00:00Z',
|
|
1354
|
+
classes: {},
|
|
1355
|
+
interfaces: {
|
|
1356
|
+
// Stale: this sourceFile does not exist on disk.
|
|
1357
|
+
WidgetPart: {
|
|
1358
|
+
name: 'WidgetPart',
|
|
1359
|
+
fields: { id: { type: 'string', optional: false } },
|
|
1360
|
+
extends: [],
|
|
1361
|
+
sourceFile: 'src/parts/interfaces/widget-part.interface.ts',
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
typeAliases: {},
|
|
1365
|
+
enums: {},
|
|
1366
|
+
exports: {},
|
|
1367
|
+
} as any,
|
|
1368
|
+
} as EmitterContext;
|
|
1369
|
+
|
|
1370
|
+
const modelFiles = nodeEmitter.generateModels(models, runCtx);
|
|
1371
|
+
const widgetFile = modelFiles.find((f) => f.path === 'src/widgets/interfaces/widget.interface.ts');
|
|
1372
|
+
expect(widgetFile).toBeDefined();
|
|
1373
|
+
// Import planned against the stale baseline path…
|
|
1374
|
+
expect(widgetFile!.content).toContain('../../parts/interfaces/widget-part.interface');
|
|
1375
|
+
|
|
1376
|
+
nodeEmitter.generateTests(spec, runCtx);
|
|
1377
|
+
|
|
1378
|
+
// …must not survive the end-of-run invariant pass.
|
|
1379
|
+
expect(widgetFile!.content).not.toContain('../../parts/interfaces/widget-part.interface');
|
|
1380
|
+
} finally {
|
|
1381
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1382
|
+
}
|
|
1383
|
+
});
|
|
1384
|
+
});
|