@workos/oagen 0.2.0 → 0.4.0
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/README.md +72 -0
- package/dist/cli/index.mjs +1076 -73
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +1 -1
- package/dist/compat/index.mjs +9 -4
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-DyQF4sel.mjs → diff-DcY93sii.mjs} +619 -18
- package/dist/diff-DcY93sii.mjs.map +1 -0
- package/dist/{differ-BwelBJN0.mjs → differ-CG5ZErKl.mjs} +2 -2
- package/dist/{differ-BwelBJN0.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
- package/dist/index.d.mts +136 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +337 -29
- package/dist/index.mjs.map +1 -1
- package/dist/scripts/smoke/baseline.mjs +351 -34
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +138 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +351 -34
- package/dist/smoke.mjs.map +1 -1
- package/dist/{tree-sitter-BPNfbEzY.mjs → tree-sitter-C53w_0WE.mjs} +42 -9
- package/dist/tree-sitter-C53w_0WE.mjs.map +1 -0
- package/dist/{types-O25qZxMl.d.mts → types-D1tMqLVB.d.mts} +165 -2
- package/dist/types-D1tMqLVB.d.mts.map +1 -0
- package/dist/verify/index.d.mts +1 -1
- package/dist/verify/index.mjs +5 -5
- package/dist/verify/index.mjs.map +1 -1
- package/docs/architecture/ir-types.md +73 -1
- package/docs/cli.md +29 -8
- package/package.json +4 -4
- package/skills/review-operations/SKILL.md +83 -0
- package/dist/diff-DyQF4sel.mjs.map +0 -1
- package/dist/tree-sitter-BPNfbEzY.mjs.map +0 -1
- package/dist/types-O25qZxMl.d.mts.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -3,13 +3,128 @@ import "dotenv/config";
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
|
-
import * as path from "node:path";
|
|
7
|
-
import { relative, resolve } from "node:path";
|
|
6
|
+
import * as path$1 from "node:path";
|
|
7
|
+
import path, { relative, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
-
import { BaseResolver, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
|
|
9
|
+
import { BaseResolver, Oas3_1Types, bundleDocument, createConfig, makeDocumentFromString } from "@redocly/openapi-core";
|
|
10
10
|
import Parser from "tree-sitter";
|
|
11
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
11
13
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
|
-
|
|
14
|
+
//#region src/ir/sdk-behavior.ts
|
|
15
|
+
/**
|
|
16
|
+
* Canonical SDK behavior defaults. These match the PHP emitter's current
|
|
17
|
+
* implementation (the most complete). Per-language overrides can be applied
|
|
18
|
+
* via `mergeSdkBehavior()` in each SDK's `oagen.config.ts`.
|
|
19
|
+
*
|
|
20
|
+
* - Retry: exponential backoff with jitter, retries on 429/5xx
|
|
21
|
+
* - Errors: standard HTTP status → exception kind mapping
|
|
22
|
+
* - Telemetry: enabled by default, tracks request ID + latency
|
|
23
|
+
* - Pagination: no inter-page delay (Node overrides to 350ms)
|
|
24
|
+
* - Idempotency: auto-generate UUID v4 for retryable POSTs
|
|
25
|
+
* - Logging: all lifecycle events enabled
|
|
26
|
+
* - User-Agent: SDK identifier + runtime version + app info + AI agent detection
|
|
27
|
+
* - Request guards: detect misplaced RequestOptions keys in params
|
|
28
|
+
* - Timeout: 60s default (Python overrides to 30s)
|
|
29
|
+
*/
|
|
30
|
+
function defaultSdkBehavior() {
|
|
31
|
+
return {
|
|
32
|
+
retry: {
|
|
33
|
+
retryableStatusCodes: [
|
|
34
|
+
429,
|
|
35
|
+
500,
|
|
36
|
+
502,
|
|
37
|
+
503,
|
|
38
|
+
504
|
|
39
|
+
],
|
|
40
|
+
maxRetries: 3,
|
|
41
|
+
retryOnConnectionError: true,
|
|
42
|
+
retryOnTimeout: true,
|
|
43
|
+
backoff: {
|
|
44
|
+
initialDelay: 1,
|
|
45
|
+
multiplier: 2,
|
|
46
|
+
maxDelay: 30,
|
|
47
|
+
jitterFactor: .5
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
errors: {
|
|
51
|
+
statusCodeMap: {
|
|
52
|
+
400: "BadRequest",
|
|
53
|
+
401: "Authentication",
|
|
54
|
+
403: "Authorization",
|
|
55
|
+
404: "NotFound",
|
|
56
|
+
409: "Conflict",
|
|
57
|
+
422: "UnprocessableEntity",
|
|
58
|
+
429: "RateLimitExceeded"
|
|
59
|
+
},
|
|
60
|
+
serverErrorKind: "Server",
|
|
61
|
+
clientErrorKind: "Api",
|
|
62
|
+
errorDocUrlTemplate: "https://workos.com/docs/errors/{code}"
|
|
63
|
+
},
|
|
64
|
+
telemetry: {
|
|
65
|
+
enabledByDefault: true,
|
|
66
|
+
headerName: "X-WorkOS-Client-Telemetry",
|
|
67
|
+
requestIdHeader: "X-Request-ID"
|
|
68
|
+
},
|
|
69
|
+
pagination: { autoPageDelayMs: 0 },
|
|
70
|
+
idempotency: {
|
|
71
|
+
headerName: "Idempotency-Key",
|
|
72
|
+
autoGenerateForPost: true
|
|
73
|
+
},
|
|
74
|
+
logging: {
|
|
75
|
+
enabled: true,
|
|
76
|
+
events: [
|
|
77
|
+
"request.start",
|
|
78
|
+
"request.success",
|
|
79
|
+
"request.retry",
|
|
80
|
+
"request.rate_limited",
|
|
81
|
+
"request.error",
|
|
82
|
+
"request.connection_error"
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
userAgent: {
|
|
86
|
+
sdkIdentifierTemplate: "{name} {lang}/{version}",
|
|
87
|
+
includeRuntimeVersion: true,
|
|
88
|
+
allowAppInfo: true,
|
|
89
|
+
aiAgentEnvVars: [
|
|
90
|
+
{
|
|
91
|
+
envVar: "CLAUDE_CODE",
|
|
92
|
+
agentName: "ClaudeCode"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
envVar: "CURSOR_AGENT",
|
|
96
|
+
agentName: "Cursor"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
envVar: "CLINE_ACTIVE",
|
|
100
|
+
agentName: "Cline"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
envVar: "WINDSURF_ACTIVE",
|
|
104
|
+
agentName: "Windsurf"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
envVar: "COPILOT_AGENT",
|
|
108
|
+
agentName: "Copilot"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
requestGuard: { optionKeys: [
|
|
113
|
+
"api_key",
|
|
114
|
+
"apiKey",
|
|
115
|
+
"idempotency_key",
|
|
116
|
+
"idempotencyKey",
|
|
117
|
+
"extra_headers",
|
|
118
|
+
"extraHeaders",
|
|
119
|
+
"max_retries",
|
|
120
|
+
"maxRetries",
|
|
121
|
+
"base_url",
|
|
122
|
+
"baseUrl"
|
|
123
|
+
] },
|
|
124
|
+
timeout: { defaultTimeoutSeconds: 60 }
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
13
128
|
//#region src/errors.ts
|
|
14
129
|
/**
|
|
15
130
|
* Structured error hierarchy for oagen.
|
|
@@ -68,13 +183,11 @@ var RegistryError = class extends OagenError {
|
|
|
68
183
|
//#region src/parser/refs.ts
|
|
69
184
|
async function loadAndBundleSpec(specPath) {
|
|
70
185
|
const absolutePath = resolve(specPath);
|
|
71
|
-
const document = makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href);
|
|
72
|
-
const config = await createConfig({});
|
|
73
|
-
const resolver = new BaseResolver();
|
|
74
186
|
const result = await bundleDocument({
|
|
75
|
-
document,
|
|
76
|
-
config:
|
|
77
|
-
|
|
187
|
+
document: makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href),
|
|
188
|
+
config: await createConfig({}),
|
|
189
|
+
types: Oas3_1Types,
|
|
190
|
+
externalRefResolver: new BaseResolver(),
|
|
78
191
|
dereference: false
|
|
79
192
|
});
|
|
80
193
|
if (result.problems.some((p) => p.severity === "error")) throw new SpecParseError(`Failed to parse spec: ${result.problems.filter((p) => p.severity === "error").map((p) => p.message).join("\n")}`, `Check the OpenAPI spec at "${absolutePath}" for syntax errors. Run a linter such as \`npx @redocly/cli lint ${specPath}\` to identify issues.`);
|
|
@@ -143,15 +256,49 @@ function walkTypeRef(ref, visitor) {
|
|
|
143
256
|
//#endregion
|
|
144
257
|
//#region src/utils/naming.ts
|
|
145
258
|
/**
|
|
259
|
+
* Known compound tokens that the regex-based splitter over-splits.
|
|
260
|
+
* Each entry is [lowercase-word-sequence, canonical-form].
|
|
261
|
+
* Sorted longest-first so greedy matching works correctly.
|
|
262
|
+
*/
|
|
263
|
+
const COMPOUND_WORDS = [[[
|
|
264
|
+
"m",
|
|
265
|
+
"2",
|
|
266
|
+
"m"
|
|
267
|
+
], "M2M"], [["o", "auth"], "OAuth"]];
|
|
268
|
+
/**
|
|
269
|
+
* Recombine adjacent words that form a known compound token.
|
|
270
|
+
*/
|
|
271
|
+
function recombineCompounds(words) {
|
|
272
|
+
const result = [];
|
|
273
|
+
let i = 0;
|
|
274
|
+
while (i < words.length) {
|
|
275
|
+
let matched = false;
|
|
276
|
+
for (const [pattern, canonical] of COMPOUND_WORDS) if (i + pattern.length <= words.length) {
|
|
277
|
+
if (pattern.every((p, j) => words[i + j].toLowerCase() === p)) {
|
|
278
|
+
result.push(canonical);
|
|
279
|
+
i += pattern.length;
|
|
280
|
+
matched = true;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!matched) {
|
|
285
|
+
result.push(words[i]);
|
|
286
|
+
i++;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
146
292
|
* Split a string into words, handling:
|
|
147
293
|
* - camelCase / PascalCase boundaries
|
|
148
294
|
* - snake_case / kebab-case separators
|
|
149
295
|
* - Consecutive capitals (e.g., "HTTPClient" → ["HTTP", "Client"])
|
|
150
296
|
* - Numbers as word boundaries (e.g., "OAuth2Token" → ["OAuth", "2", "Token"])
|
|
297
|
+
* - Known compounds are recombined (e.g., "M2M" stays as one word)
|
|
151
298
|
*/
|
|
152
299
|
function splitWords(s) {
|
|
153
300
|
if (!s) return [];
|
|
154
|
-
return s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0);
|
|
301
|
+
return recombineCompounds(s.replace(/[^a-zA-Z0-9_\-\s.]/g, "_").replace(/([a-z])([A-Z])/g, "$1\0$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2").replace(/([a-zA-Z])(\d)/g, "$1\0$2").replace(/(\d)([a-zA-Z])/g, "$1\0$2").split(/[\0_\-\s.]+/).filter((w) => w.length > 0));
|
|
155
302
|
}
|
|
156
303
|
const ACRONYM_SET = new Set([
|
|
157
304
|
"SSO",
|
|
@@ -159,7 +306,8 @@ const ACRONYM_SET = new Set([
|
|
|
159
306
|
"SAML",
|
|
160
307
|
"SCIM",
|
|
161
308
|
"JWT",
|
|
162
|
-
"HMAC"
|
|
309
|
+
"HMAC",
|
|
310
|
+
"M2M"
|
|
163
311
|
]);
|
|
164
312
|
function toPascalCase(s, acronyms) {
|
|
165
313
|
const merged = acronyms ? new Set([...ACRONYM_SET, ...acronyms]) : ACRONYM_SET;
|
|
@@ -188,11 +336,7 @@ function toSnakeCase(s) {
|
|
|
188
336
|
function toUpperSnakeCase(s) {
|
|
189
337
|
return splitWords(s).map((w) => w.toUpperCase()).join("_");
|
|
190
338
|
}
|
|
191
|
-
const BACKEND_SUFFIXES = [
|
|
192
|
-
"Dto",
|
|
193
|
-
"DTO",
|
|
194
|
-
"Controller"
|
|
195
|
-
];
|
|
339
|
+
const BACKEND_SUFFIXES = ["Controller"];
|
|
196
340
|
function stripBackendSuffixes(name) {
|
|
197
341
|
for (const suffix of BACKEND_SUFFIXES) if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length);
|
|
198
342
|
return name;
|
|
@@ -219,7 +363,9 @@ const SINGULAR_SAFE_LIST = new Set([
|
|
|
219
363
|
"Canvas",
|
|
220
364
|
"Alias",
|
|
221
365
|
"Basis",
|
|
222
|
-
"Bus"
|
|
366
|
+
"Bus",
|
|
367
|
+
"Species",
|
|
368
|
+
"Series"
|
|
223
369
|
]);
|
|
224
370
|
/**
|
|
225
371
|
* Conservative singularize for the leading PascalCase word.
|
|
@@ -228,7 +374,7 @@ const SINGULAR_SAFE_LIST = new Set([
|
|
|
228
374
|
* - trailing `s` for words >4 chars (e.g. Organizations → Organization)
|
|
229
375
|
* Safe-listed words are never singularized.
|
|
230
376
|
*/
|
|
231
|
-
function singularize(word) {
|
|
377
|
+
function singularize$1(word) {
|
|
232
378
|
if (SINGULAR_SAFE_LIST.has(word)) return word;
|
|
233
379
|
if (word.endsWith("ies") && word.length > 4) return word.slice(0, -3) + "y";
|
|
234
380
|
if (word.endsWith("ses") && word.length > 4) return word.slice(0, -2);
|
|
@@ -254,7 +400,7 @@ function cleanSchemaName(name) {
|
|
|
254
400
|
const match = result.match(/^([A-Z][a-z]*)/);
|
|
255
401
|
if (match) {
|
|
256
402
|
const leadWord = match[1];
|
|
257
|
-
const singular = singularize(leadWord);
|
|
403
|
+
const singular = singularize$1(leadWord);
|
|
258
404
|
if (singular !== leadWord) result = singular + result.slice(leadWord.length);
|
|
259
405
|
}
|
|
260
406
|
return result;
|
|
@@ -274,7 +420,7 @@ function qualifyInlineModelName(baseName, parentName) {
|
|
|
274
420
|
const trailingMatch = baseName.match(/^(.*?)([A-Z][a-z]*)$/);
|
|
275
421
|
if (trailingMatch) {
|
|
276
422
|
const [, prefix, trailingWord] = trailingMatch;
|
|
277
|
-
return `${cleanParent}${prefix}${singularize(trailingWord)}`;
|
|
423
|
+
return `${cleanParent}${prefix}${singularize$1(trailingWord)}`;
|
|
278
424
|
}
|
|
279
425
|
return `${cleanParent}${baseName}`;
|
|
280
426
|
}
|
|
@@ -335,15 +481,39 @@ function buildInlineModel(name, schema) {
|
|
|
335
481
|
}
|
|
336
482
|
//#endregion
|
|
337
483
|
//#region src/parser/schemas.ts
|
|
338
|
-
|
|
484
|
+
/**
|
|
485
|
+
* Module-level transform set during extractSchemas(). Used by schemaToTypeRef()
|
|
486
|
+
* to apply the same name transform to $ref model/enum references.
|
|
487
|
+
*/
|
|
488
|
+
let activeSchemaNameTransform = null;
|
|
489
|
+
/** Apply cleanSchemaName + the active transform (if any) to a raw schema name. */
|
|
490
|
+
function resolveSchemaName(rawName) {
|
|
491
|
+
let name = cleanSchemaName(toPascalCase(rawName));
|
|
492
|
+
if (activeSchemaNameTransform) name = activeSchemaNameTransform(name);
|
|
493
|
+
return name;
|
|
494
|
+
}
|
|
495
|
+
function extractSchemas(schemas, options) {
|
|
339
496
|
const enums = [];
|
|
340
497
|
if (!schemas) return {
|
|
341
498
|
models: [],
|
|
342
499
|
enums
|
|
343
500
|
};
|
|
501
|
+
if (options?.schemaNameTransform) {
|
|
502
|
+
const transform = options.schemaNameTransform;
|
|
503
|
+
const cleanedNames = Object.keys(schemas).map((n) => cleanSchemaName(toPascalCase(n)));
|
|
504
|
+
const transformedToOriginals = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const cleaned of cleanedNames) {
|
|
506
|
+
const transformed = transform(cleaned);
|
|
507
|
+
if (!transformedToOriginals.has(transformed)) transformedToOriginals.set(transformed, []);
|
|
508
|
+
transformedToOriginals.get(transformed).push(cleaned);
|
|
509
|
+
}
|
|
510
|
+
const unsafeToTransform = /* @__PURE__ */ new Set();
|
|
511
|
+
for (const [, originals] of transformedToOriginals) if (originals.length > 1) for (const n of originals) unsafeToTransform.add(n);
|
|
512
|
+
activeSchemaNameTransform = (name) => unsafeToTransform.has(name) ? name : transform(name);
|
|
513
|
+
} else activeSchemaNameTransform = null;
|
|
344
514
|
const modelsByCleanName = /* @__PURE__ */ new Map();
|
|
345
515
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
346
|
-
const pascalName =
|
|
516
|
+
const pascalName = resolveSchemaName(name);
|
|
347
517
|
if (schema.enum) enums.push(extractEnum(pascalName, schema));
|
|
348
518
|
else {
|
|
349
519
|
const model = extractModel(pascalName, schema, schemas);
|
|
@@ -351,6 +521,12 @@ function extractSchemas(schemas) {
|
|
|
351
521
|
if (existing) {
|
|
352
522
|
if (model.fields.length > existing.fields.length) modelsByCleanName.set(pascalName, model);
|
|
353
523
|
} else modelsByCleanName.set(pascalName, model);
|
|
524
|
+
for (const inlineModel of extractDiscriminatedAllOfVariantModels(schema, pascalName)) {
|
|
525
|
+
const existingInline = modelsByCleanName.get(inlineModel.name);
|
|
526
|
+
if (existingInline) {
|
|
527
|
+
if (inlineModel.fields.length > existingInline.fields.length) modelsByCleanName.set(inlineModel.name, inlineModel);
|
|
528
|
+
} else modelsByCleanName.set(inlineModel.name, inlineModel);
|
|
529
|
+
}
|
|
354
530
|
}
|
|
355
531
|
}
|
|
356
532
|
const models = [...modelsByCleanName.values()];
|
|
@@ -366,6 +542,10 @@ function extractSchemas(schemas) {
|
|
|
366
542
|
enums
|
|
367
543
|
};
|
|
368
544
|
}
|
|
545
|
+
/** Clear the module-level schemaNameTransform. Called by parseSpec() after all extraction phases. */
|
|
546
|
+
function clearSchemaNameTransform() {
|
|
547
|
+
activeSchemaNameTransform = null;
|
|
548
|
+
}
|
|
369
549
|
/**
|
|
370
550
|
* Walk a single TypeRef and extract inline enum refs as top-level enum definitions.
|
|
371
551
|
* Shared by both schemas.ts (field-level) and parse.ts (model-level) collection passes.
|
|
@@ -402,7 +582,7 @@ function collectInlineEnums(fields, enums) {
|
|
|
402
582
|
function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, models, modelNames, queue) {
|
|
403
583
|
walkTypeRef(ref, { model: (modelRef) => {
|
|
404
584
|
if (modelNames.has(modelRef.name)) return;
|
|
405
|
-
if (Object.keys(schemas).some((k) =>
|
|
585
|
+
if (Object.keys(schemas).some((k) => resolveSchemaName(k) === modelRef.name)) return;
|
|
406
586
|
const parentSchema = findSchemaByName(parentModelName, schemas);
|
|
407
587
|
if (!parentSchema) return;
|
|
408
588
|
const fieldSchema = findNestedFieldSchema(fieldName, parentSchema, schemas);
|
|
@@ -417,7 +597,7 @@ function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, mod
|
|
|
417
597
|
}
|
|
418
598
|
/** Find a component schema by PascalCase name. */
|
|
419
599
|
function findSchemaByName(pascalName, schemas) {
|
|
420
|
-
for (const [k, v] of Object.entries(schemas)) if (
|
|
600
|
+
for (const [k, v] of Object.entries(schemas)) if (resolveSchemaName(k) === pascalName) return v;
|
|
421
601
|
return null;
|
|
422
602
|
}
|
|
423
603
|
/** Walk into a schema to find a field's sub-schema, resolving allOf. */
|
|
@@ -575,13 +755,88 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
575
755
|
fields
|
|
576
756
|
};
|
|
577
757
|
}
|
|
758
|
+
function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
|
|
759
|
+
const models = [];
|
|
760
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
761
|
+
for (const subSchema of schema.allOf ?? []) for (const variant of subSchema.oneOf ?? []) {
|
|
762
|
+
if (!variant.properties || variant.type !== void 0 && variant.type !== "object") continue;
|
|
763
|
+
const variantName = deriveDiscriminatedVariantName(variant, fallbackName);
|
|
764
|
+
for (const model of extractInlineModelDeep(variantName, variant)) {
|
|
765
|
+
if (seenNames.has(model.name)) continue;
|
|
766
|
+
seenNames.add(model.name);
|
|
767
|
+
models.push(model);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return models;
|
|
771
|
+
}
|
|
772
|
+
function deriveDiscriminatedVariantName(schema, fallbackName) {
|
|
773
|
+
if (!schema.properties) return fallbackName;
|
|
774
|
+
for (const name of [
|
|
775
|
+
"event",
|
|
776
|
+
"type",
|
|
777
|
+
"object"
|
|
778
|
+
]) {
|
|
779
|
+
const field = schema.properties[name];
|
|
780
|
+
if (!field) continue;
|
|
781
|
+
if (typeof field.const === "string") return toPascalCase(field.const);
|
|
782
|
+
if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
|
|
783
|
+
}
|
|
784
|
+
for (const field of Object.values(schema.properties)) {
|
|
785
|
+
if (!field) continue;
|
|
786
|
+
if (typeof field.const === "string") return toPascalCase(field.const);
|
|
787
|
+
if (field.enum && field.enum.length === 1 && typeof field.enum[0] === "string") return toPascalCase(field.enum[0]);
|
|
788
|
+
}
|
|
789
|
+
return fallbackName;
|
|
790
|
+
}
|
|
791
|
+
function extractInlineModelDeep(name, schema) {
|
|
792
|
+
const requiredSet = new Set(schema.required ?? []);
|
|
793
|
+
const fields = [];
|
|
794
|
+
const properties = schema.properties ?? {};
|
|
795
|
+
const nestedModels = [];
|
|
796
|
+
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
|
797
|
+
if (!fieldSchema) continue;
|
|
798
|
+
fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, requiredSet));
|
|
799
|
+
if (fieldSchema.type === "object" && fieldSchema.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema));
|
|
800
|
+
if (fieldSchema.type === "array" && fieldSchema.items?.properties) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), fieldSchema.items));
|
|
801
|
+
if (fieldSchema.allOf) {
|
|
802
|
+
const mergedProperties = {};
|
|
803
|
+
const mergedRequired = [];
|
|
804
|
+
for (const sub of fieldSchema.allOf) {
|
|
805
|
+
if (sub.properties) Object.assign(mergedProperties, sub.properties);
|
|
806
|
+
if (sub.required) mergedRequired.push(...sub.required);
|
|
807
|
+
}
|
|
808
|
+
if (Object.keys(mergedProperties).length > 0) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), {
|
|
809
|
+
type: "object",
|
|
810
|
+
properties: mergedProperties,
|
|
811
|
+
required: mergedRequired
|
|
812
|
+
}));
|
|
813
|
+
}
|
|
814
|
+
if (fieldSchema.oneOf) {
|
|
815
|
+
const objectVariant = fieldSchema.oneOf.find((v) => v.properties && (v.type === "object" || !v.type));
|
|
816
|
+
if (objectVariant) nestedModels.push(...extractInlineModelDeep(qualifyNestedInlineName(name, fieldName), objectVariant));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return [{
|
|
820
|
+
name,
|
|
821
|
+
description: schema.description,
|
|
822
|
+
fields
|
|
823
|
+
}, ...nestedModels];
|
|
824
|
+
}
|
|
825
|
+
function qualifyNestedInlineName(parentName, fieldName) {
|
|
826
|
+
const pascalField = toPascalCase(fieldName);
|
|
827
|
+
if (pascalField.startsWith(parentName)) return pascalField;
|
|
828
|
+
const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
|
|
829
|
+
const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
|
|
830
|
+
if (match && match[2]) return match[1] + singularize$1(match[2]);
|
|
831
|
+
return qualified;
|
|
832
|
+
}
|
|
578
833
|
function schemaToTypeRef(schema, contextName, parentModelName) {
|
|
579
834
|
if (schema.$ref) {
|
|
580
835
|
const segments = schema.$ref.split("/");
|
|
581
836
|
const rawName = segments[segments.length - 1];
|
|
582
837
|
return {
|
|
583
838
|
kind: "model",
|
|
584
|
-
name:
|
|
839
|
+
name: resolveSchemaName(rawName)
|
|
585
840
|
};
|
|
586
841
|
}
|
|
587
842
|
if (Array.isArray(schema.type)) {
|
|
@@ -845,6 +1100,7 @@ const KNOWN_DATA_PATHS = new Set([
|
|
|
845
1100
|
"edges"
|
|
846
1101
|
]);
|
|
847
1102
|
const PAGINATION_METADATA_PATTERNS = [
|
|
1103
|
+
"meta",
|
|
848
1104
|
"metadata",
|
|
849
1105
|
"pagination",
|
|
850
1106
|
"cursor",
|
|
@@ -876,11 +1132,12 @@ function detectListEnvelope(schema) {
|
|
|
876
1132
|
}
|
|
877
1133
|
if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
|
|
878
1134
|
const arrayPropName = arrayProps[0];
|
|
879
|
-
|
|
1135
|
+
const hasPaginationCompanion = nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)));
|
|
1136
|
+
if (KNOWN_DATA_PATHS.has(arrayPropName) && hasPaginationCompanion) return {
|
|
880
1137
|
isEnvelope: true,
|
|
881
1138
|
dataPath: arrayPropName
|
|
882
1139
|
};
|
|
883
|
-
if (
|
|
1140
|
+
if (hasPaginationCompanion) return {
|
|
884
1141
|
isEnvelope: true,
|
|
885
1142
|
dataPath: arrayPropName
|
|
886
1143
|
};
|
|
@@ -1104,7 +1361,7 @@ function qualifyNestedName(parentName, fieldName) {
|
|
|
1104
1361
|
const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
|
|
1105
1362
|
const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
|
|
1106
1363
|
if (match && match[2]) {
|
|
1107
|
-
const trailing = singularize(match[2]);
|
|
1364
|
+
const trailing = singularize$1(match[2]);
|
|
1108
1365
|
return match[1] + trailing;
|
|
1109
1366
|
}
|
|
1110
1367
|
return qualified;
|
|
@@ -1184,13 +1441,33 @@ function extractOperations(paths, operationIdTransform) {
|
|
|
1184
1441
|
const op = pathItem[method];
|
|
1185
1442
|
if (!op) continue;
|
|
1186
1443
|
const serviceName = inferServiceName(path, op.tags?.[0]);
|
|
1187
|
-
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform);
|
|
1444
|
+
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName);
|
|
1188
1445
|
inlineModels.push(...opModels);
|
|
1189
1446
|
const ops = serviceMap.get(serviceName) ?? [];
|
|
1190
1447
|
ops.push(operation);
|
|
1191
1448
|
serviceMap.set(serviceName, ops);
|
|
1192
1449
|
}
|
|
1193
1450
|
}
|
|
1451
|
+
for (const [serviceName, operations] of serviceMap) {
|
|
1452
|
+
const pathGroups = /* @__PURE__ */ new Map();
|
|
1453
|
+
for (const op of operations) {
|
|
1454
|
+
const firstSeg = op.path.split("/").filter(Boolean)[0] ?? "";
|
|
1455
|
+
const groupKey = firstSeg.startsWith("{") ? serviceName : toPascalCase(firstSeg);
|
|
1456
|
+
const group = pathGroups.get(groupKey) ?? [];
|
|
1457
|
+
group.push(op);
|
|
1458
|
+
pathGroups.set(groupKey, group);
|
|
1459
|
+
}
|
|
1460
|
+
if (pathGroups.size > 1) {
|
|
1461
|
+
if ([...pathGroups.keys()].some((k) => k !== serviceName)) {
|
|
1462
|
+
serviceMap.delete(serviceName);
|
|
1463
|
+
for (const [groupName, groupOps] of pathGroups) {
|
|
1464
|
+
const existing = serviceMap.get(groupName) ?? [];
|
|
1465
|
+
existing.push(...groupOps);
|
|
1466
|
+
serviceMap.set(groupName, existing);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1194
1471
|
for (const [, operations] of serviceMap) disambiguateOperationNames(operations);
|
|
1195
1472
|
return {
|
|
1196
1473
|
services: Array.from(serviceMap.entries()).map(([name, operations]) => ({
|
|
@@ -1306,13 +1583,14 @@ function inferDeeperContext(path) {
|
|
|
1306
1583
|
if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
|
|
1307
1584
|
return null;
|
|
1308
1585
|
}
|
|
1309
|
-
function buildOperation(method, path, op, pathLevelParams, operationIdTransform) {
|
|
1586
|
+
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
|
|
1310
1587
|
const allParams = [...pathLevelParams, ...op.parameters ?? []];
|
|
1311
1588
|
const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
|
|
1312
|
-
const
|
|
1313
|
-
const
|
|
1314
|
-
const
|
|
1315
|
-
const
|
|
1589
|
+
const opContext = serviceName;
|
|
1590
|
+
const pathParams = extractParams(allParams, "path", opContext);
|
|
1591
|
+
const queryParams = extractParams(allParams, "query", opContext);
|
|
1592
|
+
const headerParams = extractParams(allParams, "header", opContext).filter((p) => p.name.toLowerCase() !== "idempotency-key");
|
|
1593
|
+
const cookieParams = extractParams(allParams, "cookie", opContext);
|
|
1316
1594
|
const reqBodyModels = [];
|
|
1317
1595
|
const { body: requestBody, encoding: requestBodyEncoding } = extractRequestBody(op.requestBody, op, reqBodyModels);
|
|
1318
1596
|
const { response, successResponses, errors, inlineModels, isPaginated, dataPath: responseDataPath, itemType: responseItemType } = extractResponses(op.responses, op, path, method);
|
|
@@ -1329,6 +1607,7 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1329
1607
|
itemType
|
|
1330
1608
|
};
|
|
1331
1609
|
} else if (paginationFromParams) pagination = paginationFromParams;
|
|
1610
|
+
const security = extractOperationSecurity(op.security);
|
|
1332
1611
|
return {
|
|
1333
1612
|
operation: {
|
|
1334
1613
|
name: inferOperationName(method, path, op.operationId, operationIdTransform),
|
|
@@ -1347,19 +1626,35 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1347
1626
|
pagination,
|
|
1348
1627
|
injectIdempotencyKey: hasIdempotencyHeader,
|
|
1349
1628
|
deprecated: op.deprecated || void 0,
|
|
1350
|
-
async: op["x-oagen-async"]
|
|
1629
|
+
async: op["x-oagen-async"],
|
|
1630
|
+
security
|
|
1351
1631
|
},
|
|
1352
1632
|
inlineModels
|
|
1353
1633
|
};
|
|
1354
1634
|
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Extract per-operation security requirements from an OpenAPI security directive.
|
|
1637
|
+
* Returns undefined when the operation uses the spec-level default security.
|
|
1638
|
+
*
|
|
1639
|
+
* OpenAPI security format: `security: [{ schemeName: [scope1, scope2] }]`
|
|
1640
|
+
*/
|
|
1641
|
+
function extractOperationSecurity(security) {
|
|
1642
|
+
if (!security || security.length === 0) return void 0;
|
|
1643
|
+
const requirements = [];
|
|
1644
|
+
for (const entry of security) for (const [schemeName, scopes] of Object.entries(entry)) requirements.push({
|
|
1645
|
+
schemeName,
|
|
1646
|
+
scopes: scopes ?? []
|
|
1647
|
+
});
|
|
1648
|
+
return requirements.length > 0 ? requirements : void 0;
|
|
1649
|
+
}
|
|
1355
1650
|
function buildDescription(summary, description) {
|
|
1356
1651
|
if (summary && description && summary !== description) return `${summary}\n\n${description}`;
|
|
1357
1652
|
return description ?? summary;
|
|
1358
1653
|
}
|
|
1359
|
-
function extractParams(params, location) {
|
|
1654
|
+
function extractParams(params, location, operationContext) {
|
|
1360
1655
|
return params.filter((p) => p.in === location).map((p) => ({
|
|
1361
1656
|
name: p.name,
|
|
1362
|
-
type: p.schema ? schemaToTypeRef(p.schema, p.name) : {
|
|
1657
|
+
type: p.schema ? schemaToTypeRef(p.schema, p.name, operationContext ? toPascalCase(operationContext) : void 0) : {
|
|
1363
1658
|
kind: "primitive",
|
|
1364
1659
|
type: "string"
|
|
1365
1660
|
},
|
|
@@ -1545,7 +1840,14 @@ function extractResponses(responses, op, path, method) {
|
|
|
1545
1840
|
statusCode: code,
|
|
1546
1841
|
type: extractedType
|
|
1547
1842
|
});
|
|
1548
|
-
} else if (code >= 400) {
|
|
1843
|
+
} else if (code >= 300 && code < 400) allSuccessResponses.push({
|
|
1844
|
+
statusCode: code,
|
|
1845
|
+
type: {
|
|
1846
|
+
kind: "primitive",
|
|
1847
|
+
type: "unknown"
|
|
1848
|
+
}
|
|
1849
|
+
});
|
|
1850
|
+
else if (code >= 400) {
|
|
1549
1851
|
const jsonContent = resp.content?.["application/json"];
|
|
1550
1852
|
const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
|
|
1551
1853
|
errors.push({
|
|
@@ -1672,6 +1974,19 @@ function collectInlineEnumsFromModels(models, enums) {
|
|
|
1672
1974
|
const enumNames = new Set(enums.map((e) => e.name));
|
|
1673
1975
|
for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
|
|
1674
1976
|
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Collect inline enum definitions from operation parameters (query, path, header)
|
|
1979
|
+
* and promote them to top-level enums so emitters can generate typed enum files.
|
|
1980
|
+
*/
|
|
1981
|
+
function collectInlineEnumsFromOperations(services, enums) {
|
|
1982
|
+
const enumNames = new Set(enums.map((e) => e.name));
|
|
1983
|
+
for (const service of services) for (const op of service.operations) for (const param of [
|
|
1984
|
+
...op.pathParams,
|
|
1985
|
+
...op.queryParams,
|
|
1986
|
+
...op.headerParams,
|
|
1987
|
+
...op.cookieParams ?? []
|
|
1988
|
+
]) collectInlineEnumFromRef(param.type, enums, enumNames);
|
|
1989
|
+
}
|
|
1675
1990
|
//#endregion
|
|
1676
1991
|
//#region src/parser/normalize-model-refs.ts
|
|
1677
1992
|
function validateModelRefs(spec) {
|
|
@@ -1701,10 +2016,12 @@ async function parseSpec(specPath, options) {
|
|
|
1701
2016
|
const spec = parsed;
|
|
1702
2017
|
const version = spec.openapi ?? "";
|
|
1703
2018
|
if (!version.startsWith("3.")) throw new SpecParseError(`Unsupported OpenAPI version: ${version}. oagen requires OpenAPI 3.x`, `Update the spec to OpenAPI 3.0 or 3.1. If you are using Swagger 2.x, convert it first with \`npx swagger2openapi ${specPath}\`.`);
|
|
1704
|
-
const { models, enums } = extractSchemas(spec.components?.schemas);
|
|
2019
|
+
const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
|
|
1705
2020
|
const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
|
|
2021
|
+
clearSchemaNameTransform();
|
|
1706
2022
|
const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
|
|
1707
2023
|
collectInlineEnumsFromModels(finalModels, enums);
|
|
2024
|
+
collectInlineEnumsFromOperations(services, enums);
|
|
1708
2025
|
const auth = extractAuthSchemes(spec.components?.securitySchemes);
|
|
1709
2026
|
const serverEntries = (spec.servers ?? []).map((s) => ({
|
|
1710
2027
|
url: s.url ?? "",
|
|
@@ -1719,7 +2036,8 @@ async function parseSpec(specPath, options) {
|
|
|
1719
2036
|
services,
|
|
1720
2037
|
models: finalModels,
|
|
1721
2038
|
enums,
|
|
1722
|
-
auth
|
|
2039
|
+
auth,
|
|
2040
|
+
sdk: defaultSdkBehavior()
|
|
1723
2041
|
};
|
|
1724
2042
|
validateModelRefs(result);
|
|
1725
2043
|
return result;
|
|
@@ -2655,8 +2973,9 @@ const phpMergeAdapter = {
|
|
|
2655
2973
|
testFilePatterns: [/Test\.php$/],
|
|
2656
2974
|
urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
|
|
2657
2975
|
resolveGrammar: (mod) => {
|
|
2658
|
-
|
|
2659
|
-
return
|
|
2976
|
+
const m = mod?.default ?? mod;
|
|
2977
|
+
if (typeof m === "object" && m !== null && "php" in m) return m.php;
|
|
2978
|
+
return m;
|
|
2660
2979
|
},
|
|
2661
2980
|
parseStatements(tree, source) {
|
|
2662
2981
|
const imports = [];
|
|
@@ -3162,6 +3481,35 @@ async function extractStatements(source, language) {
|
|
|
3162
3481
|
return adapter.parseStatements(tree, source);
|
|
3163
3482
|
}
|
|
3164
3483
|
/**
|
|
3484
|
+
* Return a map of top-level class name → { bodyEndLine, bodyEndCol } using
|
|
3485
|
+
* tree-sitter. `bodyEndLine` is 0-based. For brace-delimited classes the
|
|
3486
|
+
* end points at the closing brace line; for indentation-delimited classes
|
|
3487
|
+
* it points one past the last line of the class body.
|
|
3488
|
+
*/
|
|
3489
|
+
async function extractClassEndLines(source, language) {
|
|
3490
|
+
const tree = safeParse(await getParser(language), source);
|
|
3491
|
+
const map = /* @__PURE__ */ new Map();
|
|
3492
|
+
function processClassNode(node) {
|
|
3493
|
+
const nameNode = node.childForFieldName("name");
|
|
3494
|
+
if (!nameNode) return;
|
|
3495
|
+
const name = nameNode.text;
|
|
3496
|
+
const body = node.childForFieldName("body");
|
|
3497
|
+
const endRow = body ? body.endPosition.row : node.endPosition.row;
|
|
3498
|
+
if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
|
|
3499
|
+
else map.set(name, { bodyEndLine: endRow + 1 });
|
|
3500
|
+
}
|
|
3501
|
+
for (const child of tree.rootNode.children) {
|
|
3502
|
+
if (child.type === "class_definition" || child.type === "class_declaration") processClassNode(child);
|
|
3503
|
+
if (child.type === "decorated_definition") {
|
|
3504
|
+
for (const inner of child.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
|
|
3505
|
+
}
|
|
3506
|
+
if (child.type === "export_statement") {
|
|
3507
|
+
for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
|
|
3508
|
+
}
|
|
3509
|
+
}
|
|
3510
|
+
return map;
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3165
3513
|
* Merge generated content into an existing file.
|
|
3166
3514
|
*
|
|
3167
3515
|
* - Parses both files with tree-sitter
|
|
@@ -3310,11 +3658,27 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
3310
3658
|
const addedParts = [...toAppend];
|
|
3311
3659
|
if (insertions) for (const ins of insertions) addedParts.push(ins.text);
|
|
3312
3660
|
const addedCodeText = addedParts.join("\n");
|
|
3313
|
-
const filteredImports =
|
|
3661
|
+
const filteredImports = [];
|
|
3662
|
+
for (const imp of newImports) {
|
|
3314
3663
|
const braceMatch = imp.text.match(/\{([^}]+)\}/);
|
|
3315
|
-
if (!braceMatch)
|
|
3316
|
-
|
|
3317
|
-
|
|
3664
|
+
if (!braceMatch) {
|
|
3665
|
+
filteredImports.push(imp);
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3668
|
+
const names = braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean);
|
|
3669
|
+
const usedNames = names.filter((name) => addedCodeText.includes(name));
|
|
3670
|
+
if (usedNames.length === 0) continue;
|
|
3671
|
+
if (usedNames.length === names.length) filteredImports.push(imp);
|
|
3672
|
+
else {
|
|
3673
|
+
const prefix = imp.text.trimStart().startsWith("import type") ? "import type" : "import";
|
|
3674
|
+
const sourceMatch = imp.text.match(/from\s+(['"][^'"]+['"]);?/);
|
|
3675
|
+
if (sourceMatch) filteredImports.push({
|
|
3676
|
+
key: imp.key,
|
|
3677
|
+
text: `${prefix} { ${usedNames.join(", ")} } from ${sourceMatch[1]};`
|
|
3678
|
+
});
|
|
3679
|
+
else filteredImports.push(imp);
|
|
3680
|
+
}
|
|
3681
|
+
}
|
|
3318
3682
|
if (filteredImports.length > 0) {
|
|
3319
3683
|
const renderedImports = adapter.renderImports ? adapter.renderImports(filteredImports) : filteredImports.map((entry) => entry.text);
|
|
3320
3684
|
const lines = result.split("\n");
|
|
@@ -3472,13 +3836,13 @@ async function writeFiles(files, outputDir, options) {
|
|
|
3472
3836
|
const language = options?.language;
|
|
3473
3837
|
const header = options?.header ?? "";
|
|
3474
3838
|
for (const file of sorted) {
|
|
3475
|
-
const fullPath = path.join(outputDir, file.path);
|
|
3839
|
+
const fullPath = path$1.join(outputDir, file.path);
|
|
3476
3840
|
let existingContent = null;
|
|
3477
3841
|
try {
|
|
3478
3842
|
existingContent = await fs.readFile(fullPath, "utf-8");
|
|
3479
3843
|
} catch {}
|
|
3480
3844
|
if (existingContent === null) {
|
|
3481
|
-
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
3845
|
+
await fs.mkdir(path$1.dirname(fullPath), { recursive: true });
|
|
3482
3846
|
await fs.writeFile(fullPath, file.content, "utf-8");
|
|
3483
3847
|
result.written.push(file.path);
|
|
3484
3848
|
continue;
|
|
@@ -3494,15 +3858,18 @@ async function writeFiles(files, outputDir, options) {
|
|
|
3494
3858
|
}
|
|
3495
3859
|
const adapter = language ? getMergeAdapter(language) : void 0;
|
|
3496
3860
|
if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
|
|
3497
|
-
|
|
3498
|
-
|
|
3861
|
+
if (!(header && existingContent.startsWith(header))) {
|
|
3862
|
+
result.skipped.push(file.path);
|
|
3863
|
+
continue;
|
|
3864
|
+
}
|
|
3499
3865
|
}
|
|
3500
3866
|
if (existingContent === file.content) {
|
|
3501
3867
|
result.identical.push(file.path);
|
|
3502
3868
|
continue;
|
|
3503
3869
|
}
|
|
3504
3870
|
if (file.overwriteExisting) {
|
|
3505
|
-
|
|
3871
|
+
const content = existingContent.includes("@oagen-ignore-start") && language && hasGrammar(language) ? await overwriteWithPreservedRegions(existingContent, file.content, language) : file.content;
|
|
3872
|
+
await fs.writeFile(fullPath, content, "utf-8");
|
|
3506
3873
|
result.written.push(file.path);
|
|
3507
3874
|
continue;
|
|
3508
3875
|
}
|
|
@@ -3546,6 +3913,311 @@ async function writeFiles(files, outputDir, options) {
|
|
|
3546
3913
|
function isDefaultTestFile(filePath) {
|
|
3547
3914
|
return /\.(spec|test)\.[jt]sx?$/.test(filePath) || filePath.endsWith("_test.go") || filePath.endsWith("_test.rb") || filePath.endsWith("_spec.rb") || /(?:^|\/)test_.*\.py$/.test(filePath) || filePath.endsWith("_test.py") || filePath.endsWith("Test.php") || filePath.endsWith("_test.rs") || /(?:^|\/)tests\/.*\.rs$/.test(filePath);
|
|
3548
3915
|
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Overwrite a file with generated content while preserving
|
|
3918
|
+
* `@oagen-ignore-start` / `@oagen-ignore-end` regions (and their
|
|
3919
|
+
* required imports) from the existing file.
|
|
3920
|
+
*
|
|
3921
|
+
* Uses tree-sitter via `extractClassEndLines` to find accurate class
|
|
3922
|
+
* boundaries so ignore blocks are inserted at the correct position even
|
|
3923
|
+
* when docstrings contain unindented text.
|
|
3924
|
+
*/
|
|
3925
|
+
async function overwriteWithPreservedRegions(existingContent, generatedContent, language) {
|
|
3926
|
+
const existingLines = existingContent.split("\n");
|
|
3927
|
+
const generatedLines = generatedContent.split("\n");
|
|
3928
|
+
const blocks = [];
|
|
3929
|
+
for (let i = 0; i < existingLines.length; i++) {
|
|
3930
|
+
if (!existingLines[i].includes("@oagen-ignore-start")) continue;
|
|
3931
|
+
const startIdx = i;
|
|
3932
|
+
while (i < existingLines.length && !existingLines[i].includes("@oagen-ignore-end")) i++;
|
|
3933
|
+
blocks.push({
|
|
3934
|
+
containingClass: findContainingClass(existingLines, startIdx),
|
|
3935
|
+
lines: existingLines.slice(startIdx, i + 1)
|
|
3936
|
+
});
|
|
3937
|
+
}
|
|
3938
|
+
if (blocks.length === 0) return generatedContent;
|
|
3939
|
+
const result = [...generatedLines];
|
|
3940
|
+
spliceExtraImports(existingLines, result);
|
|
3941
|
+
const classEndLines = await extractClassEndLines(result.join("\n"), language);
|
|
3942
|
+
const blocksByClass = /* @__PURE__ */ new Map();
|
|
3943
|
+
for (const b of blocks) {
|
|
3944
|
+
if (!blocksByClass.has(b.containingClass)) blocksByClass.set(b.containingClass, []);
|
|
3945
|
+
blocksByClass.get(b.containingClass).push(b.lines);
|
|
3946
|
+
}
|
|
3947
|
+
const replacedClasses = /* @__PURE__ */ new Set();
|
|
3948
|
+
const handledBlockKeys = /* @__PURE__ */ new Set();
|
|
3949
|
+
const replacements = [];
|
|
3950
|
+
for (const [className, classBlocks] of blocksByClass) {
|
|
3951
|
+
let targetClass = className;
|
|
3952
|
+
if (!targetClass) for (const blockLines of classBlocks) {
|
|
3953
|
+
for (const l of blockLines) {
|
|
3954
|
+
const m = l.match(/^(?:export\s+)?class\s+(\w+)/);
|
|
3955
|
+
if (m && classEndLines.has(m[1])) {
|
|
3956
|
+
targetClass = m[1];
|
|
3957
|
+
break;
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
if (targetClass) break;
|
|
3961
|
+
}
|
|
3962
|
+
if (!targetClass) continue;
|
|
3963
|
+
if (!classBlocks.some((blockLines) => blockLines.some((l) => {
|
|
3964
|
+
return l.match(/^(?:export\s+)?class\s+(\w+)/)?.[1] === targetClass;
|
|
3965
|
+
}))) continue;
|
|
3966
|
+
const info = classEndLines.get(targetClass);
|
|
3967
|
+
if (!info) continue;
|
|
3968
|
+
const startLine = findClassDeclarationStart(result, targetClass, info.bodyEndLine);
|
|
3969
|
+
if (startLine < 0) continue;
|
|
3970
|
+
const content = [];
|
|
3971
|
+
for (const block of classBlocks) {
|
|
3972
|
+
content.push("");
|
|
3973
|
+
content.push(...block);
|
|
3974
|
+
}
|
|
3975
|
+
replacements.push({
|
|
3976
|
+
startLine,
|
|
3977
|
+
endLine: info.bodyEndLine,
|
|
3978
|
+
content
|
|
3979
|
+
});
|
|
3980
|
+
replacedClasses.add(targetClass);
|
|
3981
|
+
handledBlockKeys.add(className);
|
|
3982
|
+
}
|
|
3983
|
+
replacements.sort((a, b) => b.startLine - a.startLine);
|
|
3984
|
+
for (const rep of replacements) result.splice(rep.startLine, rep.endLine - rep.startLine, ...rep.content);
|
|
3985
|
+
let effectiveClassEndLines = classEndLines;
|
|
3986
|
+
if (replacedClasses.size > 0) effectiveClassEndLines = await extractClassEndLines(result.join("\n"), language);
|
|
3987
|
+
const insertions = [];
|
|
3988
|
+
for (const [className, classBlocks] of blocksByClass) {
|
|
3989
|
+
if (handledBlockKeys.has(className) || replacedClasses.has(className)) continue;
|
|
3990
|
+
const info = className ? effectiveClassEndLines.get(className) : void 0;
|
|
3991
|
+
const insertLine = info ? info.bodyEndLine : result.length;
|
|
3992
|
+
const content = [];
|
|
3993
|
+
for (const block of classBlocks) {
|
|
3994
|
+
content.push("");
|
|
3995
|
+
content.push(...block);
|
|
3996
|
+
}
|
|
3997
|
+
insertions.push({
|
|
3998
|
+
line: insertLine,
|
|
3999
|
+
content
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
insertions.sort((a, b) => b.line - a.line);
|
|
4003
|
+
for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
|
|
4004
|
+
return result.join("\n");
|
|
4005
|
+
}
|
|
4006
|
+
/** Scan backwards from `lineIdx` to find the nearest class declaration name. */
|
|
4007
|
+
function findContainingClass(lines, lineIdx) {
|
|
4008
|
+
for (let j = lineIdx - 1; j >= 0; j--) {
|
|
4009
|
+
const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
|
|
4010
|
+
if (m) return m[1];
|
|
4011
|
+
}
|
|
4012
|
+
return "";
|
|
4013
|
+
}
|
|
4014
|
+
/**
|
|
4015
|
+
* Find the start line of a class declaration (including decorators above it)
|
|
4016
|
+
* by scanning backwards from `beforeLine`.
|
|
4017
|
+
*/
|
|
4018
|
+
function findClassDeclarationStart(lines, className, beforeLine) {
|
|
4019
|
+
const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
|
|
4020
|
+
for (let i = beforeLine - 1; i >= 0; i--) {
|
|
4021
|
+
if (!classPattern.test(lines[i])) continue;
|
|
4022
|
+
let start = i;
|
|
4023
|
+
while (start > 0 && /^\s*@\w+/.test(lines[start - 1])) start--;
|
|
4024
|
+
return start;
|
|
4025
|
+
}
|
|
4026
|
+
return -1;
|
|
4027
|
+
}
|
|
4028
|
+
/**
|
|
4029
|
+
* Collect `from X import Y, Z` identifiers grouped by module, handling
|
|
4030
|
+
* both single-line and multiline (parenthesized) forms.
|
|
4031
|
+
*/
|
|
4032
|
+
function collectFromImports(lines) {
|
|
4033
|
+
const byModule = /* @__PURE__ */ new Map();
|
|
4034
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4035
|
+
if (!/^from\s+/.test(lines[i])) continue;
|
|
4036
|
+
let fullText = lines[i];
|
|
4037
|
+
if (fullText.includes("(") && !fullText.includes(")")) while (i + 1 < lines.length) {
|
|
4038
|
+
i++;
|
|
4039
|
+
fullText += " " + lines[i].trim();
|
|
4040
|
+
if (lines[i].includes(")")) break;
|
|
4041
|
+
}
|
|
4042
|
+
const match = fullText.replace(/[()]/g, "").replace(/\s+/g, " ").trim().match(/^from\s+(\S+)\s+import\s+(.+)$/);
|
|
4043
|
+
if (!match) continue;
|
|
4044
|
+
const mod = match[1];
|
|
4045
|
+
if (!byModule.has(mod)) byModule.set(mod, /* @__PURE__ */ new Set());
|
|
4046
|
+
for (const id of match[2].split(",").map((s) => s.trim()).filter(Boolean)) byModule.get(mod).add(id);
|
|
4047
|
+
}
|
|
4048
|
+
return byModule;
|
|
4049
|
+
}
|
|
4050
|
+
/**
|
|
4051
|
+
* Add imports from the existing file that are absent from the generated
|
|
4052
|
+
* result. Handles both `import X` and `from X import Y` forms.
|
|
4053
|
+
* Mutates `resultLines` in place.
|
|
4054
|
+
*/
|
|
4055
|
+
function spliceExtraImports(existingLines, resultLines) {
|
|
4056
|
+
const isSimpleImport = (l) => /^import\s+\w/.test(l);
|
|
4057
|
+
const existingSimple = new Set(existingLines.filter(isSimpleImport).map((l) => l.trim()));
|
|
4058
|
+
const generatedSimple = new Set(resultLines.filter(isSimpleImport).map((l) => l.trim()));
|
|
4059
|
+
const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
|
|
4060
|
+
const existingFrom = collectFromImports(existingLines);
|
|
4061
|
+
const generatedFrom = collectFromImports(resultLines);
|
|
4062
|
+
const extraFromLines = [];
|
|
4063
|
+
for (const [mod, existIds] of existingFrom) {
|
|
4064
|
+
const genIds = generatedFrom.get(mod);
|
|
4065
|
+
if (!genIds) extraFromLines.push(`from ${mod} import ${[...existIds].join(", ")}`);
|
|
4066
|
+
else {
|
|
4067
|
+
const extra = [...existIds].filter((id) => !genIds.has(id));
|
|
4068
|
+
if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
|
|
4069
|
+
}
|
|
4070
|
+
}
|
|
4071
|
+
const allExtra = [...extraSimple, ...extraFromLines];
|
|
4072
|
+
if (allExtra.length === 0) return;
|
|
4073
|
+
let lastImportIdx = -1;
|
|
4074
|
+
for (let i = 0; i < resultLines.length; i++) if (isSimpleImport(resultLines[i]) || /^from\s+/.test(resultLines[i])) {
|
|
4075
|
+
lastImportIdx = i;
|
|
4076
|
+
if (resultLines[i].includes("(") && !resultLines[i].includes(")")) while (i + 1 < resultLines.length) {
|
|
4077
|
+
i++;
|
|
4078
|
+
lastImportIdx = i;
|
|
4079
|
+
if (resultLines[i].includes(")")) break;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
if (lastImportIdx >= 0) resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
|
|
4083
|
+
}
|
|
4084
|
+
//#endregion
|
|
4085
|
+
//#region src/ir/operation-hints.ts
|
|
4086
|
+
/** Minimal snake_case converter for path segments (avoids cross-layer import). */
|
|
4087
|
+
function toSnake(s) {
|
|
4088
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s.]+/g, "_").toLowerCase();
|
|
4089
|
+
}
|
|
4090
|
+
/**
|
|
4091
|
+
* Action verbs recognised at the terminal path segment.
|
|
4092
|
+
* When a path ends with one of these, we use it as the verb instead of
|
|
4093
|
+
* deriving a CRUD verb from the HTTP method.
|
|
4094
|
+
*/
|
|
4095
|
+
const ACTION_VERBS = new Set([
|
|
4096
|
+
"accept",
|
|
4097
|
+
"activate",
|
|
4098
|
+
"authorize",
|
|
4099
|
+
"challenge",
|
|
4100
|
+
"check",
|
|
4101
|
+
"complete",
|
|
4102
|
+
"confirm",
|
|
4103
|
+
"deactivate",
|
|
4104
|
+
"disable",
|
|
4105
|
+
"enable",
|
|
4106
|
+
"enroll",
|
|
4107
|
+
"generate",
|
|
4108
|
+
"reactivate",
|
|
4109
|
+
"resend",
|
|
4110
|
+
"revoke",
|
|
4111
|
+
"send",
|
|
4112
|
+
"verify"
|
|
4113
|
+
]);
|
|
4114
|
+
/** Map HTTP method → default CRUD verb. */
|
|
4115
|
+
const CRUD_VERB = {
|
|
4116
|
+
get: "list",
|
|
4117
|
+
post: "create",
|
|
4118
|
+
put: "update",
|
|
4119
|
+
patch: "update",
|
|
4120
|
+
delete: "delete",
|
|
4121
|
+
head: "get",
|
|
4122
|
+
options: "list",
|
|
4123
|
+
trace: "list"
|
|
4124
|
+
};
|
|
4125
|
+
/**
|
|
4126
|
+
* Naive singularization: strips trailing "s" with common-sense exceptions.
|
|
4127
|
+
* Good enough for API resource nouns; not a general-purpose English stemmer.
|
|
4128
|
+
*/
|
|
4129
|
+
function singularize(word) {
|
|
4130
|
+
if (word.length <= 2) return word;
|
|
4131
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
4132
|
+
if (word.endsWith("ses") || word.endsWith("zes") || word.endsWith("xes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
|
|
4133
|
+
if (word.endsWith("ss") || word.endsWith("us") || word.endsWith("is")) return word;
|
|
4134
|
+
if (word.endsWith("s")) return word.slice(0, -1);
|
|
4135
|
+
return word;
|
|
4136
|
+
}
|
|
4137
|
+
/**
|
|
4138
|
+
* Derive a snake_case method name from an operation's HTTP method and path.
|
|
4139
|
+
*
|
|
4140
|
+
* Rules:
|
|
4141
|
+
* 1. Strip path-param segments (`{id}`, `{slug}`, etc.)
|
|
4142
|
+
* 2. If the terminal segment is an action verb, use it as the verb
|
|
4143
|
+
* 3. Otherwise, use the CRUD verb for the HTTP method
|
|
4144
|
+
* 4. For GET on a collection (no trailing {id}), use "list"; with {id}, use "get"
|
|
4145
|
+
* 5. For POST on a resource path, if only one path param, use "create" singular
|
|
4146
|
+
* 6. Singularize the resource noun for single-resource operations
|
|
4147
|
+
*/
|
|
4148
|
+
function deriveMethodName(op, _service) {
|
|
4149
|
+
const segments = op.path.split("/").filter(Boolean);
|
|
4150
|
+
const staticSegments = [];
|
|
4151
|
+
let trailingParam = false;
|
|
4152
|
+
for (const seg of segments) if (seg.startsWith("{") && seg.endsWith("}")) trailingParam = true;
|
|
4153
|
+
else {
|
|
4154
|
+
staticSegments.push(seg);
|
|
4155
|
+
trailingParam = false;
|
|
4156
|
+
}
|
|
4157
|
+
if (staticSegments.length === 0) return `${CRUD_VERB[op.httpMethod] ?? "get"}_root`;
|
|
4158
|
+
const terminal = toSnake(staticSegments[staticSegments.length - 1]);
|
|
4159
|
+
if (ACTION_VERBS.has(terminal)) {
|
|
4160
|
+
const context = staticSegments.slice(0, -1).filter((s) => !s.startsWith("{"));
|
|
4161
|
+
const resource = context.length > 0 ? toSnake(singularize(context[context.length - 1])) : "";
|
|
4162
|
+
if (resource) return `${terminal}_${resource}`;
|
|
4163
|
+
return terminal;
|
|
4164
|
+
}
|
|
4165
|
+
let verb = CRUD_VERB[op.httpMethod] ?? "get";
|
|
4166
|
+
if (op.httpMethod === "get" && trailingParam) verb = "get";
|
|
4167
|
+
let resource = toSnake(terminal);
|
|
4168
|
+
if (staticSegments.length >= 2) {
|
|
4169
|
+
const penultimate = staticSegments[staticSegments.length - 2];
|
|
4170
|
+
const penIdx = segments.lastIndexOf(penultimate);
|
|
4171
|
+
const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
|
|
4172
|
+
if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
|
|
4173
|
+
}
|
|
4174
|
+
const noun = trailingParam ? singularize(resource) : resource;
|
|
4175
|
+
return `${verb}_${verb === "list" ? resource : noun}`;
|
|
4176
|
+
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Resolve all operations in the spec, applying hints and mount rules.
|
|
4179
|
+
*
|
|
4180
|
+
* @param spec - The parsed API spec
|
|
4181
|
+
* @param hints - Per-operation overrides keyed by "METHOD /path"
|
|
4182
|
+
* @param mountRules - Service name → target service mappings
|
|
4183
|
+
* @returns Resolved operations ready for emitter consumption
|
|
4184
|
+
*/
|
|
4185
|
+
function resolveOperations(spec, hints, mountRules) {
|
|
4186
|
+
const resolved = [];
|
|
4187
|
+
const hintMap = hints ?? {};
|
|
4188
|
+
const mounts = mountRules ?? {};
|
|
4189
|
+
for (const service of spec.services) for (const op of service.operations) {
|
|
4190
|
+
const hint = hintMap[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
4191
|
+
const methodName = hint?.name ?? deriveMethodName(op, service);
|
|
4192
|
+
const mountOn = hint?.mountOn ?? mounts[service.name] ?? service.name;
|
|
4193
|
+
let wrappers;
|
|
4194
|
+
if (hint?.split) wrappers = hint.split.map((sh) => ({
|
|
4195
|
+
name: sh.name,
|
|
4196
|
+
targetVariant: sh.targetVariant,
|
|
4197
|
+
defaults: sh.defaults ?? {},
|
|
4198
|
+
inferFromClient: sh.inferFromClient ?? [],
|
|
4199
|
+
exposedParams: sh.exposedParams ?? [],
|
|
4200
|
+
optionalParams: [],
|
|
4201
|
+
responseModelName: resolveResponseModelName(op)
|
|
4202
|
+
}));
|
|
4203
|
+
resolved.push({
|
|
4204
|
+
operation: op,
|
|
4205
|
+
service,
|
|
4206
|
+
methodName,
|
|
4207
|
+
mountOn,
|
|
4208
|
+
wrappers
|
|
4209
|
+
});
|
|
4210
|
+
}
|
|
4211
|
+
return resolved;
|
|
4212
|
+
}
|
|
4213
|
+
/** Extract the response model name from an operation (for wrapper metadata). */
|
|
4214
|
+
function resolveResponseModelName(op) {
|
|
4215
|
+
if (op.httpMethod === "delete") return null;
|
|
4216
|
+
const ref = op.response;
|
|
4217
|
+
if (ref.kind === "model") return ref.name;
|
|
4218
|
+
if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
|
|
4219
|
+
return null;
|
|
4220
|
+
}
|
|
3549
4221
|
//#endregion
|
|
3550
4222
|
//#region src/engine/generate-files.ts
|
|
3551
4223
|
function buildEmitterContext(spec, options) {
|
|
@@ -3555,20 +4227,79 @@ function buildEmitterContext(spec, options) {
|
|
|
3555
4227
|
spec,
|
|
3556
4228
|
outputDir: options.outputDir,
|
|
3557
4229
|
apiSurface: options.apiSurface,
|
|
3558
|
-
overlayLookup: options.overlayLookup
|
|
4230
|
+
overlayLookup: options.overlayLookup,
|
|
4231
|
+
resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
|
|
3559
4232
|
};
|
|
3560
4233
|
}
|
|
4234
|
+
/**
|
|
4235
|
+
* Collect model and enum names transitively referenced by service operations.
|
|
4236
|
+
* Walks operation parameters, request bodies, responses, and pagination item
|
|
4237
|
+
* types, then chases model field references until the set stabilizes.
|
|
4238
|
+
*/
|
|
4239
|
+
function collectReferencedNames(services, models) {
|
|
4240
|
+
const referencedModels = /* @__PURE__ */ new Set();
|
|
4241
|
+
const referencedEnums = /* @__PURE__ */ new Set();
|
|
4242
|
+
const collectFromTypeRef = (ref) => {
|
|
4243
|
+
walkTypeRef(ref, {
|
|
4244
|
+
model: (r) => referencedModels.add(r.name),
|
|
4245
|
+
enum: (r) => referencedEnums.add(r.name)
|
|
4246
|
+
});
|
|
4247
|
+
};
|
|
4248
|
+
for (const service of services) for (const op of service.operations) {
|
|
4249
|
+
for (const p of [
|
|
4250
|
+
...op.pathParams,
|
|
4251
|
+
...op.queryParams,
|
|
4252
|
+
...op.headerParams,
|
|
4253
|
+
...op.cookieParams ?? []
|
|
4254
|
+
]) collectFromTypeRef(p.type);
|
|
4255
|
+
if (op.requestBody) collectFromTypeRef(op.requestBody);
|
|
4256
|
+
collectFromTypeRef(op.response);
|
|
4257
|
+
if (op.pagination) collectFromTypeRef(op.pagination.itemType);
|
|
4258
|
+
for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
|
|
4259
|
+
if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
|
|
4260
|
+
}
|
|
4261
|
+
for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
|
|
4262
|
+
const modelsByName = new Map(models.map((m) => [m.name, m]));
|
|
4263
|
+
const visited = /* @__PURE__ */ new Set();
|
|
4264
|
+
const queue = [...referencedModels];
|
|
4265
|
+
while (queue.length > 0) {
|
|
4266
|
+
const name = queue.pop();
|
|
4267
|
+
if (visited.has(name)) continue;
|
|
4268
|
+
visited.add(name);
|
|
4269
|
+
const model = modelsByName.get(name);
|
|
4270
|
+
if (!model) continue;
|
|
4271
|
+
for (const field of model.fields) {
|
|
4272
|
+
collectFromTypeRef(field.type);
|
|
4273
|
+
for (const m of referencedModels) if (!visited.has(m)) queue.push(m);
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
return {
|
|
4277
|
+
models: referencedModels,
|
|
4278
|
+
enums: referencedEnums
|
|
4279
|
+
};
|
|
4280
|
+
}
|
|
4281
|
+
function isDiscriminatedModel(model) {
|
|
4282
|
+
return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
|
|
4283
|
+
}
|
|
3561
4284
|
/** Collect all generated files from an emitter (no headers, no path prefixes). */
|
|
3562
4285
|
function generateAllFiles(spec, emitter, ctx) {
|
|
4286
|
+
const referenced = collectReferencedNames(spec.services, spec.models);
|
|
4287
|
+
const reachableModels = spec.models.filter((m) => referenced.models.has(m.name));
|
|
4288
|
+
const reachableEnums = spec.enums.filter((e) => referenced.enums.has(e.name));
|
|
4289
|
+
const reachableSpec = {
|
|
4290
|
+
...spec,
|
|
4291
|
+
models: reachableModels,
|
|
4292
|
+
enums: reachableEnums
|
|
4293
|
+
};
|
|
3563
4294
|
return [
|
|
3564
|
-
...emitter.generateModels(
|
|
3565
|
-
...emitter.generateEnums(
|
|
4295
|
+
...emitter.generateModels(reachableModels, ctx),
|
|
4296
|
+
...emitter.generateEnums(reachableEnums, ctx),
|
|
3566
4297
|
...emitter.generateResources(spec.services, ctx),
|
|
3567
4298
|
...emitter.generateClient(spec, ctx),
|
|
3568
4299
|
...emitter.generateErrors(ctx),
|
|
3569
4300
|
...emitter.generateConfig(ctx),
|
|
3570
|
-
...emitter.generateTypeSignatures?.(
|
|
3571
|
-
...emitter.generateTests(
|
|
4301
|
+
...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
|
|
4302
|
+
...emitter.generateTests(reachableSpec, ctx),
|
|
3572
4303
|
...emitter.generateManifest?.(spec, ctx) ?? []
|
|
3573
4304
|
];
|
|
3574
4305
|
}
|
|
@@ -3592,6 +4323,103 @@ function generateFiles(spec, emitter, options) {
|
|
|
3592
4323
|
}
|
|
3593
4324
|
//#endregion
|
|
3594
4325
|
//#region src/engine/integrate.ts
|
|
4326
|
+
/**
|
|
4327
|
+
* Resolve a relative import path against a source file path to get the
|
|
4328
|
+
* target file path. Handles implicit .ts extensions and /index.ts.
|
|
4329
|
+
*/
|
|
4330
|
+
function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
|
|
4331
|
+
const cleaned = importModulePath.replace(/^['"]|['"]$/g, "");
|
|
4332
|
+
if (!cleaned.startsWith(".")) return void 0;
|
|
4333
|
+
const dir = path.dirname(fromFilePath);
|
|
4334
|
+
const resolved = path.normalize(path.join(dir, cleaned));
|
|
4335
|
+
for (const candidate of [
|
|
4336
|
+
resolved,
|
|
4337
|
+
`${resolved}.ts`,
|
|
4338
|
+
`${resolved}/index.ts`
|
|
4339
|
+
]) if (knownPaths.has(candidate)) return candidate;
|
|
4340
|
+
}
|
|
4341
|
+
/**
|
|
4342
|
+
* Determine whether a generated file is a "root" — a file that should always
|
|
4343
|
+
* be integrated into the target. Roots are entry points from which we trace
|
|
4344
|
+
* the import graph. Everything else must be reachable from a root.
|
|
4345
|
+
*
|
|
4346
|
+
* Roots: resource classes, client, config, errors, enums, barrel index files.
|
|
4347
|
+
* Non-roots: serializers, fixtures, standalone interfaces, test files.
|
|
4348
|
+
*/
|
|
4349
|
+
function isRootFile(filePath) {
|
|
4350
|
+
if (filePath.includes("/fixtures/")) return false;
|
|
4351
|
+
if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
|
|
4352
|
+
if (filePath.includes("/serializers/")) return false;
|
|
4353
|
+
if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
|
|
4354
|
+
return true;
|
|
4355
|
+
}
|
|
4356
|
+
/**
|
|
4357
|
+
* Build a file-level dependency graph from generated files and return only
|
|
4358
|
+
* files reachable from root entry points. Uses tree-sitter to parse imports.
|
|
4359
|
+
*/
|
|
4360
|
+
async function treeShakeFiles(files, language) {
|
|
4361
|
+
if (!hasGrammar(language)) return files;
|
|
4362
|
+
const knownPaths = new Set(files.map((f) => f.path));
|
|
4363
|
+
const deps = /* @__PURE__ */ new Map();
|
|
4364
|
+
for (const file of files) {
|
|
4365
|
+
if (!file.path.endsWith(".ts") && !file.path.endsWith(".js")) {
|
|
4366
|
+
deps.set(file.path, /* @__PURE__ */ new Set());
|
|
4367
|
+
continue;
|
|
4368
|
+
}
|
|
4369
|
+
const parsed = await extractStatements(file.content, language);
|
|
4370
|
+
const fileDeps = /* @__PURE__ */ new Set();
|
|
4371
|
+
for (const imp of parsed.imports) {
|
|
4372
|
+
const resolved = resolveImportPath(file.path, imp.key, knownPaths);
|
|
4373
|
+
if (resolved) fileDeps.add(resolved);
|
|
4374
|
+
}
|
|
4375
|
+
for (const stmt of parsed.statements) if (stmt.kind === "reexport") {
|
|
4376
|
+
const match = stmt.text.match(/from\s+['"]([^'"]+)['"]/);
|
|
4377
|
+
if (match) {
|
|
4378
|
+
const resolved = resolveImportPath(file.path, match[1], knownPaths);
|
|
4379
|
+
if (resolved) fileDeps.add(resolved);
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
deps.set(file.path, fileDeps);
|
|
4383
|
+
}
|
|
4384
|
+
const codeExtensions = new Set([
|
|
4385
|
+
".ts",
|
|
4386
|
+
".js",
|
|
4387
|
+
".tsx",
|
|
4388
|
+
".jsx",
|
|
4389
|
+
".py",
|
|
4390
|
+
".rb",
|
|
4391
|
+
".go",
|
|
4392
|
+
".rs",
|
|
4393
|
+
".kt",
|
|
4394
|
+
".cs",
|
|
4395
|
+
".ex",
|
|
4396
|
+
".exs",
|
|
4397
|
+
".php"
|
|
4398
|
+
]);
|
|
4399
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
4400
|
+
const queue = [];
|
|
4401
|
+
for (const file of files) {
|
|
4402
|
+
const ext = file.path.slice(file.path.lastIndexOf("."));
|
|
4403
|
+
if (!codeExtensions.has(ext)) {
|
|
4404
|
+
reachable.add(file.path);
|
|
4405
|
+
continue;
|
|
4406
|
+
}
|
|
4407
|
+
if (isRootFile(file.path)) {
|
|
4408
|
+
reachable.add(file.path);
|
|
4409
|
+
queue.push(file.path);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
while (queue.length > 0) {
|
|
4413
|
+
const current = queue.pop();
|
|
4414
|
+
const fileDeps = deps.get(current);
|
|
4415
|
+
if (!fileDeps) continue;
|
|
4416
|
+
for (const dep of fileDeps) if (!reachable.has(dep)) {
|
|
4417
|
+
reachable.add(dep);
|
|
4418
|
+
queue.push(dep);
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
return files.filter((f) => reachable.has(f.path));
|
|
4422
|
+
}
|
|
3595
4423
|
function mapFilesForTargetIntegration(files, language) {
|
|
3596
4424
|
const langPrefix = `${language}/`;
|
|
3597
4425
|
return files.filter((f) => f.integrateTarget !== false).map((f) => {
|
|
@@ -3603,11 +4431,93 @@ function mapFilesForTargetIntegration(files, language) {
|
|
|
3603
4431
|
};
|
|
3604
4432
|
});
|
|
3605
4433
|
}
|
|
4434
|
+
/**
|
|
4435
|
+
* Resolve file-to-directory conflicts in the target.
|
|
4436
|
+
*
|
|
4437
|
+
* When a generated file creates a package directory (e.g., `api_keys/__init__.py`)
|
|
4438
|
+
* that would shadow an existing module file (e.g., `api_keys.py`), move the
|
|
4439
|
+
* module file's content into `_compat.py` inside the package and add a
|
|
4440
|
+
* re-export from `__init__.py`. This preserves backward-compatible imports
|
|
4441
|
+
* (e.g., `from workos.api_keys import ApiKeysModule`) while the package directory
|
|
4442
|
+
* provides the generated models and resource classes.
|
|
4443
|
+
*/
|
|
4444
|
+
async function resolveFileToDirectoryConflicts(files, targetDir) {
|
|
4445
|
+
const result = [...files];
|
|
4446
|
+
const removals = [];
|
|
4447
|
+
for (let i = 0; i < result.length; i++) {
|
|
4448
|
+
const file = result[i];
|
|
4449
|
+
const match = file.path.match(/^(.+)\/__init__\.py$/);
|
|
4450
|
+
if (!match) continue;
|
|
4451
|
+
const dirPath = match[1];
|
|
4452
|
+
const moduleFilePath = `${dirPath}.py`;
|
|
4453
|
+
const fullModulePath = path.join(targetDir, moduleFilePath);
|
|
4454
|
+
let moduleContent;
|
|
4455
|
+
try {
|
|
4456
|
+
moduleContent = await fs.readFile(fullModulePath, "utf-8");
|
|
4457
|
+
} catch {
|
|
4458
|
+
continue;
|
|
4459
|
+
}
|
|
4460
|
+
const compatPath = `${dirPath}/_compat.py`;
|
|
4461
|
+
result.push({
|
|
4462
|
+
path: compatPath,
|
|
4463
|
+
content: moduleContent,
|
|
4464
|
+
overwriteExisting: true
|
|
4465
|
+
});
|
|
4466
|
+
const compatImport = "from ._compat import * # noqa: F401,F403 # pyright: ignore[reportUnusedImport]";
|
|
4467
|
+
const existingContent = file.content.endsWith("\n") ? file.content : file.content + "\n";
|
|
4468
|
+
result[i] = {
|
|
4469
|
+
...file,
|
|
4470
|
+
content: existingContent + compatImport + "\n"
|
|
4471
|
+
};
|
|
4472
|
+
removals.push(fullModulePath);
|
|
4473
|
+
}
|
|
4474
|
+
return {
|
|
4475
|
+
files: result,
|
|
4476
|
+
removals
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
3606
4479
|
async function integrateGeneratedFiles(opts) {
|
|
3607
|
-
|
|
4480
|
+
const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
|
|
4481
|
+
const result = await writeFiles(resolved, opts.targetDir, {
|
|
3608
4482
|
language: opts.language,
|
|
3609
4483
|
header: opts.header
|
|
3610
4484
|
});
|
|
4485
|
+
for (const filePath of removals) try {
|
|
4486
|
+
await fs.unlink(filePath);
|
|
4487
|
+
} catch {}
|
|
4488
|
+
return result;
|
|
4489
|
+
}
|
|
4490
|
+
//#endregion
|
|
4491
|
+
//#region src/engine/formatter.ts
|
|
4492
|
+
/**
|
|
4493
|
+
* Post-integration formatter.
|
|
4494
|
+
*
|
|
4495
|
+
* After generating and merging files into a target directory, runs the
|
|
4496
|
+
* emitter-provided format command on all written/merged files so that
|
|
4497
|
+
* generated code matches the target project's style conventions.
|
|
4498
|
+
*/
|
|
4499
|
+
const execFileAsync = promisify(execFile);
|
|
4500
|
+
/**
|
|
4501
|
+
* Run the emitter's format command on the given target files.
|
|
4502
|
+
* Silently skips if the emitter has no formatCommand or returns null.
|
|
4503
|
+
*/
|
|
4504
|
+
async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
4505
|
+
if (filePaths.length === 0) return;
|
|
4506
|
+
if (!emitter.formatCommand) return;
|
|
4507
|
+
const formatCmd = emitter.formatCommand(targetDir);
|
|
4508
|
+
if (!formatCmd) return;
|
|
4509
|
+
const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
|
|
4510
|
+
const batchSize = formatCmd.batchSize ?? 100;
|
|
4511
|
+
for (let i = 0; i < absolutePaths.length; i += batchSize) {
|
|
4512
|
+
const batch = absolutePaths.slice(i, i + batchSize);
|
|
4513
|
+
try {
|
|
4514
|
+
await execFileAsync(formatCmd.cmd, [...formatCmd.args, ...batch], {
|
|
4515
|
+
cwd: targetDir,
|
|
4516
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
4517
|
+
timeout: 12e4
|
|
4518
|
+
});
|
|
4519
|
+
} catch {}
|
|
4520
|
+
}
|
|
3611
4521
|
}
|
|
3612
4522
|
//#endregion
|
|
3613
4523
|
//#region src/engine/orchestrator.ts
|
|
@@ -3636,6 +4546,12 @@ async function generate(spec, emitter, options) {
|
|
|
3636
4546
|
if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
|
|
3637
4547
|
if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
|
|
3638
4548
|
if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
|
|
4549
|
+
const allTargetFiles = [
|
|
4550
|
+
...targetResult.written,
|
|
4551
|
+
...targetResult.merged,
|
|
4552
|
+
...targetResult.identical
|
|
4553
|
+
];
|
|
4554
|
+
if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
|
|
3639
4555
|
}
|
|
3640
4556
|
return withHeaders;
|
|
3641
4557
|
}
|
|
@@ -4204,7 +5120,7 @@ function loadOverlayContext(opts) {
|
|
|
4204
5120
|
throw new ConfigError(`Failed to parse API surface JSON: ${err instanceof Error ? err.message : String(err)}`, `Ensure "${opts.apiSurfacePath}" contains valid JSON. Re-run \`oagen extract\` to regenerate it.`);
|
|
4205
5121
|
}
|
|
4206
5122
|
let manifest;
|
|
4207
|
-
const resolvedManifestPath = opts.manifestPath ?? path.join(opts.outputDir, "smoke-manifest.json");
|
|
5123
|
+
const resolvedManifestPath = opts.manifestPath ?? path$1.join(opts.outputDir, "smoke-manifest.json");
|
|
4208
5124
|
if (opts.manifestPath || existsSync(resolvedManifestPath)) try {
|
|
4209
5125
|
const parsed = JSON.parse(readFileSync(resolvedManifestPath, "utf-8"));
|
|
4210
5126
|
if (Array.isArray(parsed)) manifest = parsed;
|
|
@@ -4295,7 +5211,10 @@ function expandDocUrls(spec, docUrl) {
|
|
|
4295
5211
|
//#endregion
|
|
4296
5212
|
//#region src/cli/generate.ts
|
|
4297
5213
|
async function generateCommand(opts) {
|
|
4298
|
-
let ir = await parseSpec(opts.spec, {
|
|
5214
|
+
let ir = await parseSpec(opts.spec, {
|
|
5215
|
+
operationIdTransform: opts.operationIdTransform,
|
|
5216
|
+
schemaNameTransform: opts.schemaNameTransform
|
|
5217
|
+
});
|
|
4299
5218
|
if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
|
|
4300
5219
|
const emitter = getEmitter(opts.lang);
|
|
4301
5220
|
const namespace = opts.namespace ?? ir.name;
|
|
@@ -4318,7 +5237,9 @@ async function generateCommand(opts) {
|
|
|
4318
5237
|
outputDir: opts.output,
|
|
4319
5238
|
target: opts.target,
|
|
4320
5239
|
apiSurface,
|
|
4321
|
-
overlayLookup
|
|
5240
|
+
overlayLookup,
|
|
5241
|
+
operationHints: opts.operationHints,
|
|
5242
|
+
mountRules: opts.mountRules
|
|
4322
5243
|
});
|
|
4323
5244
|
if (opts.dryRun) for (const f of files) console.log(f.path);
|
|
4324
5245
|
else {
|
|
@@ -4675,7 +5596,10 @@ function summarize(changes) {
|
|
|
4675
5596
|
//#endregion
|
|
4676
5597
|
//#region src/cli/diff.ts
|
|
4677
5598
|
async function diffCommand(opts) {
|
|
4678
|
-
const parseOptions = {
|
|
5599
|
+
const parseOptions = {
|
|
5600
|
+
operationIdTransform: opts.operationIdTransform,
|
|
5601
|
+
schemaNameTransform: opts.schemaNameTransform
|
|
5602
|
+
};
|
|
4679
5603
|
let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
|
|
4680
5604
|
if (opts.docUrl) {
|
|
4681
5605
|
oldSpec = expandDocUrls(oldSpec, opts.docUrl);
|
|
@@ -5277,7 +6201,7 @@ function cleanGeneratedFiles(dir) {
|
|
|
5277
6201
|
return;
|
|
5278
6202
|
}
|
|
5279
6203
|
for (const entry of entries) {
|
|
5280
|
-
const full = path.join(dir, entry);
|
|
6204
|
+
const full = path$1.join(dir, entry);
|
|
5281
6205
|
let stat;
|
|
5282
6206
|
try {
|
|
5283
6207
|
stat = statSync(full);
|
|
@@ -5307,7 +6231,7 @@ function cleanGeneratedFiles(dir) {
|
|
|
5307
6231
|
* it to ManifestEntry[] for the overlay builder.
|
|
5308
6232
|
*/
|
|
5309
6233
|
function loadManifestFromDir(outputDir) {
|
|
5310
|
-
const manifestPath = path.join(outputDir, "smoke-manifest.json");
|
|
6234
|
+
const manifestPath = path$1.join(outputDir, "smoke-manifest.json");
|
|
5311
6235
|
if (!existsSync(manifestPath)) return void 0;
|
|
5312
6236
|
try {
|
|
5313
6237
|
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
@@ -5468,16 +6392,16 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
|
|
|
5468
6392
|
//#endregion
|
|
5469
6393
|
//#region src/verify/run-smoke-check.ts
|
|
5470
6394
|
const __filename = fileURLToPath(import.meta.url);
|
|
5471
|
-
const __dirname = path.dirname(__filename);
|
|
6395
|
+
const __dirname = path$1.dirname(__filename);
|
|
5472
6396
|
function resolveScript(scriptRelPath) {
|
|
5473
|
-
const distScript = path.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".
|
|
6397
|
+
const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".mjs"));
|
|
5474
6398
|
if (existsSync(distScript)) return {
|
|
5475
6399
|
bin: "node",
|
|
5476
6400
|
script: distScript
|
|
5477
6401
|
};
|
|
5478
6402
|
return {
|
|
5479
6403
|
bin: "npx",
|
|
5480
|
-
script: path.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
|
|
6404
|
+
script: path$1.resolve(__dirname, "..", "..", "scripts", scriptRelPath)
|
|
5481
6405
|
};
|
|
5482
6406
|
}
|
|
5483
6407
|
function runPackagedScript(scriptRelPath, args) {
|
|
@@ -5589,7 +6513,7 @@ function printCompatResult(result, baseline) {
|
|
|
5589
6513
|
console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
|
|
5590
6514
|
}
|
|
5591
6515
|
async function verifyCommand(opts) {
|
|
5592
|
-
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform } = opts;
|
|
6516
|
+
const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
|
|
5593
6517
|
const maxRetries = opts.maxRetries ?? 3;
|
|
5594
6518
|
const diagData = {};
|
|
5595
6519
|
let stepNum = 1;
|
|
@@ -5600,7 +6524,10 @@ async function verifyCommand(opts) {
|
|
|
5600
6524
|
console.log(separator);
|
|
5601
6525
|
const effectiveScope = scope ?? (spec ? "spec-only" : "full");
|
|
5602
6526
|
let parsedSpec;
|
|
5603
|
-
if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
|
|
6527
|
+
if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
|
|
6528
|
+
operationIdTransform,
|
|
6529
|
+
schemaNameTransform
|
|
6530
|
+
});
|
|
5604
6531
|
else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
|
|
5605
6532
|
const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
|
|
5606
6533
|
baseline,
|
|
@@ -5643,7 +6570,13 @@ async function verifyCommand(opts) {
|
|
|
5643
6570
|
console.log(`\n${separator}`);
|
|
5644
6571
|
console.log(`Step ${stepNum}: Staleness detection`);
|
|
5645
6572
|
console.log(separator);
|
|
5646
|
-
const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, {
|
|
6573
|
+
const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, {
|
|
6574
|
+
operationIdTransform,
|
|
6575
|
+
schemaNameTransform
|
|
6576
|
+
}), await parseSpec(spec, {
|
|
6577
|
+
operationIdTransform,
|
|
6578
|
+
schemaNameTransform
|
|
6579
|
+
}), lang);
|
|
5647
6580
|
if (stalenessResult.violations.length > 0) {
|
|
5648
6581
|
console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
|
|
5649
6582
|
for (const v of stalenessResult.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
|
|
@@ -5920,6 +6853,50 @@ async function initCommand(opts) {
|
|
|
5920
6853
|
console.log(" - npm run sdk:verify -- --spec <path-to-spec>");
|
|
5921
6854
|
}
|
|
5922
6855
|
//#endregion
|
|
6856
|
+
//#region src/cli/resolve.ts
|
|
6857
|
+
async function resolveCommand(opts) {
|
|
6858
|
+
let ir = await parseSpec(opts.spec, {
|
|
6859
|
+
operationIdTransform: opts.operationIdTransform,
|
|
6860
|
+
schemaNameTransform: opts.schemaNameTransform
|
|
6861
|
+
});
|
|
6862
|
+
if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
|
|
6863
|
+
const resolved = resolveOperations(ir, opts.operationHints, opts.mountRules);
|
|
6864
|
+
if ((opts.format ?? "table") === "json") {
|
|
6865
|
+
const output = resolved.map(toJsonRow);
|
|
6866
|
+
console.log(JSON.stringify(output, null, 2));
|
|
6867
|
+
return;
|
|
6868
|
+
}
|
|
6869
|
+
printTable(resolved, opts.operationHints);
|
|
6870
|
+
}
|
|
6871
|
+
function toJsonRow(r) {
|
|
6872
|
+
return {
|
|
6873
|
+
service: r.service.name,
|
|
6874
|
+
method: r.operation.httpMethod.toUpperCase(),
|
|
6875
|
+
path: r.operation.path,
|
|
6876
|
+
derivedName: r.methodName,
|
|
6877
|
+
hintApplied: r.methodName !== r.operation.name,
|
|
6878
|
+
mountOn: r.mountOn,
|
|
6879
|
+
wrappers: r.wrappers?.map((w) => w.name)
|
|
6880
|
+
};
|
|
6881
|
+
}
|
|
6882
|
+
function printTable(resolved, hints) {
|
|
6883
|
+
const hintMap = hints ?? {};
|
|
6884
|
+
console.log("| Service | Method | Path | Resolved Name | Hint | Mount On |");
|
|
6885
|
+
console.log("|---|---|---|---|---|---|");
|
|
6886
|
+
const unhinted = [];
|
|
6887
|
+
for (const r of resolved) {
|
|
6888
|
+
const hasHint = `${r.operation.httpMethod.toUpperCase()} ${r.operation.path}` in hintMap;
|
|
6889
|
+
const hintMarker = hasHint ? "yes" : "";
|
|
6890
|
+
const mountChanged = r.mountOn !== r.service.name;
|
|
6891
|
+
const mountCol = mountChanged ? r.mountOn : "";
|
|
6892
|
+
console.log(`| ${r.service.name} | ${r.operation.httpMethod.toUpperCase()} | ${r.operation.path} | \`${r.methodName}\` | ${hintMarker} | ${mountCol} |`);
|
|
6893
|
+
if (r.wrappers) for (const w of r.wrappers) console.log(`| | | | \`${w.name}\` (wrapper) | yes | ${mountCol} |`);
|
|
6894
|
+
if (!hasHint && !mountChanged) unhinted.push(r);
|
|
6895
|
+
}
|
|
6896
|
+
console.log("");
|
|
6897
|
+
console.log(`Total: ${resolved.length} operations, ${resolved.length - unhinted.length} with hints/mounts, ${unhinted.length} algorithm-only`);
|
|
6898
|
+
}
|
|
6899
|
+
//#endregion
|
|
5923
6900
|
//#region src/cli/config-loader.ts
|
|
5924
6901
|
const CONFIG_NAMES = [
|
|
5925
6902
|
"oagen.config.ts",
|
|
@@ -5928,7 +6905,7 @@ const CONFIG_NAMES = [
|
|
|
5928
6905
|
];
|
|
5929
6906
|
async function loadConfig(cwd = process.cwd()) {
|
|
5930
6907
|
for (const name of CONFIG_NAMES) {
|
|
5931
|
-
const configPath = path.resolve(cwd, name);
|
|
6908
|
+
const configPath = path$1.resolve(cwd, name);
|
|
5932
6909
|
if (!existsSync(configPath)) continue;
|
|
5933
6910
|
try {
|
|
5934
6911
|
const mod = await import(pathToFileURL(configPath).href);
|
|
@@ -5955,14 +6932,20 @@ function handleError(err) {
|
|
|
5955
6932
|
}
|
|
5956
6933
|
let configSmokeRunners;
|
|
5957
6934
|
let configOperationIdTransform;
|
|
6935
|
+
let configSchemaNameTransform;
|
|
5958
6936
|
let configDocUrl;
|
|
6937
|
+
let configOperationHints;
|
|
6938
|
+
let configMountRules;
|
|
5959
6939
|
try {
|
|
5960
6940
|
const config = await loadConfig();
|
|
5961
6941
|
if (config) {
|
|
5962
6942
|
applyConfig(config);
|
|
5963
6943
|
configSmokeRunners = config.smokeRunners;
|
|
5964
6944
|
configOperationIdTransform = config.operationIdTransform;
|
|
6945
|
+
configSchemaNameTransform = config.schemaNameTransform;
|
|
5965
6946
|
configDocUrl = config.docUrl;
|
|
6947
|
+
configOperationHints = config.operationHints;
|
|
6948
|
+
configMountRules = config.mountRules;
|
|
5966
6949
|
}
|
|
5967
6950
|
} catch (err) {
|
|
5968
6951
|
handleError(err);
|
|
@@ -5985,13 +6968,17 @@ program.command("generate").description("Run a registered emitter against an Ope
|
|
|
5985
6968
|
generateCommand({
|
|
5986
6969
|
...opts,
|
|
5987
6970
|
operationIdTransform: configOperationIdTransform,
|
|
5988
|
-
|
|
6971
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
6972
|
+
docUrl: configDocUrl,
|
|
6973
|
+
operationHints: configOperationHints,
|
|
6974
|
+
mountRules: configMountRules
|
|
5989
6975
|
}).catch(handleError);
|
|
5990
6976
|
});
|
|
5991
6977
|
program.command("diff").description("Compare two OpenAPI specs and output a diff report").requiredOption("--old <path>", "Path to old spec").requiredOption("--new <path>", "Path to new spec").action((opts) => {
|
|
5992
6978
|
diffCommand({
|
|
5993
6979
|
...opts,
|
|
5994
6980
|
operationIdTransform: configOperationIdTransform,
|
|
6981
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
5995
6982
|
docUrl: configDocUrl
|
|
5996
6983
|
}).catch(handleError);
|
|
5997
6984
|
});
|
|
@@ -6005,7 +6992,23 @@ program.command("verify").description("Run smoke tests (and optional compat chec
|
|
|
6005
6992
|
verifyCommand({
|
|
6006
6993
|
...opts,
|
|
6007
6994
|
maxRetries: parseInt(opts.maxRetries, 10),
|
|
6008
|
-
operationIdTransform: configOperationIdTransform
|
|
6995
|
+
operationIdTransform: configOperationIdTransform,
|
|
6996
|
+
schemaNameTransform: configSchemaNameTransform
|
|
6997
|
+
}).catch(handleError);
|
|
6998
|
+
});
|
|
6999
|
+
program.command("resolve").description("Resolve operation names from spec and output a review table").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").option("--format <format>", "Output format: table or json", "table").action((opts) => {
|
|
7000
|
+
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
|
|
7001
|
+
if (!opts.spec) {
|
|
7002
|
+
console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
|
|
7003
|
+
process.exit(1);
|
|
7004
|
+
}
|
|
7005
|
+
resolveCommand({
|
|
7006
|
+
...opts,
|
|
7007
|
+
operationIdTransform: configOperationIdTransform,
|
|
7008
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
7009
|
+
docUrl: configDocUrl,
|
|
7010
|
+
operationHints: configOperationHints,
|
|
7011
|
+
mountRules: configMountRules
|
|
6009
7012
|
}).catch(handleError);
|
|
6010
7013
|
});
|
|
6011
7014
|
program.command("init").description("Scaffold a new emitter project").requiredOption("--lang <language>", "Target language").option("--project <dir>", "Project directory", ".").action((opts) => {
|