@workos/oagen 0.3.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 +844 -56
- 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-Cn6S74vV.mjs → diff-DcY93sii.mjs} +433 -14
- package/dist/diff-DcY93sii.mjs.map +1 -0
- package/dist/{differ-B6472Yef.mjs → differ-CG5ZErKl.mjs} +2 -2
- package/dist/{differ-B6472Yef.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
- package/dist/index.d.mts +107 -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 +314 -32
- 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 +314 -32
- package/dist/smoke.mjs.map +1 -1
- package/dist/{tree-sitter-DFRXfWg9.mjs → tree-sitter-C53w_0WE.mjs} +5 -7
- 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 +4 -4
- 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-Cn6S74vV.mjs.map +0 -1
- package/dist/tree-sitter-DFRXfWg9.mjs.map +0 -1
- package/dist/types-O25qZxMl.d.mts.map +0 -1
package/dist/cli/index.mjs
CHANGED
|
@@ -6,11 +6,125 @@ import { readFile } from "node:fs/promises";
|
|
|
6
6
|
import * as path$1 from "node:path";
|
|
7
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
11
|
import { execFile, execFileSync } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
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
|
|
14
128
|
//#region src/errors.ts
|
|
15
129
|
/**
|
|
16
130
|
* Structured error hierarchy for oagen.
|
|
@@ -69,13 +183,11 @@ var RegistryError = class extends OagenError {
|
|
|
69
183
|
//#region src/parser/refs.ts
|
|
70
184
|
async function loadAndBundleSpec(specPath) {
|
|
71
185
|
const absolutePath = resolve(specPath);
|
|
72
|
-
const document = makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href);
|
|
73
|
-
const config = await createConfig({});
|
|
74
|
-
const resolver = new BaseResolver();
|
|
75
186
|
const result = await bundleDocument({
|
|
76
|
-
document,
|
|
77
|
-
config:
|
|
78
|
-
|
|
187
|
+
document: makeDocumentFromString(await readFile(absolutePath, "utf-8"), pathToFileURL(absolutePath).href),
|
|
188
|
+
config: await createConfig({}),
|
|
189
|
+
types: Oas3_1Types,
|
|
190
|
+
externalRefResolver: new BaseResolver(),
|
|
79
191
|
dereference: false
|
|
80
192
|
});
|
|
81
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.`);
|
|
@@ -224,11 +336,7 @@ function toSnakeCase(s) {
|
|
|
224
336
|
function toUpperSnakeCase(s) {
|
|
225
337
|
return splitWords(s).map((w) => w.toUpperCase()).join("_");
|
|
226
338
|
}
|
|
227
|
-
const BACKEND_SUFFIXES = [
|
|
228
|
-
"Dto",
|
|
229
|
-
"DTO",
|
|
230
|
-
"Controller"
|
|
231
|
-
];
|
|
339
|
+
const BACKEND_SUFFIXES = ["Controller"];
|
|
232
340
|
function stripBackendSuffixes(name) {
|
|
233
341
|
for (const suffix of BACKEND_SUFFIXES) if (name.endsWith(suffix) && name.length > suffix.length) return name.slice(0, -suffix.length);
|
|
234
342
|
return name;
|
|
@@ -255,7 +363,9 @@ const SINGULAR_SAFE_LIST = new Set([
|
|
|
255
363
|
"Canvas",
|
|
256
364
|
"Alias",
|
|
257
365
|
"Basis",
|
|
258
|
-
"Bus"
|
|
366
|
+
"Bus",
|
|
367
|
+
"Species",
|
|
368
|
+
"Series"
|
|
259
369
|
]);
|
|
260
370
|
/**
|
|
261
371
|
* Conservative singularize for the leading PascalCase word.
|
|
@@ -264,7 +374,7 @@ const SINGULAR_SAFE_LIST = new Set([
|
|
|
264
374
|
* - trailing `s` for words >4 chars (e.g. Organizations → Organization)
|
|
265
375
|
* Safe-listed words are never singularized.
|
|
266
376
|
*/
|
|
267
|
-
function singularize(word) {
|
|
377
|
+
function singularize$1(word) {
|
|
268
378
|
if (SINGULAR_SAFE_LIST.has(word)) return word;
|
|
269
379
|
if (word.endsWith("ies") && word.length > 4) return word.slice(0, -3) + "y";
|
|
270
380
|
if (word.endsWith("ses") && word.length > 4) return word.slice(0, -2);
|
|
@@ -290,7 +400,7 @@ function cleanSchemaName(name) {
|
|
|
290
400
|
const match = result.match(/^([A-Z][a-z]*)/);
|
|
291
401
|
if (match) {
|
|
292
402
|
const leadWord = match[1];
|
|
293
|
-
const singular = singularize(leadWord);
|
|
403
|
+
const singular = singularize$1(leadWord);
|
|
294
404
|
if (singular !== leadWord) result = singular + result.slice(leadWord.length);
|
|
295
405
|
}
|
|
296
406
|
return result;
|
|
@@ -310,7 +420,7 @@ function qualifyInlineModelName(baseName, parentName) {
|
|
|
310
420
|
const trailingMatch = baseName.match(/^(.*?)([A-Z][a-z]*)$/);
|
|
311
421
|
if (trailingMatch) {
|
|
312
422
|
const [, prefix, trailingWord] = trailingMatch;
|
|
313
|
-
return `${cleanParent}${prefix}${singularize(trailingWord)}`;
|
|
423
|
+
return `${cleanParent}${prefix}${singularize$1(trailingWord)}`;
|
|
314
424
|
}
|
|
315
425
|
return `${cleanParent}${baseName}`;
|
|
316
426
|
}
|
|
@@ -371,15 +481,39 @@ function buildInlineModel(name, schema) {
|
|
|
371
481
|
}
|
|
372
482
|
//#endregion
|
|
373
483
|
//#region src/parser/schemas.ts
|
|
374
|
-
|
|
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) {
|
|
375
496
|
const enums = [];
|
|
376
497
|
if (!schemas) return {
|
|
377
498
|
models: [],
|
|
378
499
|
enums
|
|
379
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;
|
|
380
514
|
const modelsByCleanName = /* @__PURE__ */ new Map();
|
|
381
515
|
for (const [name, schema] of Object.entries(schemas)) {
|
|
382
|
-
const pascalName =
|
|
516
|
+
const pascalName = resolveSchemaName(name);
|
|
383
517
|
if (schema.enum) enums.push(extractEnum(pascalName, schema));
|
|
384
518
|
else {
|
|
385
519
|
const model = extractModel(pascalName, schema, schemas);
|
|
@@ -387,6 +521,12 @@ function extractSchemas(schemas) {
|
|
|
387
521
|
if (existing) {
|
|
388
522
|
if (model.fields.length > existing.fields.length) modelsByCleanName.set(pascalName, model);
|
|
389
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
|
+
}
|
|
390
530
|
}
|
|
391
531
|
}
|
|
392
532
|
const models = [...modelsByCleanName.values()];
|
|
@@ -402,6 +542,10 @@ function extractSchemas(schemas) {
|
|
|
402
542
|
enums
|
|
403
543
|
};
|
|
404
544
|
}
|
|
545
|
+
/** Clear the module-level schemaNameTransform. Called by parseSpec() after all extraction phases. */
|
|
546
|
+
function clearSchemaNameTransform() {
|
|
547
|
+
activeSchemaNameTransform = null;
|
|
548
|
+
}
|
|
405
549
|
/**
|
|
406
550
|
* Walk a single TypeRef and extract inline enum refs as top-level enum definitions.
|
|
407
551
|
* Shared by both schemas.ts (field-level) and parse.ts (model-level) collection passes.
|
|
@@ -438,7 +582,7 @@ function collectInlineEnums(fields, enums) {
|
|
|
438
582
|
function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, models, modelNames, queue) {
|
|
439
583
|
walkTypeRef(ref, { model: (modelRef) => {
|
|
440
584
|
if (modelNames.has(modelRef.name)) return;
|
|
441
|
-
if (Object.keys(schemas).some((k) =>
|
|
585
|
+
if (Object.keys(schemas).some((k) => resolveSchemaName(k) === modelRef.name)) return;
|
|
442
586
|
const parentSchema = findSchemaByName(parentModelName, schemas);
|
|
443
587
|
if (!parentSchema) return;
|
|
444
588
|
const fieldSchema = findNestedFieldSchema(fieldName, parentSchema, schemas);
|
|
@@ -453,7 +597,7 @@ function collectNestedInlineModels(ref, fieldName, parentModelName, schemas, mod
|
|
|
453
597
|
}
|
|
454
598
|
/** Find a component schema by PascalCase name. */
|
|
455
599
|
function findSchemaByName(pascalName, schemas) {
|
|
456
|
-
for (const [k, v] of Object.entries(schemas)) if (
|
|
600
|
+
for (const [k, v] of Object.entries(schemas)) if (resolveSchemaName(k) === pascalName) return v;
|
|
457
601
|
return null;
|
|
458
602
|
}
|
|
459
603
|
/** Walk into a schema to find a field's sub-schema, resolving allOf. */
|
|
@@ -611,13 +755,88 @@ function extractAllOfModel(name, schema, schemas) {
|
|
|
611
755
|
fields
|
|
612
756
|
};
|
|
613
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
|
+
}
|
|
614
833
|
function schemaToTypeRef(schema, contextName, parentModelName) {
|
|
615
834
|
if (schema.$ref) {
|
|
616
835
|
const segments = schema.$ref.split("/");
|
|
617
836
|
const rawName = segments[segments.length - 1];
|
|
618
837
|
return {
|
|
619
838
|
kind: "model",
|
|
620
|
-
name:
|
|
839
|
+
name: resolveSchemaName(rawName)
|
|
621
840
|
};
|
|
622
841
|
}
|
|
623
842
|
if (Array.isArray(schema.type)) {
|
|
@@ -881,6 +1100,7 @@ const KNOWN_DATA_PATHS = new Set([
|
|
|
881
1100
|
"edges"
|
|
882
1101
|
]);
|
|
883
1102
|
const PAGINATION_METADATA_PATTERNS = [
|
|
1103
|
+
"meta",
|
|
884
1104
|
"metadata",
|
|
885
1105
|
"pagination",
|
|
886
1106
|
"cursor",
|
|
@@ -912,11 +1132,12 @@ function detectListEnvelope(schema) {
|
|
|
912
1132
|
}
|
|
913
1133
|
if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
|
|
914
1134
|
const arrayPropName = arrayProps[0];
|
|
915
|
-
|
|
1135
|
+
const hasPaginationCompanion = nonArrayKeys.some((key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern)));
|
|
1136
|
+
if (KNOWN_DATA_PATHS.has(arrayPropName) && hasPaginationCompanion) return {
|
|
916
1137
|
isEnvelope: true,
|
|
917
1138
|
dataPath: arrayPropName
|
|
918
1139
|
};
|
|
919
|
-
if (
|
|
1140
|
+
if (hasPaginationCompanion) return {
|
|
920
1141
|
isEnvelope: true,
|
|
921
1142
|
dataPath: arrayPropName
|
|
922
1143
|
};
|
|
@@ -1140,7 +1361,7 @@ function qualifyNestedName(parentName, fieldName) {
|
|
|
1140
1361
|
const qualified = `${stripListItemMarkers(parentName)}${pascalField}`;
|
|
1141
1362
|
const match = qualified.match(/^(.+?)([A-Z][a-z]+s?)$/);
|
|
1142
1363
|
if (match && match[2]) {
|
|
1143
|
-
const trailing = singularize(match[2]);
|
|
1364
|
+
const trailing = singularize$1(match[2]);
|
|
1144
1365
|
return match[1] + trailing;
|
|
1145
1366
|
}
|
|
1146
1367
|
return qualified;
|
|
@@ -1220,13 +1441,33 @@ function extractOperations(paths, operationIdTransform) {
|
|
|
1220
1441
|
const op = pathItem[method];
|
|
1221
1442
|
if (!op) continue;
|
|
1222
1443
|
const serviceName = inferServiceName(path, op.tags?.[0]);
|
|
1223
|
-
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform);
|
|
1444
|
+
const { operation, inlineModels: opModels } = buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName);
|
|
1224
1445
|
inlineModels.push(...opModels);
|
|
1225
1446
|
const ops = serviceMap.get(serviceName) ?? [];
|
|
1226
1447
|
ops.push(operation);
|
|
1227
1448
|
serviceMap.set(serviceName, ops);
|
|
1228
1449
|
}
|
|
1229
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
|
+
}
|
|
1230
1471
|
for (const [, operations] of serviceMap) disambiguateOperationNames(operations);
|
|
1231
1472
|
return {
|
|
1232
1473
|
services: Array.from(serviceMap.entries()).map(([name, operations]) => ({
|
|
@@ -1342,13 +1583,14 @@ function inferDeeperContext(path) {
|
|
|
1342
1583
|
if (nonParams.length >= 3) return toPascalCase(nonParams.slice(1, -1).join("_"));
|
|
1343
1584
|
return null;
|
|
1344
1585
|
}
|
|
1345
|
-
function buildOperation(method, path, op, pathLevelParams, operationIdTransform) {
|
|
1586
|
+
function buildOperation(method, path, op, pathLevelParams, operationIdTransform, serviceName) {
|
|
1346
1587
|
const allParams = [...pathLevelParams, ...op.parameters ?? []];
|
|
1347
1588
|
const hasIdempotencyHeader = allParams.some((p) => p.in === "header" && p.name.toLowerCase() === "idempotency-key");
|
|
1348
|
-
const
|
|
1349
|
-
const
|
|
1350
|
-
const
|
|
1351
|
-
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);
|
|
1352
1594
|
const reqBodyModels = [];
|
|
1353
1595
|
const { body: requestBody, encoding: requestBodyEncoding } = extractRequestBody(op.requestBody, op, reqBodyModels);
|
|
1354
1596
|
const { response, successResponses, errors, inlineModels, isPaginated, dataPath: responseDataPath, itemType: responseItemType } = extractResponses(op.responses, op, path, method);
|
|
@@ -1365,6 +1607,7 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1365
1607
|
itemType
|
|
1366
1608
|
};
|
|
1367
1609
|
} else if (paginationFromParams) pagination = paginationFromParams;
|
|
1610
|
+
const security = extractOperationSecurity(op.security);
|
|
1368
1611
|
return {
|
|
1369
1612
|
operation: {
|
|
1370
1613
|
name: inferOperationName(method, path, op.operationId, operationIdTransform),
|
|
@@ -1383,19 +1626,35 @@ function buildOperation(method, path, op, pathLevelParams, operationIdTransform)
|
|
|
1383
1626
|
pagination,
|
|
1384
1627
|
injectIdempotencyKey: hasIdempotencyHeader,
|
|
1385
1628
|
deprecated: op.deprecated || void 0,
|
|
1386
|
-
async: op["x-oagen-async"]
|
|
1629
|
+
async: op["x-oagen-async"],
|
|
1630
|
+
security
|
|
1387
1631
|
},
|
|
1388
1632
|
inlineModels
|
|
1389
1633
|
};
|
|
1390
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
|
+
}
|
|
1391
1650
|
function buildDescription(summary, description) {
|
|
1392
1651
|
if (summary && description && summary !== description) return `${summary}\n\n${description}`;
|
|
1393
1652
|
return description ?? summary;
|
|
1394
1653
|
}
|
|
1395
|
-
function extractParams(params, location) {
|
|
1654
|
+
function extractParams(params, location, operationContext) {
|
|
1396
1655
|
return params.filter((p) => p.in === location).map((p) => ({
|
|
1397
1656
|
name: p.name,
|
|
1398
|
-
type: p.schema ? schemaToTypeRef(p.schema, p.name) : {
|
|
1657
|
+
type: p.schema ? schemaToTypeRef(p.schema, p.name, operationContext ? toPascalCase(operationContext) : void 0) : {
|
|
1399
1658
|
kind: "primitive",
|
|
1400
1659
|
type: "string"
|
|
1401
1660
|
},
|
|
@@ -1581,7 +1840,14 @@ function extractResponses(responses, op, path, method) {
|
|
|
1581
1840
|
statusCode: code,
|
|
1582
1841
|
type: extractedType
|
|
1583
1842
|
});
|
|
1584
|
-
} 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) {
|
|
1585
1851
|
const jsonContent = resp.content?.["application/json"];
|
|
1586
1852
|
const type = jsonContent?.schema ? schemaToTypeRef(jsonContent.schema, `Error${code}`) : void 0;
|
|
1587
1853
|
errors.push({
|
|
@@ -1708,6 +1974,19 @@ function collectInlineEnumsFromModels(models, enums) {
|
|
|
1708
1974
|
const enumNames = new Set(enums.map((e) => e.name));
|
|
1709
1975
|
for (const model of models) for (const field of model.fields) collectInlineEnumFromRef(field.type, enums, enumNames);
|
|
1710
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
|
+
}
|
|
1711
1990
|
//#endregion
|
|
1712
1991
|
//#region src/parser/normalize-model-refs.ts
|
|
1713
1992
|
function validateModelRefs(spec) {
|
|
@@ -1737,10 +2016,12 @@ async function parseSpec(specPath, options) {
|
|
|
1737
2016
|
const spec = parsed;
|
|
1738
2017
|
const version = spec.openapi ?? "";
|
|
1739
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}\`.`);
|
|
1740
|
-
const { models, enums } = extractSchemas(spec.components?.schemas);
|
|
2019
|
+
const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
|
|
1741
2020
|
const { services, inlineModels } = extractOperations(spec.paths, options?.operationIdTransform);
|
|
2021
|
+
clearSchemaNameTransform();
|
|
1742
2022
|
const finalModels = collapseJsonSuffixModels(mergeFieldInlineModels(mergeInlineResponseModels(models, inlineModels), extractInlineModelsFromSchemas(spec.components?.schemas)), services);
|
|
1743
2023
|
collectInlineEnumsFromModels(finalModels, enums);
|
|
2024
|
+
collectInlineEnumsFromOperations(services, enums);
|
|
1744
2025
|
const auth = extractAuthSchemes(spec.components?.securitySchemes);
|
|
1745
2026
|
const serverEntries = (spec.servers ?? []).map((s) => ({
|
|
1746
2027
|
url: s.url ?? "",
|
|
@@ -1755,7 +2036,8 @@ async function parseSpec(specPath, options) {
|
|
|
1755
2036
|
services,
|
|
1756
2037
|
models: finalModels,
|
|
1757
2038
|
enums,
|
|
1758
|
-
auth
|
|
2039
|
+
auth,
|
|
2040
|
+
sdk: defaultSdkBehavior()
|
|
1759
2041
|
};
|
|
1760
2042
|
validateModelRefs(result);
|
|
1761
2043
|
return result;
|
|
@@ -2691,8 +2973,9 @@ const phpMergeAdapter = {
|
|
|
2691
2973
|
testFilePatterns: [/Test\.php$/],
|
|
2692
2974
|
urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
|
|
2693
2975
|
resolveGrammar: (mod) => {
|
|
2694
|
-
|
|
2695
|
-
return
|
|
2976
|
+
const m = mod?.default ?? mod;
|
|
2977
|
+
if (typeof m === "object" && m !== null && "php" in m) return m.php;
|
|
2978
|
+
return m;
|
|
2696
2979
|
},
|
|
2697
2980
|
parseStatements(tree, source) {
|
|
2698
2981
|
const imports = [];
|
|
@@ -3198,6 +3481,35 @@ async function extractStatements(source, language) {
|
|
|
3198
3481
|
return adapter.parseStatements(tree, source);
|
|
3199
3482
|
}
|
|
3200
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
|
+
/**
|
|
3201
3513
|
* Merge generated content into an existing file.
|
|
3202
3514
|
*
|
|
3203
3515
|
* - Parses both files with tree-sitter
|
|
@@ -3546,15 +3858,18 @@ async function writeFiles(files, outputDir, options) {
|
|
|
3546
3858
|
}
|
|
3547
3859
|
const adapter = language ? getMergeAdapter(language) : void 0;
|
|
3548
3860
|
if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
|
|
3549
|
-
|
|
3550
|
-
|
|
3861
|
+
if (!(header && existingContent.startsWith(header))) {
|
|
3862
|
+
result.skipped.push(file.path);
|
|
3863
|
+
continue;
|
|
3864
|
+
}
|
|
3551
3865
|
}
|
|
3552
3866
|
if (existingContent === file.content) {
|
|
3553
3867
|
result.identical.push(file.path);
|
|
3554
3868
|
continue;
|
|
3555
3869
|
}
|
|
3556
3870
|
if (file.overwriteExisting) {
|
|
3557
|
-
|
|
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");
|
|
3558
3873
|
result.written.push(file.path);
|
|
3559
3874
|
continue;
|
|
3560
3875
|
}
|
|
@@ -3598,6 +3913,311 @@ async function writeFiles(files, outputDir, options) {
|
|
|
3598
3913
|
function isDefaultTestFile(filePath) {
|
|
3599
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);
|
|
3600
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
|
+
}
|
|
3601
4221
|
//#endregion
|
|
3602
4222
|
//#region src/engine/generate-files.ts
|
|
3603
4223
|
function buildEmitterContext(spec, options) {
|
|
@@ -3607,7 +4227,8 @@ function buildEmitterContext(spec, options) {
|
|
|
3607
4227
|
spec,
|
|
3608
4228
|
outputDir: options.outputDir,
|
|
3609
4229
|
apiSurface: options.apiSurface,
|
|
3610
|
-
overlayLookup: options.overlayLookup
|
|
4230
|
+
overlayLookup: options.overlayLookup,
|
|
4231
|
+
resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
|
|
3611
4232
|
};
|
|
3612
4233
|
}
|
|
3613
4234
|
/**
|
|
@@ -3637,6 +4258,7 @@ function collectReferencedNames(services, models) {
|
|
|
3637
4258
|
for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
|
|
3638
4259
|
if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
|
|
3639
4260
|
}
|
|
4261
|
+
for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
|
|
3640
4262
|
const modelsByName = new Map(models.map((m) => [m.name, m]));
|
|
3641
4263
|
const visited = /* @__PURE__ */ new Set();
|
|
3642
4264
|
const queue = [...referencedModels];
|
|
@@ -3656,6 +4278,9 @@ function collectReferencedNames(services, models) {
|
|
|
3656
4278
|
enums: referencedEnums
|
|
3657
4279
|
};
|
|
3658
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
|
+
}
|
|
3659
4284
|
/** Collect all generated files from an emitter (no headers, no path prefixes). */
|
|
3660
4285
|
function generateAllFiles(spec, emitter, ctx) {
|
|
3661
4286
|
const referenced = collectReferencedNames(spec.services, spec.models);
|
|
@@ -3756,11 +4381,33 @@ async function treeShakeFiles(files, language) {
|
|
|
3756
4381
|
}
|
|
3757
4382
|
deps.set(file.path, fileDeps);
|
|
3758
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
|
+
]);
|
|
3759
4399
|
const reachable = /* @__PURE__ */ new Set();
|
|
3760
4400
|
const queue = [];
|
|
3761
|
-
for (const file of files)
|
|
3762
|
-
|
|
3763
|
-
|
|
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
|
+
}
|
|
3764
4411
|
}
|
|
3765
4412
|
while (queue.length > 0) {
|
|
3766
4413
|
const current = queue.pop();
|
|
@@ -3784,11 +4431,61 @@ function mapFilesForTargetIntegration(files, language) {
|
|
|
3784
4431
|
};
|
|
3785
4432
|
});
|
|
3786
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
|
+
}
|
|
3787
4479
|
async function integrateGeneratedFiles(opts) {
|
|
3788
|
-
|
|
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, {
|
|
3789
4482
|
language: opts.language,
|
|
3790
4483
|
header: opts.header
|
|
3791
4484
|
});
|
|
4485
|
+
for (const filePath of removals) try {
|
|
4486
|
+
await fs.unlink(filePath);
|
|
4487
|
+
} catch {}
|
|
4488
|
+
return result;
|
|
3792
4489
|
}
|
|
3793
4490
|
//#endregion
|
|
3794
4491
|
//#region src/engine/formatter.ts
|
|
@@ -3849,7 +4546,11 @@ async function generate(spec, emitter, options) {
|
|
|
3849
4546
|
if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
|
|
3850
4547
|
if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
|
|
3851
4548
|
if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
|
|
3852
|
-
const allTargetFiles = [
|
|
4549
|
+
const allTargetFiles = [
|
|
4550
|
+
...targetResult.written,
|
|
4551
|
+
...targetResult.merged,
|
|
4552
|
+
...targetResult.identical
|
|
4553
|
+
];
|
|
3853
4554
|
if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
|
|
3854
4555
|
}
|
|
3855
4556
|
return withHeaders;
|
|
@@ -4510,7 +5211,10 @@ function expandDocUrls(spec, docUrl) {
|
|
|
4510
5211
|
//#endregion
|
|
4511
5212
|
//#region src/cli/generate.ts
|
|
4512
5213
|
async function generateCommand(opts) {
|
|
4513
|
-
let ir = await parseSpec(opts.spec, {
|
|
5214
|
+
let ir = await parseSpec(opts.spec, {
|
|
5215
|
+
operationIdTransform: opts.operationIdTransform,
|
|
5216
|
+
schemaNameTransform: opts.schemaNameTransform
|
|
5217
|
+
});
|
|
4514
5218
|
if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
|
|
4515
5219
|
const emitter = getEmitter(opts.lang);
|
|
4516
5220
|
const namespace = opts.namespace ?? ir.name;
|
|
@@ -4533,7 +5237,9 @@ async function generateCommand(opts) {
|
|
|
4533
5237
|
outputDir: opts.output,
|
|
4534
5238
|
target: opts.target,
|
|
4535
5239
|
apiSurface,
|
|
4536
|
-
overlayLookup
|
|
5240
|
+
overlayLookup,
|
|
5241
|
+
operationHints: opts.operationHints,
|
|
5242
|
+
mountRules: opts.mountRules
|
|
4537
5243
|
});
|
|
4538
5244
|
if (opts.dryRun) for (const f of files) console.log(f.path);
|
|
4539
5245
|
else {
|
|
@@ -4890,7 +5596,10 @@ function summarize(changes) {
|
|
|
4890
5596
|
//#endregion
|
|
4891
5597
|
//#region src/cli/diff.ts
|
|
4892
5598
|
async function diffCommand(opts) {
|
|
4893
|
-
const parseOptions = {
|
|
5599
|
+
const parseOptions = {
|
|
5600
|
+
operationIdTransform: opts.operationIdTransform,
|
|
5601
|
+
schemaNameTransform: opts.schemaNameTransform
|
|
5602
|
+
};
|
|
4894
5603
|
let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
|
|
4895
5604
|
if (opts.docUrl) {
|
|
4896
5605
|
oldSpec = expandDocUrls(oldSpec, opts.docUrl);
|
|
@@ -5685,7 +6394,7 @@ function runStalenessCheck(baseline, oldSpec, newSpec, lang) {
|
|
|
5685
6394
|
const __filename = fileURLToPath(import.meta.url);
|
|
5686
6395
|
const __dirname = path$1.dirname(__filename);
|
|
5687
6396
|
function resolveScript(scriptRelPath) {
|
|
5688
|
-
const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".
|
|
6397
|
+
const distScript = path$1.resolve(__dirname, "..", "..", "dist", "scripts", scriptRelPath.replace(/\.ts$/, ".mjs"));
|
|
5689
6398
|
if (existsSync(distScript)) return {
|
|
5690
6399
|
bin: "node",
|
|
5691
6400
|
script: distScript
|
|
@@ -5804,7 +6513,7 @@ function printCompatResult(result, baseline) {
|
|
|
5804
6513
|
console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
|
|
5805
6514
|
}
|
|
5806
6515
|
async function verifyCommand(opts) {
|
|
5807
|
-
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;
|
|
5808
6517
|
const maxRetries = opts.maxRetries ?? 3;
|
|
5809
6518
|
const diagData = {};
|
|
5810
6519
|
let stepNum = 1;
|
|
@@ -5815,7 +6524,10 @@ async function verifyCommand(opts) {
|
|
|
5815
6524
|
console.log(separator);
|
|
5816
6525
|
const effectiveScope = scope ?? (spec ? "spec-only" : "full");
|
|
5817
6526
|
let parsedSpec;
|
|
5818
|
-
if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
|
|
6527
|
+
if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
|
|
6528
|
+
operationIdTransform,
|
|
6529
|
+
schemaNameTransform
|
|
6530
|
+
});
|
|
5819
6531
|
else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
|
|
5820
6532
|
const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
|
|
5821
6533
|
baseline,
|
|
@@ -5858,7 +6570,13 @@ async function verifyCommand(opts) {
|
|
|
5858
6570
|
console.log(`\n${separator}`);
|
|
5859
6571
|
console.log(`Step ${stepNum}: Staleness detection`);
|
|
5860
6572
|
console.log(separator);
|
|
5861
|
-
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);
|
|
5862
6580
|
if (stalenessResult.violations.length > 0) {
|
|
5863
6581
|
console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
|
|
5864
6582
|
for (const v of stalenessResult.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
|
|
@@ -6135,6 +6853,50 @@ async function initCommand(opts) {
|
|
|
6135
6853
|
console.log(" - npm run sdk:verify -- --spec <path-to-spec>");
|
|
6136
6854
|
}
|
|
6137
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
|
|
6138
6900
|
//#region src/cli/config-loader.ts
|
|
6139
6901
|
const CONFIG_NAMES = [
|
|
6140
6902
|
"oagen.config.ts",
|
|
@@ -6170,14 +6932,20 @@ function handleError(err) {
|
|
|
6170
6932
|
}
|
|
6171
6933
|
let configSmokeRunners;
|
|
6172
6934
|
let configOperationIdTransform;
|
|
6935
|
+
let configSchemaNameTransform;
|
|
6173
6936
|
let configDocUrl;
|
|
6937
|
+
let configOperationHints;
|
|
6938
|
+
let configMountRules;
|
|
6174
6939
|
try {
|
|
6175
6940
|
const config = await loadConfig();
|
|
6176
6941
|
if (config) {
|
|
6177
6942
|
applyConfig(config);
|
|
6178
6943
|
configSmokeRunners = config.smokeRunners;
|
|
6179
6944
|
configOperationIdTransform = config.operationIdTransform;
|
|
6945
|
+
configSchemaNameTransform = config.schemaNameTransform;
|
|
6180
6946
|
configDocUrl = config.docUrl;
|
|
6947
|
+
configOperationHints = config.operationHints;
|
|
6948
|
+
configMountRules = config.mountRules;
|
|
6181
6949
|
}
|
|
6182
6950
|
} catch (err) {
|
|
6183
6951
|
handleError(err);
|
|
@@ -6200,13 +6968,17 @@ program.command("generate").description("Run a registered emitter against an Ope
|
|
|
6200
6968
|
generateCommand({
|
|
6201
6969
|
...opts,
|
|
6202
6970
|
operationIdTransform: configOperationIdTransform,
|
|
6203
|
-
|
|
6971
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
6972
|
+
docUrl: configDocUrl,
|
|
6973
|
+
operationHints: configOperationHints,
|
|
6974
|
+
mountRules: configMountRules
|
|
6204
6975
|
}).catch(handleError);
|
|
6205
6976
|
});
|
|
6206
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) => {
|
|
6207
6978
|
diffCommand({
|
|
6208
6979
|
...opts,
|
|
6209
6980
|
operationIdTransform: configOperationIdTransform,
|
|
6981
|
+
schemaNameTransform: configSchemaNameTransform,
|
|
6210
6982
|
docUrl: configDocUrl
|
|
6211
6983
|
}).catch(handleError);
|
|
6212
6984
|
});
|
|
@@ -6220,7 +6992,23 @@ program.command("verify").description("Run smoke tests (and optional compat chec
|
|
|
6220
6992
|
verifyCommand({
|
|
6221
6993
|
...opts,
|
|
6222
6994
|
maxRetries: parseInt(opts.maxRetries, 10),
|
|
6223
|
-
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
|
|
6224
7012
|
}).catch(handleError);
|
|
6225
7013
|
});
|
|
6226
7014
|
program.command("init").description("Scaffold a new emitter project").requiredOption("--lang <language>", "Target language").option("--project <dir>", "Project directory", ".").action((opts) => {
|