@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/src/node/live-surface.ts
CHANGED
|
@@ -324,6 +324,315 @@ const METHOD_KEYWORD_BLOCKLIST = new Set([
|
|
|
324
324
|
'constructor', // tracked separately if needed
|
|
325
325
|
]);
|
|
326
326
|
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Partial-emission merge
|
|
329
|
+
//
|
|
330
|
+
// For a service that is NOT owned and NOT a missing-service adoption,
|
|
331
|
+
// `resources.ts` intentionally emits a PARTIAL resource class: operations
|
|
332
|
+
// already covered by the baseline class are filtered out, leaving only the
|
|
333
|
+
// new methods. Overwriting the on-disk file with that partial class would
|
|
334
|
+
// delete every existing public method (this deleted four methods from
|
|
335
|
+
// workos-node's api-keys.ts when the spec gained one operation). The helpers
|
|
336
|
+
// below let `applyLiveSurface` detect that case and merge instead: keep the
|
|
337
|
+
// existing file text verbatim and append only the generated-only methods
|
|
338
|
+
// (plus any imports they need).
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
interface ParsedClassLines {
|
|
342
|
+
name: string;
|
|
343
|
+
/** Line index of the `export class` declaration. */
|
|
344
|
+
declLine: number;
|
|
345
|
+
/** Line index of the line containing the class body's closing brace. */
|
|
346
|
+
closeLine: number;
|
|
347
|
+
/** Method names declared directly on the class body, in source order. */
|
|
348
|
+
methods: string[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Class members sit at exactly two-space indent in both generated output and
|
|
352
|
+
// prettier-formatted SDK files; deeper-indented lines are method bodies.
|
|
353
|
+
const CLASS_MEMBER_RE =
|
|
354
|
+
/^ {2}(?:(?:public|private|protected|readonly|static)\s+)*(?:async\s+)?([a-zA-Z_$][\w$]*)\s*(?:<[^>]*>)?\s*\(/;
|
|
355
|
+
|
|
356
|
+
interface BraceScan {
|
|
357
|
+
/** Net code-brace balance accumulated so far. */
|
|
358
|
+
depth: number;
|
|
359
|
+
/** Whether any code `{` has been seen — distinguishes "not yet opened" from "balanced and closed". */
|
|
360
|
+
opened: boolean;
|
|
361
|
+
/** Whether we are mid `/* … */` block comment (spans lines). */
|
|
362
|
+
inBlockComment: boolean;
|
|
363
|
+
/**
|
|
364
|
+
* Interpolation-brace depth for each active template literal; a non-empty
|
|
365
|
+
* stack means we are inside a template, and the top being 0 means we are in
|
|
366
|
+
* its raw text (vs. inside a `${ … }` interpolation).
|
|
367
|
+
*/
|
|
368
|
+
templates: number[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Advance a brace-balance scan across one `line`, mutating `s`. Counts only
|
|
373
|
+
* braces that live in *code* — including template `${ … }` interpolation,
|
|
374
|
+
* whose braces are self-balancing — and ignores braces inside string/template
|
|
375
|
+
* text, line comments, and block comments. State threads across lines so block
|
|
376
|
+
* comments and multi-line template literals are tracked correctly.
|
|
377
|
+
*
|
|
378
|
+
* Naive character counting (the prior approach) would clip a method block at a
|
|
379
|
+
* `}` that merely sat inside a comment (`// returns when done }`) or a string
|
|
380
|
+
* (`'closing brace: }'`), appending a truncated, unbalanced body. Regex
|
|
381
|
+
* literals are intentionally not modelled: distinguishing `/` division from a
|
|
382
|
+
* regex needs full tokenization, and a regex whose braces are net-unbalanced
|
|
383
|
+
* does not occur in generated or hand-edited SDK code (`{n,m}` quantifiers are
|
|
384
|
+
* always balanced).
|
|
385
|
+
*/
|
|
386
|
+
function advanceBraceScan(line: string, s: BraceScan): void {
|
|
387
|
+
for (let i = 0; i < line.length; i++) {
|
|
388
|
+
const c = line[i];
|
|
389
|
+
if (s.inBlockComment) {
|
|
390
|
+
if (c === '*' && line[i + 1] === '/') {
|
|
391
|
+
s.inBlockComment = false;
|
|
392
|
+
i++;
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const tdepth = s.templates.length;
|
|
397
|
+
if (tdepth > 0 && s.templates[tdepth - 1] === 0) {
|
|
398
|
+
// Raw template text: only an escape, a closing backtick, or the start of
|
|
399
|
+
// an interpolation matters; everything else (braces included) is literal.
|
|
400
|
+
if (c === '\\') {
|
|
401
|
+
i++;
|
|
402
|
+
} else if (c === '`') {
|
|
403
|
+
s.templates.pop();
|
|
404
|
+
} else if (c === '$' && line[i + 1] === '{') {
|
|
405
|
+
s.templates[tdepth - 1] = 1;
|
|
406
|
+
s.depth++;
|
|
407
|
+
s.opened = true;
|
|
408
|
+
i++;
|
|
409
|
+
}
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
// Code context: top level, or inside a template interpolation.
|
|
413
|
+
if (c === '/' && line[i + 1] === '/') break; // line comment runs to EOL
|
|
414
|
+
if (c === '/' && line[i + 1] === '*') {
|
|
415
|
+
s.inBlockComment = true;
|
|
416
|
+
i++;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (c === "'" || c === '"') {
|
|
420
|
+
const quote = c;
|
|
421
|
+
i++;
|
|
422
|
+
while (i < line.length && line[i] !== quote) {
|
|
423
|
+
if (line[i] === '\\') i++;
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (c === '`') {
|
|
429
|
+
s.templates.push(0);
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (c === '{') {
|
|
433
|
+
s.depth++;
|
|
434
|
+
s.opened = true;
|
|
435
|
+
if (tdepth > 0) s.templates[tdepth - 1]++;
|
|
436
|
+
} else if (c === '}') {
|
|
437
|
+
s.depth--;
|
|
438
|
+
if (tdepth > 0) s.templates[tdepth - 1]--;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function parseClassesByLine(lines: string[]): ParsedClassLines[] {
|
|
444
|
+
const classes: ParsedClassLines[] = [];
|
|
445
|
+
for (let i = 0; i < lines.length; i++) {
|
|
446
|
+
const decl = lines[i].match(/^export\s+(?:abstract\s+)?class\s+([A-Z][\w$]*)/);
|
|
447
|
+
if (!decl) continue;
|
|
448
|
+
|
|
449
|
+
const scan: BraceScan = { depth: 0, opened: false, inBlockComment: false, templates: [] };
|
|
450
|
+
let closeLine = -1;
|
|
451
|
+
for (let j = i; j < lines.length; j++) {
|
|
452
|
+
advanceBraceScan(lines[j], scan);
|
|
453
|
+
if (scan.opened && scan.depth <= 0) {
|
|
454
|
+
closeLine = j;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (closeLine < 0) continue;
|
|
459
|
+
|
|
460
|
+
const methods: string[] = [];
|
|
461
|
+
for (let j = i + 1; j < closeLine; j++) {
|
|
462
|
+
const member = lines[j].match(CLASS_MEMBER_RE);
|
|
463
|
+
if (!member) continue;
|
|
464
|
+
const name = member[1];
|
|
465
|
+
if (name === 'constructor' || METHOD_KEYWORD_BLOCKLIST.has(name)) continue;
|
|
466
|
+
if (!methods.includes(name)) methods.push(name);
|
|
467
|
+
}
|
|
468
|
+
classes.push({ name: decl[1], declLine: i, closeLine, methods });
|
|
469
|
+
i = closeLine;
|
|
470
|
+
}
|
|
471
|
+
return classes;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Extract the full source block of `method` (attached comments included) from
|
|
476
|
+
* a parsed class. Returns null if the block cannot be delimited.
|
|
477
|
+
*/
|
|
478
|
+
function extractMethodBlock(lines: string[], cls: ParsedClassLines, method: string): string[] | null {
|
|
479
|
+
for (let j = cls.declLine + 1; j < cls.closeLine; j++) {
|
|
480
|
+
const member = lines[j].match(CLASS_MEMBER_RE);
|
|
481
|
+
if (!member || member[1] !== method) continue;
|
|
482
|
+
|
|
483
|
+
// Pull in the contiguous comment block directly above the signature.
|
|
484
|
+
let start = j;
|
|
485
|
+
while (start > cls.declLine + 1) {
|
|
486
|
+
const above = lines[start - 1].trim();
|
|
487
|
+
if (above.startsWith('//') || above.startsWith('/*') || above.startsWith('*')) start--;
|
|
488
|
+
else break;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The member ends where the brace depth opened by its signature returns to
|
|
492
|
+
// zero. `advanceBraceScan` counts only code braces — ignoring those inside
|
|
493
|
+
// strings, template text, and comments — so it is robust to inner closures,
|
|
494
|
+
// object literals, and template interpolation nested at any indentation, as
|
|
495
|
+
// well as a stray `}` in a comment or string. The previous "first
|
|
496
|
+
// two-space-indented `}`" heuristic clipped the block at a nested brace at
|
|
497
|
+
// method-close indentation; raw character counting clipped it at a `}` that
|
|
498
|
+
// merely sat in a comment or string. Either appends a truncated body and
|
|
499
|
+
// orphans the real closing brace.
|
|
500
|
+
const scan: BraceScan = { depth: 0, opened: false, inBlockComment: false, templates: [] };
|
|
501
|
+
for (let end = j; end < cls.closeLine; end++) {
|
|
502
|
+
advanceBraceScan(lines[end], scan);
|
|
503
|
+
if (scan.opened && scan.depth <= 0) return lines.slice(start, end + 1);
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Multi-line tolerant: prettier wraps long named-import lists across lines.
|
|
511
|
+
const IMPORT_STMT_RE =
|
|
512
|
+
/^import\s+(type\s+)?(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([\s\S]*?)\}\s*)?(?:\*\s+as\s+([A-Za-z_$][\w$]*)\s+)?from\s+['"]([^'"]+)['"];?/gm;
|
|
513
|
+
|
|
514
|
+
function importLocalName(entry: string): string {
|
|
515
|
+
const asMatch = entry.match(/\s+as\s+([A-Za-z_$][\w$]*)\s*$/);
|
|
516
|
+
if (asMatch) return asMatch[1];
|
|
517
|
+
return entry.replace(/^type\s+/, '').trim();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function collectImportedLocalNames(text: string): Set<string> {
|
|
521
|
+
const names = new Set<string>();
|
|
522
|
+
for (const m of text.matchAll(IMPORT_STMT_RE)) {
|
|
523
|
+
if (m[2]) names.add(m[2]);
|
|
524
|
+
if (m[4]) names.add(m[4]);
|
|
525
|
+
for (const entry of (m[3] ?? '').split(',')) {
|
|
526
|
+
const trimmed = entry.trim();
|
|
527
|
+
if (trimmed) names.add(importLocalName(trimmed));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return names;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Import statements from `generatedText` whose names `existingText` lacks. */
|
|
534
|
+
function missingImportStatements(existingText: string, generatedText: string): string[] {
|
|
535
|
+
const existingNames = collectImportedLocalNames(existingText);
|
|
536
|
+
const statements: string[] = [];
|
|
537
|
+
for (const m of generatedText.matchAll(IMPORT_STMT_RE)) {
|
|
538
|
+
const [stmt, typeOnly, defaultName, named, namespaceName, moduleSpec] = m;
|
|
539
|
+
if (defaultName || namespaceName) {
|
|
540
|
+
// Default/namespace imports: append verbatim when the local is missing.
|
|
541
|
+
const local = defaultName ?? namespaceName;
|
|
542
|
+
if (local && !existingNames.has(local)) statements.push(stmt.endsWith(';') ? stmt : `${stmt};`);
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const missing = (named ?? '')
|
|
546
|
+
.split(',')
|
|
547
|
+
.map((entry) => entry.trim())
|
|
548
|
+
.filter((entry) => entry && !existingNames.has(importLocalName(entry)));
|
|
549
|
+
if (missing.length === 0) continue;
|
|
550
|
+
statements.push(`import ${typeOnly ? 'type ' : ''}{ ${missing.join(', ')} } from '${moduleSpec}';`);
|
|
551
|
+
}
|
|
552
|
+
return statements;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Merge a partial class emission into the existing on-disk file content.
|
|
557
|
+
*
|
|
558
|
+
* Acts only when a class declared in BOTH texts has methods on disk that the
|
|
559
|
+
* generated content drops — the signature of a "new operations only" partial
|
|
560
|
+
* emission. Returns the existing text with the generated-only methods appended
|
|
561
|
+
* to the class body (and missing imports added), preserving every existing
|
|
562
|
+
* method and import verbatim.
|
|
563
|
+
*
|
|
564
|
+
* Returns null when the generated content does not drop any existing method;
|
|
565
|
+
* callers should then proceed with their normal (overwrite) path so full
|
|
566
|
+
* regenerations keep propagating emitter improvements and spec renames.
|
|
567
|
+
*/
|
|
568
|
+
export function mergeGeneratedClassMethodsIntoExisting(existingText: string, generatedText: string): string | null {
|
|
569
|
+
const existingLines = existingText.split('\n');
|
|
570
|
+
const generatedLines = generatedText.split('\n');
|
|
571
|
+
const existingClasses = parseClassesByLine(existingLines);
|
|
572
|
+
if (existingClasses.length === 0) return null;
|
|
573
|
+
const generatedClasses = new Map(parseClassesByLine(generatedLines).map((cls) => [cls.name, cls]));
|
|
574
|
+
|
|
575
|
+
let dropsExistingMethod = false;
|
|
576
|
+
const insertions: Array<{ atLine: number; blocks: string[][] }> = [];
|
|
577
|
+
for (const existing of existingClasses) {
|
|
578
|
+
const generated = generatedClasses.get(existing.name);
|
|
579
|
+
if (!generated) continue;
|
|
580
|
+
const generatedMethods = new Set(generated.methods);
|
|
581
|
+
if (!existing.methods.some((method) => !generatedMethods.has(method))) continue;
|
|
582
|
+
dropsExistingMethod = true;
|
|
583
|
+
|
|
584
|
+
const existingMethods = new Set(existing.methods);
|
|
585
|
+
const blocks: string[][] = [];
|
|
586
|
+
for (const method of generated.methods) {
|
|
587
|
+
if (existingMethods.has(method)) continue;
|
|
588
|
+
const block = extractMethodBlock(generatedLines, generated, method);
|
|
589
|
+
if (block) blocks.push(block);
|
|
590
|
+
}
|
|
591
|
+
if (blocks.length > 0) insertions.push({ atLine: existing.closeLine, blocks });
|
|
592
|
+
}
|
|
593
|
+
if (!dropsExistingMethod) return null;
|
|
594
|
+
|
|
595
|
+
const merged = [...existingLines];
|
|
596
|
+
// Bottom-up so earlier insertions don't shift later line indices. Imports
|
|
597
|
+
// sit above every class body, so the import insertion point (computed on
|
|
598
|
+
// the original text) stays valid after these splices.
|
|
599
|
+
insertions.sort((a, b) => b.atLine - a.atLine);
|
|
600
|
+
for (const insertion of insertions) {
|
|
601
|
+
const blockLines: string[] = [];
|
|
602
|
+
for (const block of insertion.blocks) {
|
|
603
|
+
blockLines.push('', ...block);
|
|
604
|
+
}
|
|
605
|
+
merged.splice(insertion.atLine, 0, ...blockLines);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const importStatements = missingImportStatements(existingText, generatedText);
|
|
609
|
+
if (importStatements.length > 0) {
|
|
610
|
+
let lastImportLine = -1;
|
|
611
|
+
for (let i = 0; i < existingLines.length; i++) {
|
|
612
|
+
if (
|
|
613
|
+
/from\s+['"][^'"]+['"];?\s*$/.test(existingLines[i]) ||
|
|
614
|
+
/^import\s+['"][^'"]+['"];?\s*$/.test(existingLines[i])
|
|
615
|
+
) {
|
|
616
|
+
lastImportLine = i;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (lastImportLine >= 0) {
|
|
620
|
+
merged.splice(lastImportLine + 1, 0, ...importStatements);
|
|
621
|
+
} else {
|
|
622
|
+
// No imports yet: place them after the leading comment block.
|
|
623
|
+
let insertAt = 0;
|
|
624
|
+
while (insertAt < existingLines.length) {
|
|
625
|
+
const line = existingLines[insertAt].trim();
|
|
626
|
+
if (line === '' || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) insertAt++;
|
|
627
|
+
else break;
|
|
628
|
+
}
|
|
629
|
+
merged.splice(insertAt, 0, ...importStatements, '');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return merged.join('\n');
|
|
634
|
+
}
|
|
635
|
+
|
|
327
636
|
/**
|
|
328
637
|
* Given the start index of a class declaration, return the brace-delimited body
|
|
329
638
|
* as a substring. Returns '' if the body cannot be located.
|
package/src/node/models.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
emitSerializerBody,
|
|
42
42
|
hasDateTimeConversion,
|
|
43
43
|
} from './field-plan.js';
|
|
44
|
-
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile } from './live-surface.js';
|
|
44
|
+
import { liveSurfaceHasExistingSdk, liveSurfaceHasManagedFile, liveSurfaceInterfacePath } from './live-surface.js';
|
|
45
45
|
import { isNodeOwnedService } from './options.js';
|
|
46
46
|
import { unwrapListModel } from './fixtures.js';
|
|
47
47
|
import { groupByMount, buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
|
|
@@ -370,7 +370,13 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
370
370
|
const eDir = resolveDir(eService);
|
|
371
371
|
const bEnum = ctx.apiSurface?.enums?.[irEnumName];
|
|
372
372
|
const bAlias = ctx.apiSurface?.typeAliases?.[irEnumName];
|
|
373
|
-
|
|
373
|
+
// Same owned-service exception as the `deps.enums` loop below:
|
|
374
|
+
// `generateEnums` emits the canonical module for owned services
|
|
375
|
+
// even when the baseline declares the name elsewhere (usually in
|
|
376
|
+
// the very file being overwritten), so import planning must
|
|
377
|
+
// target the canonical path to agree with that emission.
|
|
378
|
+
const eEnumIsOwned = isNodeOwnedService(ctx, eService);
|
|
379
|
+
const bSrc = eEnumIsOwned ? undefined : ((bEnum as any)?.sourceFile ?? (bAlias as any)?.sourceFile);
|
|
374
380
|
const gPath = `src/${eDir}/interfaces/${fileName(irEnumName)}.interface.ts`;
|
|
375
381
|
const cPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
376
382
|
if (bSrc === cPath) {
|
|
@@ -446,8 +452,21 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
446
452
|
|
|
447
453
|
const baselineEnum = ctx.apiSurface?.enums?.[dep];
|
|
448
454
|
const baselineAlias = ctx.apiSurface?.typeAliases?.[dep];
|
|
449
|
-
const baselineSrc = (baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile;
|
|
450
455
|
const depService = enumToService.get(dep);
|
|
456
|
+
const depEnumIsOwned = isNodeOwnedService(ctx, depService);
|
|
457
|
+
// Fall back to the live-surface declaration path: `generateEnums` skips
|
|
458
|
+
// emission when the enum is already declared elsewhere in the SDK (same
|
|
459
|
+
// fallback, see enums.ts), so the import must follow that declaration —
|
|
460
|
+
// the canonical per-service file will never exist.
|
|
461
|
+
//
|
|
462
|
+
// OWNED services are the exception, mirroring enums.ts: the on-disk
|
|
463
|
+
// declaration usually lives in a file this very regeneration
|
|
464
|
+
// overwrites, `generateEnums` emits the canonical module anyway, and
|
|
465
|
+
// the import must agree with that emission — otherwise the generated
|
|
466
|
+
// interface references a name that is declared nowhere.
|
|
467
|
+
const baselineSrc = depEnumIsOwned
|
|
468
|
+
? undefined
|
|
469
|
+
: ((baselineEnum as any)?.sourceFile ?? (baselineAlias as any)?.sourceFile ?? liveSurfaceInterfacePath(dep));
|
|
451
470
|
const depDir = resolveDir(depService);
|
|
452
471
|
const generatedPath = `src/${depDir}/interfaces/${fileName(dep)}.interface.ts`;
|
|
453
472
|
const currentFilePath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
@@ -602,6 +621,17 @@ export function generateModels(models: Model[], ctx: EmitterContext, shared?: Sh
|
|
|
602
621
|
if (generatedNames.has(name)) continue;
|
|
603
622
|
const sepPath = `src/${dirName}/interfaces/${fileName(name)}.interface.ts`;
|
|
604
623
|
if (sepPath !== filePath && files.some((f) => f.path === sepPath)) continue;
|
|
624
|
+
// Owned-service enums get their canonical per-service module
|
|
625
|
+
// emitted by `generateEnums` this run, and this file imports the
|
|
626
|
+
// name (see the deps.enums loop above). Preserving the legacy
|
|
627
|
+
// inline declaration would collide with that import (TS2440).
|
|
628
|
+
if (
|
|
629
|
+
!isInlineEnum(name) &&
|
|
630
|
+
ctx.spec.enums.some((e) => e.name === name) &&
|
|
631
|
+
isNodeOwnedService(ctx, enumToService.get(name))
|
|
632
|
+
) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
605
635
|
inlineNames.add(name);
|
|
606
636
|
}
|
|
607
637
|
};
|
|
@@ -1164,6 +1194,16 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
|
|
|
1164
1194
|
}
|
|
1165
1195
|
|
|
1166
1196
|
function baselineFieldCompatible(baselineField: { type: string; optional: boolean }, irField: Field): boolean {
|
|
1197
|
+
// A baseline `any` is the footprint of a previously-broken generation:
|
|
1198
|
+
// api-surface extraction types a field as `any` when its import failed to
|
|
1199
|
+
// resolve (e.g. the enum file hadn't been emitted yet in that run). Copying
|
|
1200
|
+
// it forward would freeze the degradation — once `state: any` lands in the
|
|
1201
|
+
// SDK, every later regen sees it as the baseline and re-emits it. When the
|
|
1202
|
+
// IR knows the real model/enum name, always re-derive from the IR instead.
|
|
1203
|
+
if (baselineTypeIsDegradedAny(baselineField.type) && hasNamedTypeReference(irField.type)) {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1167
1207
|
const irNullable = irField.type.kind === 'nullable';
|
|
1168
1208
|
const baselineHasNull = baselineField.type.includes('null');
|
|
1169
1209
|
|
|
@@ -1182,6 +1222,32 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
|
|
|
1182
1222
|
return true;
|
|
1183
1223
|
}
|
|
1184
1224
|
|
|
1225
|
+
/** `any`, `any[]`, `any | null`, … — shapes api-surface extraction degrades to. */
|
|
1226
|
+
function baselineTypeIsDegradedAny(typeStr: string): boolean {
|
|
1227
|
+
const stripped = typeStr
|
|
1228
|
+
.replace(/\s*\|\s*(?:null|undefined)\b/g, '')
|
|
1229
|
+
.replace(/\[\]$/, '')
|
|
1230
|
+
.trim();
|
|
1231
|
+
return stripped === 'any';
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** Does the IR type reference a named model/enum anywhere (incl. arrays)? */
|
|
1235
|
+
function hasNamedTypeReference(ref: TypeRef): boolean {
|
|
1236
|
+
switch (ref.kind) {
|
|
1237
|
+
case 'model':
|
|
1238
|
+
case 'enum':
|
|
1239
|
+
return true;
|
|
1240
|
+
case 'array':
|
|
1241
|
+
return hasNamedTypeReference(ref.items);
|
|
1242
|
+
case 'nullable':
|
|
1243
|
+
return hasNamedTypeReference(ref.inner);
|
|
1244
|
+
case 'union':
|
|
1245
|
+
return ref.variants.some((v) => hasNamedTypeReference(v));
|
|
1246
|
+
default:
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1185
1251
|
function hasSpecificIRType(ref: TypeRef): boolean {
|
|
1186
1252
|
switch (ref.kind) {
|
|
1187
1253
|
case 'model':
|
package/src/node/naming.ts
CHANGED
|
@@ -61,6 +61,26 @@ export function setBaselineInterfaceNames(names: Set<string>): void {
|
|
|
61
61
|
baselineInterfaceNames = names;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Every name DECLARED by the live SDK or baseline api-surface — interfaces
|
|
66
|
+
* AND type aliases. Exact-name declarations preempt structural renames in
|
|
67
|
+
* `resolveInterfaceName`: when the IR model's own name is already declared,
|
|
68
|
+
* the structural matcher must not re-point it at a different declaration.
|
|
69
|
+
*
|
|
70
|
+
* This matters for alias-form files (`export type X = Y;`): the engine's
|
|
71
|
+
* api-surface records X under `typeAliases` with no fields, so its
|
|
72
|
+
* exact-name pass cannot claim X and the Jaccard fallback "renames" IR
|
|
73
|
+
* model X to whatever interface looks similar. Propagating that rename
|
|
74
|
+
* emitted duplicate, renamed declarations whose form flip-flopped on every
|
|
75
|
+
* regeneration (workos-node ApiKeyOwner / UserManagement model files).
|
|
76
|
+
*
|
|
77
|
+
* Set by `index.ts` immediately after `getSurface(ctx)` runs.
|
|
78
|
+
*/
|
|
79
|
+
let baselineDeclaredNames: Set<string> = new Set();
|
|
80
|
+
export function setBaselineDeclaredNames(names: Set<string>): void {
|
|
81
|
+
baselineDeclaredNames = names;
|
|
82
|
+
}
|
|
83
|
+
|
|
64
84
|
/**
|
|
65
85
|
* IR models that belong to newly-adopted services should not be renamed by
|
|
66
86
|
* structural baseline matches from unrelated hand-written services.
|
|
@@ -100,6 +120,178 @@ export function setStructurallyRenamedDomainNames(names: Set<string>): void {
|
|
|
100
120
|
structurallyRenamedDomainNames = names;
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The structural half of `resolveInterfaceName`, pre-injectivity: look up the
|
|
125
|
+
* engine's structurally-inferred match (`overlayLookup.modelNameByIR`), apply
|
|
126
|
+
* the adopted/discriminated/declared-name guards, and normalize legacy
|
|
127
|
+
* `Serialized*` / wire-shaped `*Response` matches down to the baseline domain
|
|
128
|
+
* name. Returns the candidate live name, or undefined when no structural
|
|
129
|
+
* match applies. Shared by `resolveInterfaceName` and the claims registry so
|
|
130
|
+
* both see the exact same candidate for every IR model.
|
|
131
|
+
*/
|
|
132
|
+
function inferStructuralRename(name: string, ctx: EmitterContext): string | undefined {
|
|
133
|
+
let inferred =
|
|
134
|
+
adoptedModelNames.has(name) || discriminatedModelNames.has(name)
|
|
135
|
+
? undefined
|
|
136
|
+
: ctx.overlayLookup?.modelNameByIR?.get(name);
|
|
137
|
+
// Exact-name declarations preempt structural renames: when the live SDK
|
|
138
|
+
// already declares `name` (interface or type alias), a non-identity
|
|
139
|
+
// structural match is a misfire — the alias/typeAlias resolution in
|
|
140
|
+
// `resolveInterfaceName` (or the name itself) is the canonical answer. See
|
|
141
|
+
// `setBaselineDeclaredNames` for the alias-form feedback loop this breaks.
|
|
142
|
+
if (inferred && inferred !== name && baselineDeclaredNames.has(name)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (!inferred) return undefined;
|
|
146
|
+
if (inferred.startsWith('Serialized')) {
|
|
147
|
+
const stripped = inferred.slice('Serialized'.length);
|
|
148
|
+
if (stripped && ctx.apiSurface?.interfaces?.[stripped]) {
|
|
149
|
+
inferred = stripped;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Structural matchers tend to lock onto the wire-shaped baseline
|
|
153
|
+
// interface (`AuditLogSchemaResponse`) because the IR carries
|
|
154
|
+
// snake_case fields. Prefer the corresponding domain name (without
|
|
155
|
+
// the `Response` suffix) when both exist in baseline — domain refs
|
|
156
|
+
// belong on the domain side, the wire/serialize path picks up the
|
|
157
|
+
// `*Response` variant via `wireInterfaceName`.
|
|
158
|
+
if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
|
|
159
|
+
const stripped = inferred.slice(0, -'Response'.length);
|
|
160
|
+
if (stripped && ctx.apiSurface.interfaces[stripped]) {
|
|
161
|
+
inferred = stripped;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return inferred;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Per-run registry making structural name resolution INJECTIVE: live-surface
|
|
169
|
+
* name → the single IR model name allowed to claim it. Built lazily from
|
|
170
|
+
* `ctx.spec.models` on first use and cached per ctx.
|
|
171
|
+
*
|
|
172
|
+
* Cache-correctness invariant (relied on, not assumed): the `ctx` reaching
|
|
173
|
+
* this function is always the memoized `nodeCtx` from `withNodeOperationOverrides`
|
|
174
|
+
* — every emitter hook derives it as its first step and threads it through
|
|
175
|
+
* `getSurface`/`resolveInterfaceName`, and that helper returns one stable
|
|
176
|
+
* object per run. `nodeCtx.spec` is built once via spread and `spec.models` is
|
|
177
|
+
* never reassigned or mutated in place anywhere (enrichment pushes only onto a
|
|
178
|
+
* pre-enrichment local collector). So the cached value can never drift from
|
|
179
|
+
* the `spec.models` it was computed from. Do not begin mutating `spec.models`
|
|
180
|
+
* under a live ctx without invalidating this cache.
|
|
181
|
+
*
|
|
182
|
+
* Without it, the structural fallback could map two distinct IR models onto
|
|
183
|
+
* one live declaration. workos-node AuditLogs evidence: IR
|
|
184
|
+
* `AuditLogEventActor` and `AuditLogEventTarget` (near-identical shapes) both
|
|
185
|
+
* resolved to the hand-written `AuditLogActor`, so
|
|
186
|
+
* `audit-log-event-target.interface.ts` was emitted declaring
|
|
187
|
+
* `export interface AuditLogActor` (file stem and declaration disagree),
|
|
188
|
+
* with duplicate imports/`describe` blocks and two `serializeAuditLogActor`
|
|
189
|
+
* definitions downstream. The raw engine overlay is injective on its own
|
|
190
|
+
* names, but the resolver's `Serialized*`/`*Response` normalization below can
|
|
191
|
+
* collapse two distinct raw matches onto one bare name — the claims registry
|
|
192
|
+
* gates the final, post-normalization answer.
|
|
193
|
+
*
|
|
194
|
+
* Claim order:
|
|
195
|
+
* 1. Exact-name overrides (`overlayLookup.interfaceByName`) claim first.
|
|
196
|
+
* 2. Identity structural matches (IR name === live name) claim their own name.
|
|
197
|
+
* 3. Contested renames go to the claimant with the higher field-overlap
|
|
198
|
+
* similarity; ties break toward the closer name (edit distance), then
|
|
199
|
+
* lexicographically for determinism. Losers are NEVER unified — they keep
|
|
200
|
+
* their canonical IR-derived names.
|
|
201
|
+
*/
|
|
202
|
+
const structuralClaimsCache = new WeakMap<EmitterContext, Map<string, string>>();
|
|
203
|
+
|
|
204
|
+
/** Jaccard similarity between two normalized field-name sets. */
|
|
205
|
+
function fieldJaccard(a: Set<string>, b: Set<string>): number {
|
|
206
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
207
|
+
let intersection = 0;
|
|
208
|
+
for (const item of a) if (b.has(item)) intersection++;
|
|
209
|
+
const union = a.size + b.size - intersection;
|
|
210
|
+
return union === 0 ? 0 : intersection / union;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Levenshtein distance over lowercased names (tie-break for contested claims). */
|
|
214
|
+
function nameDistance(a: string, b: string): number {
|
|
215
|
+
const s = a.toLowerCase();
|
|
216
|
+
const t = b.toLowerCase();
|
|
217
|
+
if (s === t) return 0;
|
|
218
|
+
let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
|
|
219
|
+
for (let i = 1; i <= s.length; i++) {
|
|
220
|
+
const curr = [i];
|
|
221
|
+
for (let j = 1; j <= t.length; j++) {
|
|
222
|
+
const cost = s[i - 1] === t[j - 1] ? 0 : 1;
|
|
223
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
224
|
+
}
|
|
225
|
+
prev = curr;
|
|
226
|
+
}
|
|
227
|
+
return prev[t.length];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Normalized field-name signature of an IR model. */
|
|
231
|
+
function irFieldSignature(model: { fields: { name: string }[] }): Set<string> {
|
|
232
|
+
return new Set(model.fields.map((f) => toSnakeCase(f.name)));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Normalized field-name signature of a live-surface interface, if known. */
|
|
236
|
+
function liveFieldSignature(ctx: EmitterContext, liveName: string): Set<string> | undefined {
|
|
237
|
+
const iface = ctx.apiSurface?.interfaces?.[liveName] as { fields?: Record<string, unknown> } | undefined;
|
|
238
|
+
if (!iface?.fields) return undefined;
|
|
239
|
+
return new Set(Object.keys(iface.fields).map((f) => toSnakeCase(f)));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getStructuralNameClaims(ctx: EmitterContext): Map<string, string> {
|
|
243
|
+
const cached = structuralClaimsCache.get(ctx);
|
|
244
|
+
if (cached) return cached;
|
|
245
|
+
const claims = new Map<string, string>();
|
|
246
|
+
|
|
247
|
+
// 1. Exact-name overrides claim first (mirrors resolveInterfaceName step 1).
|
|
248
|
+
for (const [irName, liveName] of ctx.overlayLookup?.interfaceByName ?? new Map<string, string>()) {
|
|
249
|
+
if (!claims.has(liveName)) claims.set(liveName, irName);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 2. Identity matches claim their own name; renames queue up per live name.
|
|
253
|
+
const contested = new Map<string, string[]>();
|
|
254
|
+
const modelsByName = new Map<string, { fields: { name: string }[] }>();
|
|
255
|
+
for (const model of ctx.spec.models) {
|
|
256
|
+
modelsByName.set(model.name, model);
|
|
257
|
+
if (ctx.overlayLookup?.interfaceByName?.has(model.name)) continue;
|
|
258
|
+
const inferred = inferStructuralRename(model.name, ctx);
|
|
259
|
+
if (!inferred) continue;
|
|
260
|
+
if (inferred === model.name) {
|
|
261
|
+
if (!claims.has(inferred)) claims.set(inferred, model.name);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const group = contested.get(inferred);
|
|
265
|
+
if (group) group.push(model.name);
|
|
266
|
+
else contested.set(inferred, [model.name]);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3. Award each contested live name to exactly one structural claimant.
|
|
270
|
+
for (const [liveName, irNames] of contested) {
|
|
271
|
+
if (claims.has(liveName)) continue; // exact/identity claims preempt renames
|
|
272
|
+
let winner = irNames[0];
|
|
273
|
+
if (irNames.length > 1) {
|
|
274
|
+
const liveFields = liveFieldSignature(ctx, liveName);
|
|
275
|
+
const scoreOf = (irName: string): number => {
|
|
276
|
+
const model = modelsByName.get(irName);
|
|
277
|
+
if (!model || !liveFields) return 0;
|
|
278
|
+
return fieldJaccard(irFieldSignature(model), liveFields);
|
|
279
|
+
};
|
|
280
|
+
winner = [...irNames].sort((a, b) => {
|
|
281
|
+
const scoreDiff = scoreOf(b) - scoreOf(a);
|
|
282
|
+
if (scoreDiff !== 0) return scoreDiff;
|
|
283
|
+
const distDiff = nameDistance(a, liveName) - nameDistance(b, liveName);
|
|
284
|
+
if (distDiff !== 0) return distDiff;
|
|
285
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
286
|
+
})[0];
|
|
287
|
+
}
|
|
288
|
+
claims.set(liveName, winner);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
structuralClaimsCache.set(ctx, claims);
|
|
292
|
+
return claims;
|
|
293
|
+
}
|
|
294
|
+
|
|
103
295
|
/**
|
|
104
296
|
* Wire/response interface name.
|
|
105
297
|
*
|
|
@@ -224,7 +416,8 @@ export function resolveClassName(service: Service, ctx: EmitterContext): string
|
|
|
224
416
|
* 1. `overlayLookup.interfaceByName` — exact-name overrides from the live SDK.
|
|
225
417
|
* 2. `overlayLookup.modelNameByIR` — structurally-inferred matches (e.g., IR
|
|
226
418
|
* `ValidateApiKey` with one field `value: string` → live SDK interface
|
|
227
|
-
* `ValidateApiKeyOptions`)
|
|
419
|
+
* `ValidateApiKeyOptions`), gated by the injective claims registry: each
|
|
420
|
+
* live name goes to at most one IR model (see `getStructuralNameClaims`).
|
|
228
421
|
* 3. Type-alias resolution (when an alias points to an interface).
|
|
229
422
|
* 4. Suffix-fallback heuristic for the workos-node `*Options` convention:
|
|
230
423
|
* when the IR name `X` has no baseline match but `XOptions` does, use
|
|
@@ -236,30 +429,18 @@ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: {
|
|
|
236
429
|
const existing = ctx.overlayLookup?.interfaceByName?.get(name);
|
|
237
430
|
if (existing) return existing;
|
|
238
431
|
|
|
239
|
-
let inferred =
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
if (inferred
|
|
245
|
-
const
|
|
246
|
-
if (
|
|
247
|
-
inferred =
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
// Structural matchers tend to lock onto the wire-shaped baseline
|
|
251
|
-
// interface (`AuditLogSchemaResponse`) because the IR carries
|
|
252
|
-
// snake_case fields. Prefer the corresponding domain name (without
|
|
253
|
-
// the `Response` suffix) when both exist in baseline — domain refs
|
|
254
|
-
// belong on the domain side, the wire/serialize path picks up the
|
|
255
|
-
// `*Response` variant via `wireInterfaceName`.
|
|
256
|
-
if (inferred.endsWith('Response') && ctx.apiSurface?.interfaces) {
|
|
257
|
-
const stripped = inferred.slice(0, -'Response'.length);
|
|
258
|
-
if (stripped && ctx.apiSurface.interfaces[stripped]) {
|
|
259
|
-
inferred = stripped;
|
|
432
|
+
let inferred = inferStructuralRename(name, ctx);
|
|
433
|
+
if (inferred !== undefined) {
|
|
434
|
+
// Injectivity gate: a structurally-renamed live name may be claimed by
|
|
435
|
+
// at most one IR model per run. When another model holds the claim, the
|
|
436
|
+
// rename is dropped and this model keeps its canonical IR-derived name.
|
|
437
|
+
if (inferred !== name) {
|
|
438
|
+
const claimant = getStructuralNameClaims(ctx).get(inferred);
|
|
439
|
+
if (claimant !== undefined && claimant !== name) {
|
|
440
|
+
inferred = undefined;
|
|
260
441
|
}
|
|
261
442
|
}
|
|
262
|
-
return inferred;
|
|
443
|
+
if (inferred !== undefined) return inferred;
|
|
263
444
|
}
|
|
264
445
|
|
|
265
446
|
if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
|