@workos/oagen-emitters 0.12.1 → 0.12.2
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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint-pr-title.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.node-version +1 -1
- 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/{plugin-CmfzawTp.mjs → plugin-eCuvoL1T.mjs} +2508 -1474
- package/dist/plugin-eCuvoL1T.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +6 -6
- package/renovate.json +46 -6
- package/src/node/client.ts +19 -32
- package/src/node/enums.ts +67 -30
- package/src/node/errors.ts +2 -8
- package/src/node/field-plan.ts +188 -52
- package/src/node/fixtures.ts +11 -33
- package/src/node/index.ts +345 -20
- package/src/node/live-surface.ts +378 -0
- package/src/node/models.ts +540 -351
- package/src/node/naming.ts +119 -25
- package/src/node/node-overrides.ts +77 -0
- package/src/node/options.ts +41 -0
- package/src/node/resources.ts +455 -46
- package/src/node/sdk-errors.ts +0 -16
- package/src/node/tests.ts +108 -83
- package/src/node/type-map.ts +40 -18
- package/src/node/utils.ts +89 -102
- package/src/node/wrappers.ts +0 -20
- package/test/node/client.test.ts +106 -1201
- package/test/node/enums.test.ts +59 -130
- package/test/node/errors.test.ts +2 -3
- package/test/node/live-surface.test.ts +240 -0
- package/test/node/models.test.ts +396 -765
- package/test/node/naming.test.ts +69 -234
- package/test/node/resources.test.ts +376 -2036
- package/test/node/tests.test.ts +119 -0
- package/test/node/type-map.test.ts +49 -54
- package/test/node/utils.test.ts +29 -80
- package/dist/plugin-CmfzawTp.mjs.map +0 -1
- package/test/node/serializers.test.ts +0 -444
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Snapshot of the live target SDK gathered by walking `--output` once.
|
|
7
|
+
*
|
|
8
|
+
* Replaces the heavier `--api-surface` extract step for the Node emitter.
|
|
9
|
+
* Only declaration *names* are extracted (regex-based, no AST). That is
|
|
10
|
+
* enough to drive "skip if already present" filtering in models, resources,
|
|
11
|
+
* and serializers.
|
|
12
|
+
*/
|
|
13
|
+
export interface LiveSurface {
|
|
14
|
+
/** Absolute root the surface was built from (e.g. workos-node repo root). */
|
|
15
|
+
rootDir: string;
|
|
16
|
+
/** Set of file paths (relative to rootDir, POSIX separators) that exist on disk. */
|
|
17
|
+
files: Set<string>;
|
|
18
|
+
/**
|
|
19
|
+
* Git-tracked paths under the SDK root.
|
|
20
|
+
*
|
|
21
|
+
* When non-empty, this is the canonical baseline surface for "existing SDK"
|
|
22
|
+
* decisions. It excludes prior bad generations that left untracked files in
|
|
23
|
+
* the tree, which lets the emitter stop re-emitting that junk so manifest
|
|
24
|
+
* pruning can clean it up on the next run.
|
|
25
|
+
*/
|
|
26
|
+
trackedFiles: Set<string>;
|
|
27
|
+
/** Files marked `@oagen-ignore-file`: never emit on top of these. */
|
|
28
|
+
protectedFiles: Set<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Files whose first lines contain the `auto-generated by oagen` header.
|
|
31
|
+
* These were produced by a prior generation and should be regenerated when
|
|
32
|
+
* the spec changes (e.g. parameter renames). Letting the engine see them
|
|
33
|
+
* in the new output list is what enables that update path — dropping them
|
|
34
|
+
* would freeze them at their last-generated content.
|
|
35
|
+
*/
|
|
36
|
+
autogenFiles: Set<string>;
|
|
37
|
+
/** Class name → relative file path. */
|
|
38
|
+
classes: Map<string, ClassInfo>;
|
|
39
|
+
/** Interface or type-alias name → relative file path. */
|
|
40
|
+
interfaces: Map<string, InterfaceInfo>;
|
|
41
|
+
/** Function name → relative file path (for serializers, helpers). */
|
|
42
|
+
functions: Map<string, string>;
|
|
43
|
+
/**
|
|
44
|
+
* `export const X = {...} as const` declarations on disk → ordered
|
|
45
|
+
* member-name → literal-value mapping. Used by `enums.ts` to preserve
|
|
46
|
+
* acronym casing (e.g. `DSync: 'dsync'`) on regeneration; without this,
|
|
47
|
+
* `toPascalCase('dsync')` would emit `Dsync` and silently break call
|
|
48
|
+
* sites that referenced `EnumName.DSync`.
|
|
49
|
+
*/
|
|
50
|
+
constObjectEnums: Map<string, Map<string, string>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ClassInfo {
|
|
54
|
+
filePath: string;
|
|
55
|
+
methods: Set<string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InterfaceInfo {
|
|
59
|
+
filePath: string;
|
|
60
|
+
fields: Set<string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SRC_DIR = 'src';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk `${rootDir}/src/` and build a live-surface snapshot.
|
|
67
|
+
*
|
|
68
|
+
* If `${rootDir}/src/` does not exist, returns an empty surface (greenfield).
|
|
69
|
+
* Reads each `.ts` file once; ignores `.spec.ts` and `.test.ts`.
|
|
70
|
+
*/
|
|
71
|
+
export function buildLiveSurface(rootDir: string): LiveSurface {
|
|
72
|
+
const surface: LiveSurface = {
|
|
73
|
+
rootDir,
|
|
74
|
+
files: new Set(),
|
|
75
|
+
trackedFiles: loadGitTrackedFiles(rootDir),
|
|
76
|
+
protectedFiles: new Set(),
|
|
77
|
+
autogenFiles: new Set(),
|
|
78
|
+
classes: new Map(),
|
|
79
|
+
interfaces: new Map(),
|
|
80
|
+
functions: new Map(),
|
|
81
|
+
constObjectEnums: new Map(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const srcRoot = path.join(rootDir, SRC_DIR);
|
|
85
|
+
if (!fs.existsSync(srcRoot) || !fs.statSync(srcRoot).isDirectory()) {
|
|
86
|
+
return surface;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const absPath of walk(srcRoot)) {
|
|
90
|
+
const rel = toPosix(path.relative(rootDir, absPath));
|
|
91
|
+
surface.files.add(rel);
|
|
92
|
+
if (!rel.endsWith('.ts')) continue;
|
|
93
|
+
if (surface.trackedFiles.size > 0 && !surface.trackedFiles.has(rel)) continue;
|
|
94
|
+
|
|
95
|
+
let text: string;
|
|
96
|
+
try {
|
|
97
|
+
text = fs.readFileSync(absPath, 'utf8');
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (isProtected(text)) {
|
|
103
|
+
surface.protectedFiles.add(rel);
|
|
104
|
+
} else if (isAutogen(text)) {
|
|
105
|
+
surface.autogenFiles.add(rel);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (rel.endsWith('.spec.ts') || rel.endsWith('.test.ts')) continue;
|
|
109
|
+
|
|
110
|
+
extractDeclarations(text, rel, surface);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return surface;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Module-level snapshot of the active live-surface, set by `index.ts` once per
|
|
118
|
+
* generation run. Used by callers that don't have a clean way to thread
|
|
119
|
+
* `EmitterContext` through (e.g. deeply-nested helpers in `resources.ts`
|
|
120
|
+
* that decide whether to inline a serializer call).
|
|
121
|
+
*/
|
|
122
|
+
let activeSurface: LiveSurface | null = null;
|
|
123
|
+
export function setActiveLiveSurface(surface: LiveSurface): void {
|
|
124
|
+
activeSurface = surface;
|
|
125
|
+
}
|
|
126
|
+
export function liveSurfaceHasFunction(name: string): boolean {
|
|
127
|
+
return activeSurface?.functions.has(name) ?? false;
|
|
128
|
+
}
|
|
129
|
+
/** Returns the relative file path containing the function, if known. */
|
|
130
|
+
export function liveSurfaceFunctionPath(name: string): string | undefined {
|
|
131
|
+
return activeSurface?.functions.get(name);
|
|
132
|
+
}
|
|
133
|
+
export function liveSurfaceHasFile(relPath: string): boolean {
|
|
134
|
+
return activeSurface?.files.has(relPath) ?? false;
|
|
135
|
+
}
|
|
136
|
+
export function liveSurfaceHasManagedFile(relPath: string): boolean {
|
|
137
|
+
const surface = activeSurface;
|
|
138
|
+
if (!surface) return false;
|
|
139
|
+
const managedPaths = surface.trackedFiles.size > 0 ? surface.trackedFiles : surface.files;
|
|
140
|
+
return managedPaths.has(relPath);
|
|
141
|
+
}
|
|
142
|
+
export function liveSurfaceHasAutogenFile(relPath: string): boolean {
|
|
143
|
+
return activeSurface?.autogenFiles.has(relPath) ?? false;
|
|
144
|
+
}
|
|
145
|
+
export function liveSurfaceHasExistingSdk(): boolean {
|
|
146
|
+
const surface = activeSurface;
|
|
147
|
+
if (!surface) return false;
|
|
148
|
+
const managedPaths = surface.trackedFiles.size > 0 ? surface.trackedFiles : surface.files;
|
|
149
|
+
return managedPaths.size > 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Empty surface — used as a default when no outputDir is available. */
|
|
153
|
+
export function emptyLiveSurface(): LiveSurface {
|
|
154
|
+
return {
|
|
155
|
+
rootDir: '',
|
|
156
|
+
files: new Set(),
|
|
157
|
+
trackedFiles: new Set(),
|
|
158
|
+
protectedFiles: new Set(),
|
|
159
|
+
autogenFiles: new Set(),
|
|
160
|
+
classes: new Map(),
|
|
161
|
+
interfaces: new Map(),
|
|
162
|
+
functions: new Map(),
|
|
163
|
+
constObjectEnums: new Map(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Returns the existing member-name → value map for a const-object enum, if any. */
|
|
168
|
+
export function liveSurfaceConstEnumMembers(name: string): Map<string, string> | undefined {
|
|
169
|
+
return activeSurface?.constObjectEnums.get(name);
|
|
170
|
+
}
|
|
171
|
+
export function liveSurfaceInterfacePath(name: string): string | undefined {
|
|
172
|
+
return activeSurface?.interfaces.get(name)?.filePath;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Should the emitter avoid writing this relative path on disk? */
|
|
176
|
+
export function shouldSkipPath(surface: LiveSurface, relPath: string): boolean {
|
|
177
|
+
return surface.protectedFiles.has(relPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Does the live SDK already contain a file at this relative path? */
|
|
181
|
+
export function pathExists(surface: LiveSurface, relPath: string): boolean {
|
|
182
|
+
return surface.files.has(relPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function* walk(dir: string): Generator<string> {
|
|
186
|
+
let entries: fs.Dirent[];
|
|
187
|
+
try {
|
|
188
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
for (const entry of entries) {
|
|
193
|
+
const abs = path.join(dir, entry.name);
|
|
194
|
+
if (entry.isDirectory()) {
|
|
195
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
196
|
+
yield* walk(abs);
|
|
197
|
+
} else if (entry.isFile()) {
|
|
198
|
+
yield abs;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function toPosix(p: string): string {
|
|
204
|
+
return p.split(path.sep).join('/');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function loadGitTrackedFiles(rootDir: string): Set<string> {
|
|
208
|
+
try {
|
|
209
|
+
const stdout = execFileSync('git', ['-C', rootDir, 'ls-files', '--', 'src'], {
|
|
210
|
+
encoding: 'utf8',
|
|
211
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
212
|
+
});
|
|
213
|
+
return new Set(
|
|
214
|
+
stdout
|
|
215
|
+
.split(/\r?\n/)
|
|
216
|
+
.map((line) => line.trim())
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.map(toPosix),
|
|
219
|
+
);
|
|
220
|
+
} catch {
|
|
221
|
+
return new Set();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isProtected(text: string): boolean {
|
|
226
|
+
// Look in the first 800 chars only — markers belong at file top.
|
|
227
|
+
const head = text.slice(0, 800);
|
|
228
|
+
return /@oagen-ignore-file\b/.test(head);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isAutogen(text: string): boolean {
|
|
232
|
+
// Same locality assumption — the auto-generated header is always at top.
|
|
233
|
+
const head = text.slice(0, 400);
|
|
234
|
+
return /auto-generated by oagen/i.test(head);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const CLASS_RE = /^\s*export\s+(?:abstract\s+)?class\s+([A-Z][\w$]*)/gm;
|
|
238
|
+
const INTERFACE_RE = /^\s*export\s+interface\s+([A-Z][\w$]*)/gm;
|
|
239
|
+
const TYPE_ALIAS_RE = /^\s*export\s+type\s+([A-Z][\w$]*)\s*=/gm;
|
|
240
|
+
// Picks up both standard declarations (`export function deserializeX(...)`)
|
|
241
|
+
// and arrow-style consts (`export const deserializeX = (...) =>`). The
|
|
242
|
+
// latter is the workos-node house style for serializers, including the
|
|
243
|
+
// generic-arrow form `export const deserializeProfile = <T extends ...>(`.
|
|
244
|
+
const FUNCTION_RE = /^\s*export\s+(?:async\s+)?function\s+([a-zA-Z_$][\w$]*)/gm;
|
|
245
|
+
const CONST_FN_RE = /^\s*export\s+const\s+([a-zA-Z_$][\w$]*)\s*(?::\s*[^=]+)?=\s*(?:async\s+)?(?:<[^>]*>\s*)?\(/gm;
|
|
246
|
+
// For methods, match a class member declaration (rough — picks up async + plain methods).
|
|
247
|
+
const METHOD_RE =
|
|
248
|
+
/^\s{2,}(?:public\s+|private\s+|protected\s+|readonly\s+)*(?:async\s+)?([a-zA-Z_$][\w$]*)\s*(?:<[^>]*>)?\s*\(/gm;
|
|
249
|
+
|
|
250
|
+
function extractDeclarations(text: string, relPath: string, surface: LiveSurface): void {
|
|
251
|
+
// Classes
|
|
252
|
+
for (const m of text.matchAll(CLASS_RE)) {
|
|
253
|
+
const name = m[1];
|
|
254
|
+
const body = sliceClassBody(text, m.index ?? 0);
|
|
255
|
+
const methods = new Set<string>();
|
|
256
|
+
if (body) {
|
|
257
|
+
for (const mm of body.matchAll(METHOD_RE)) {
|
|
258
|
+
const methodName = mm[1];
|
|
259
|
+
// Filter out keywords accidentally captured.
|
|
260
|
+
if (METHOD_KEYWORD_BLOCKLIST.has(methodName)) continue;
|
|
261
|
+
methods.add(methodName);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
surface.classes.set(name, { filePath: relPath, methods });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Interfaces
|
|
268
|
+
for (const m of text.matchAll(INTERFACE_RE)) {
|
|
269
|
+
const name = m[1];
|
|
270
|
+
const fields = extractInterfaceFields(text, m.index ?? 0);
|
|
271
|
+
surface.interfaces.set(name, { filePath: relPath, fields });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Type aliases (treated as interfaces for skip purposes; field set is empty).
|
|
275
|
+
for (const m of text.matchAll(TYPE_ALIAS_RE)) {
|
|
276
|
+
const name = m[1];
|
|
277
|
+
if (!surface.interfaces.has(name)) {
|
|
278
|
+
surface.interfaces.set(name, { filePath: relPath, fields: new Set() });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Top-level exported functions (both `function` and arrow-const styles).
|
|
283
|
+
for (const m of text.matchAll(FUNCTION_RE)) {
|
|
284
|
+
surface.functions.set(m[1], relPath);
|
|
285
|
+
}
|
|
286
|
+
for (const m of text.matchAll(CONST_FN_RE)) {
|
|
287
|
+
surface.functions.set(m[1], relPath);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// `export const X = { Member: 'value', ... } as const;` — capture the
|
|
291
|
+
// member-name → literal-value mapping so enum regeneration can preserve
|
|
292
|
+
// existing casing (e.g. `DSync: 'dsync'` instead of `Dsync: 'dsync'`).
|
|
293
|
+
for (const m of text.matchAll(CONST_OBJECT_ENUM_RE)) {
|
|
294
|
+
const enumName = m[1];
|
|
295
|
+
const body = m[2];
|
|
296
|
+
const members = new Map<string, string>();
|
|
297
|
+
for (const memberMatch of body.matchAll(CONST_OBJECT_MEMBER_RE)) {
|
|
298
|
+
const memberName = memberMatch[1] ?? memberMatch[2] ?? memberMatch[3];
|
|
299
|
+
const value = memberMatch[4];
|
|
300
|
+
if (memberName && value !== undefined) {
|
|
301
|
+
members.set(value, memberName);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (members.size > 0) {
|
|
305
|
+
surface.constObjectEnums.set(enumName, members);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// `export const X = { ... } as const`
|
|
311
|
+
const CONST_OBJECT_ENUM_RE = /^\s*export\s+const\s+([A-Z][\w$]*)\s*=\s*\{([\s\S]*?)\}\s*as\s+const\s*;/gm;
|
|
312
|
+
// Member entries inside the object body — supports unquoted, single-quoted,
|
|
313
|
+
// and double-quoted keys. Captures key (group 1/2/3) → string value (group 4).
|
|
314
|
+
const CONST_OBJECT_MEMBER_RE = /(?:'([^']+)'|"([^"]+)"|([a-zA-Z_$][\w$]*))\s*:\s*'([^']*)'/g;
|
|
315
|
+
|
|
316
|
+
const METHOD_KEYWORD_BLOCKLIST = new Set([
|
|
317
|
+
'if',
|
|
318
|
+
'for',
|
|
319
|
+
'while',
|
|
320
|
+
'switch',
|
|
321
|
+
'catch',
|
|
322
|
+
'return',
|
|
323
|
+
'throw',
|
|
324
|
+
'constructor', // tracked separately if needed
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Given the start index of a class declaration, return the brace-delimited body
|
|
329
|
+
* as a substring. Returns '' if the body cannot be located.
|
|
330
|
+
*/
|
|
331
|
+
function sliceClassBody(text: string, start: number): string {
|
|
332
|
+
const open = text.indexOf('{', start);
|
|
333
|
+
if (open < 0) return '';
|
|
334
|
+
let depth = 0;
|
|
335
|
+
for (let i = open; i < text.length; i++) {
|
|
336
|
+
const c = text[i];
|
|
337
|
+
if (c === '{') depth++;
|
|
338
|
+
else if (c === '}') {
|
|
339
|
+
depth--;
|
|
340
|
+
if (depth === 0) {
|
|
341
|
+
return text.slice(open + 1, i);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return '';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Pull field names from an interface body. Conservative: only matches simple
|
|
350
|
+
* declarations of the form `name?: ...;` or `name: ...;`. Generics, methods,
|
|
351
|
+
* and computed property names are skipped (they don't influence skip decisions).
|
|
352
|
+
*/
|
|
353
|
+
function extractInterfaceFields(text: string, start: number): Set<string> {
|
|
354
|
+
const fields = new Set<string>();
|
|
355
|
+
const open = text.indexOf('{', start);
|
|
356
|
+
if (open < 0) return fields;
|
|
357
|
+
let depth = 0;
|
|
358
|
+
let close = -1;
|
|
359
|
+
for (let i = open; i < text.length; i++) {
|
|
360
|
+
const c = text[i];
|
|
361
|
+
if (c === '{') depth++;
|
|
362
|
+
else if (c === '}') {
|
|
363
|
+
depth--;
|
|
364
|
+
if (depth === 0) {
|
|
365
|
+
close = i;
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (close < 0) return fields;
|
|
371
|
+
const body = text.slice(open + 1, close);
|
|
372
|
+
const fieldRe = /^\s*(?:readonly\s+)?(?:'([^']+)'|"([^"]+)"|([a-zA-Z_$][\w$]*))\s*\??\s*:/gm;
|
|
373
|
+
for (const m of body.matchAll(fieldRe)) {
|
|
374
|
+
const name = m[1] ?? m[2] ?? m[3];
|
|
375
|
+
if (name) fields.add(name);
|
|
376
|
+
}
|
|
377
|
+
return fields;
|
|
378
|
+
}
|