@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
|
@@ -1,10 +1,147 @@
|
|
|
1
|
-
import { A as walkTypeRef, C as assertNever, f as toSnakeCase, i as normalizeJsExtension, t as safeParse, x as RegistryError } from "./tree-sitter-
|
|
1
|
+
import { A as walkTypeRef, C as assertNever, f as toSnakeCase, i as normalizeJsExtension, t as safeParse, x as RegistryError } from "./tree-sitter-C53w_0WE.mjs";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path$1 from "node:path";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import Parser from "tree-sitter";
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
//#region src/ir/operation-hints.ts
|
|
9
|
+
/** Minimal snake_case converter for path segments (avoids cross-layer import). */
|
|
10
|
+
function toSnake(s) {
|
|
11
|
+
return s.replace(/([a-z])([A-Z])/g, "$1_$2").replace(/[-\s.]+/g, "_").toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Action verbs recognised at the terminal path segment.
|
|
15
|
+
* When a path ends with one of these, we use it as the verb instead of
|
|
16
|
+
* deriving a CRUD verb from the HTTP method.
|
|
17
|
+
*/
|
|
18
|
+
const ACTION_VERBS = new Set([
|
|
19
|
+
"accept",
|
|
20
|
+
"activate",
|
|
21
|
+
"authorize",
|
|
22
|
+
"challenge",
|
|
23
|
+
"check",
|
|
24
|
+
"complete",
|
|
25
|
+
"confirm",
|
|
26
|
+
"deactivate",
|
|
27
|
+
"disable",
|
|
28
|
+
"enable",
|
|
29
|
+
"enroll",
|
|
30
|
+
"generate",
|
|
31
|
+
"reactivate",
|
|
32
|
+
"resend",
|
|
33
|
+
"revoke",
|
|
34
|
+
"send",
|
|
35
|
+
"verify"
|
|
36
|
+
]);
|
|
37
|
+
/** Map HTTP method → default CRUD verb. */
|
|
38
|
+
const CRUD_VERB = {
|
|
39
|
+
get: "list",
|
|
40
|
+
post: "create",
|
|
41
|
+
put: "update",
|
|
42
|
+
patch: "update",
|
|
43
|
+
delete: "delete",
|
|
44
|
+
head: "get",
|
|
45
|
+
options: "list",
|
|
46
|
+
trace: "list"
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Naive singularization: strips trailing "s" with common-sense exceptions.
|
|
50
|
+
* Good enough for API resource nouns; not a general-purpose English stemmer.
|
|
51
|
+
*/
|
|
52
|
+
function singularize(word) {
|
|
53
|
+
if (word.length <= 2) return word;
|
|
54
|
+
if (word.endsWith("ies")) return word.slice(0, -3) + "y";
|
|
55
|
+
if (word.endsWith("ses") || word.endsWith("zes") || word.endsWith("xes") || word.endsWith("ches") || word.endsWith("shes")) return word.slice(0, -2);
|
|
56
|
+
if (word.endsWith("ss") || word.endsWith("us") || word.endsWith("is")) return word;
|
|
57
|
+
if (word.endsWith("s")) return word.slice(0, -1);
|
|
58
|
+
return word;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Derive a snake_case method name from an operation's HTTP method and path.
|
|
62
|
+
*
|
|
63
|
+
* Rules:
|
|
64
|
+
* 1. Strip path-param segments (`{id}`, `{slug}`, etc.)
|
|
65
|
+
* 2. If the terminal segment is an action verb, use it as the verb
|
|
66
|
+
* 3. Otherwise, use the CRUD verb for the HTTP method
|
|
67
|
+
* 4. For GET on a collection (no trailing {id}), use "list"; with {id}, use "get"
|
|
68
|
+
* 5. For POST on a resource path, if only one path param, use "create" singular
|
|
69
|
+
* 6. Singularize the resource noun for single-resource operations
|
|
70
|
+
*/
|
|
71
|
+
function deriveMethodName(op, _service) {
|
|
72
|
+
const segments = op.path.split("/").filter(Boolean);
|
|
73
|
+
const staticSegments = [];
|
|
74
|
+
let trailingParam = false;
|
|
75
|
+
for (const seg of segments) if (seg.startsWith("{") && seg.endsWith("}")) trailingParam = true;
|
|
76
|
+
else {
|
|
77
|
+
staticSegments.push(seg);
|
|
78
|
+
trailingParam = false;
|
|
79
|
+
}
|
|
80
|
+
if (staticSegments.length === 0) return `${CRUD_VERB[op.httpMethod] ?? "get"}_root`;
|
|
81
|
+
const terminal = toSnake(staticSegments[staticSegments.length - 1]);
|
|
82
|
+
if (ACTION_VERBS.has(terminal)) {
|
|
83
|
+
const context = staticSegments.slice(0, -1).filter((s) => !s.startsWith("{"));
|
|
84
|
+
const resource = context.length > 0 ? toSnake(singularize(context[context.length - 1])) : "";
|
|
85
|
+
if (resource) return `${terminal}_${resource}`;
|
|
86
|
+
return terminal;
|
|
87
|
+
}
|
|
88
|
+
let verb = CRUD_VERB[op.httpMethod] ?? "get";
|
|
89
|
+
if (op.httpMethod === "get" && trailingParam) verb = "get";
|
|
90
|
+
let resource = toSnake(terminal);
|
|
91
|
+
if (staticSegments.length >= 2) {
|
|
92
|
+
const penultimate = staticSegments[staticSegments.length - 2];
|
|
93
|
+
const penIdx = segments.lastIndexOf(penultimate);
|
|
94
|
+
const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
|
|
95
|
+
if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
|
|
96
|
+
}
|
|
97
|
+
const noun = trailingParam ? singularize(resource) : resource;
|
|
98
|
+
return `${verb}_${verb === "list" ? resource : noun}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve all operations in the spec, applying hints and mount rules.
|
|
102
|
+
*
|
|
103
|
+
* @param spec - The parsed API spec
|
|
104
|
+
* @param hints - Per-operation overrides keyed by "METHOD /path"
|
|
105
|
+
* @param mountRules - Service name → target service mappings
|
|
106
|
+
* @returns Resolved operations ready for emitter consumption
|
|
107
|
+
*/
|
|
108
|
+
function resolveOperations(spec, hints, mountRules) {
|
|
109
|
+
const resolved = [];
|
|
110
|
+
const hintMap = hints ?? {};
|
|
111
|
+
const mounts = mountRules ?? {};
|
|
112
|
+
for (const service of spec.services) for (const op of service.operations) {
|
|
113
|
+
const hint = hintMap[`${op.httpMethod.toUpperCase()} ${op.path}`];
|
|
114
|
+
const methodName = hint?.name ?? deriveMethodName(op, service);
|
|
115
|
+
const mountOn = hint?.mountOn ?? mounts[service.name] ?? service.name;
|
|
116
|
+
let wrappers;
|
|
117
|
+
if (hint?.split) wrappers = hint.split.map((sh) => ({
|
|
118
|
+
name: sh.name,
|
|
119
|
+
targetVariant: sh.targetVariant,
|
|
120
|
+
defaults: sh.defaults ?? {},
|
|
121
|
+
inferFromClient: sh.inferFromClient ?? [],
|
|
122
|
+
exposedParams: sh.exposedParams ?? [],
|
|
123
|
+
optionalParams: [],
|
|
124
|
+
responseModelName: resolveResponseModelName(op)
|
|
125
|
+
}));
|
|
126
|
+
resolved.push({
|
|
127
|
+
operation: op,
|
|
128
|
+
service,
|
|
129
|
+
methodName,
|
|
130
|
+
mountOn,
|
|
131
|
+
wrappers
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return resolved;
|
|
135
|
+
}
|
|
136
|
+
/** Extract the response model name from an operation (for wrapper metadata). */
|
|
137
|
+
function resolveResponseModelName(op) {
|
|
138
|
+
if (op.httpMethod === "delete") return null;
|
|
139
|
+
const ref = op.response;
|
|
140
|
+
if (ref.kind === "model") return ref.name;
|
|
141
|
+
if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
8
145
|
//#region src/engine/merge-adapters/url-fingerprint.ts
|
|
9
146
|
/**
|
|
10
147
|
* Extract a URL path fingerprint from a method body using tree-sitter AST traversal.
|
|
@@ -913,8 +1050,9 @@ const phpMergeAdapter = {
|
|
|
913
1050
|
testFilePatterns: [/Test\.php$/],
|
|
914
1051
|
urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
|
|
915
1052
|
resolveGrammar: (mod) => {
|
|
916
|
-
|
|
917
|
-
return
|
|
1053
|
+
const m = mod?.default ?? mod;
|
|
1054
|
+
if (typeof m === "object" && m !== null && "php" in m) return m.php;
|
|
1055
|
+
return m;
|
|
918
1056
|
},
|
|
919
1057
|
parseStatements(tree, source) {
|
|
920
1058
|
const imports = [];
|
|
@@ -1413,6 +1551,35 @@ async function extractStatements(source, language) {
|
|
|
1413
1551
|
return adapter.parseStatements(tree, source);
|
|
1414
1552
|
}
|
|
1415
1553
|
/**
|
|
1554
|
+
* Return a map of top-level class name → { bodyEndLine, bodyEndCol } using
|
|
1555
|
+
* tree-sitter. `bodyEndLine` is 0-based. For brace-delimited classes the
|
|
1556
|
+
* end points at the closing brace line; for indentation-delimited classes
|
|
1557
|
+
* it points one past the last line of the class body.
|
|
1558
|
+
*/
|
|
1559
|
+
async function extractClassEndLines(source, language) {
|
|
1560
|
+
const tree = safeParse(await getParser(language), source);
|
|
1561
|
+
const map = /* @__PURE__ */ new Map();
|
|
1562
|
+
function processClassNode(node) {
|
|
1563
|
+
const nameNode = node.childForFieldName("name");
|
|
1564
|
+
if (!nameNode) return;
|
|
1565
|
+
const name = nameNode.text;
|
|
1566
|
+
const body = node.childForFieldName("body");
|
|
1567
|
+
const endRow = body ? body.endPosition.row : node.endPosition.row;
|
|
1568
|
+
if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
|
|
1569
|
+
else map.set(name, { bodyEndLine: endRow + 1 });
|
|
1570
|
+
}
|
|
1571
|
+
for (const child of tree.rootNode.children) {
|
|
1572
|
+
if (child.type === "class_definition" || child.type === "class_declaration") processClassNode(child);
|
|
1573
|
+
if (child.type === "decorated_definition") {
|
|
1574
|
+
for (const inner of child.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
|
|
1575
|
+
}
|
|
1576
|
+
if (child.type === "export_statement") {
|
|
1577
|
+
for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return map;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1416
1583
|
* Merge generated content into an existing file.
|
|
1417
1584
|
*
|
|
1418
1585
|
* - Parses both files with tree-sitter
|
|
@@ -1761,15 +1928,18 @@ async function writeFiles(files, outputDir, options) {
|
|
|
1761
1928
|
}
|
|
1762
1929
|
const adapter = language ? getMergeAdapter(language) : void 0;
|
|
1763
1930
|
if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
|
|
1764
|
-
|
|
1765
|
-
|
|
1931
|
+
if (!(header && existingContent.startsWith(header))) {
|
|
1932
|
+
result.skipped.push(file.path);
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1766
1935
|
}
|
|
1767
1936
|
if (existingContent === file.content) {
|
|
1768
1937
|
result.identical.push(file.path);
|
|
1769
1938
|
continue;
|
|
1770
1939
|
}
|
|
1771
1940
|
if (file.overwriteExisting) {
|
|
1772
|
-
|
|
1941
|
+
const content = existingContent.includes("@oagen-ignore-start") && language && hasGrammar(language) ? await overwriteWithPreservedRegions(existingContent, file.content, language) : file.content;
|
|
1942
|
+
await fs.writeFile(fullPath, content, "utf-8");
|
|
1773
1943
|
result.written.push(file.path);
|
|
1774
1944
|
continue;
|
|
1775
1945
|
}
|
|
@@ -1813,6 +1983,174 @@ async function writeFiles(files, outputDir, options) {
|
|
|
1813
1983
|
function isDefaultTestFile(filePath) {
|
|
1814
1984
|
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);
|
|
1815
1985
|
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Overwrite a file with generated content while preserving
|
|
1988
|
+
* `@oagen-ignore-start` / `@oagen-ignore-end` regions (and their
|
|
1989
|
+
* required imports) from the existing file.
|
|
1990
|
+
*
|
|
1991
|
+
* Uses tree-sitter via `extractClassEndLines` to find accurate class
|
|
1992
|
+
* boundaries so ignore blocks are inserted at the correct position even
|
|
1993
|
+
* when docstrings contain unindented text.
|
|
1994
|
+
*/
|
|
1995
|
+
async function overwriteWithPreservedRegions(existingContent, generatedContent, language) {
|
|
1996
|
+
const existingLines = existingContent.split("\n");
|
|
1997
|
+
const generatedLines = generatedContent.split("\n");
|
|
1998
|
+
const blocks = [];
|
|
1999
|
+
for (let i = 0; i < existingLines.length; i++) {
|
|
2000
|
+
if (!existingLines[i].includes("@oagen-ignore-start")) continue;
|
|
2001
|
+
const startIdx = i;
|
|
2002
|
+
while (i < existingLines.length && !existingLines[i].includes("@oagen-ignore-end")) i++;
|
|
2003
|
+
blocks.push({
|
|
2004
|
+
containingClass: findContainingClass(existingLines, startIdx),
|
|
2005
|
+
lines: existingLines.slice(startIdx, i + 1)
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
if (blocks.length === 0) return generatedContent;
|
|
2009
|
+
const result = [...generatedLines];
|
|
2010
|
+
spliceExtraImports(existingLines, result);
|
|
2011
|
+
const classEndLines = await extractClassEndLines(result.join("\n"), language);
|
|
2012
|
+
const blocksByClass = /* @__PURE__ */ new Map();
|
|
2013
|
+
for (const b of blocks) {
|
|
2014
|
+
if (!blocksByClass.has(b.containingClass)) blocksByClass.set(b.containingClass, []);
|
|
2015
|
+
blocksByClass.get(b.containingClass).push(b.lines);
|
|
2016
|
+
}
|
|
2017
|
+
const replacedClasses = /* @__PURE__ */ new Set();
|
|
2018
|
+
const handledBlockKeys = /* @__PURE__ */ new Set();
|
|
2019
|
+
const replacements = [];
|
|
2020
|
+
for (const [className, classBlocks] of blocksByClass) {
|
|
2021
|
+
let targetClass = className;
|
|
2022
|
+
if (!targetClass) for (const blockLines of classBlocks) {
|
|
2023
|
+
for (const l of blockLines) {
|
|
2024
|
+
const m = l.match(/^(?:export\s+)?class\s+(\w+)/);
|
|
2025
|
+
if (m && classEndLines.has(m[1])) {
|
|
2026
|
+
targetClass = m[1];
|
|
2027
|
+
break;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
if (targetClass) break;
|
|
2031
|
+
}
|
|
2032
|
+
if (!targetClass) continue;
|
|
2033
|
+
if (!classBlocks.some((blockLines) => blockLines.some((l) => {
|
|
2034
|
+
return l.match(/^(?:export\s+)?class\s+(\w+)/)?.[1] === targetClass;
|
|
2035
|
+
}))) continue;
|
|
2036
|
+
const info = classEndLines.get(targetClass);
|
|
2037
|
+
if (!info) continue;
|
|
2038
|
+
const startLine = findClassDeclarationStart(result, targetClass, info.bodyEndLine);
|
|
2039
|
+
if (startLine < 0) continue;
|
|
2040
|
+
const content = [];
|
|
2041
|
+
for (const block of classBlocks) {
|
|
2042
|
+
content.push("");
|
|
2043
|
+
content.push(...block);
|
|
2044
|
+
}
|
|
2045
|
+
replacements.push({
|
|
2046
|
+
startLine,
|
|
2047
|
+
endLine: info.bodyEndLine,
|
|
2048
|
+
content
|
|
2049
|
+
});
|
|
2050
|
+
replacedClasses.add(targetClass);
|
|
2051
|
+
handledBlockKeys.add(className);
|
|
2052
|
+
}
|
|
2053
|
+
replacements.sort((a, b) => b.startLine - a.startLine);
|
|
2054
|
+
for (const rep of replacements) result.splice(rep.startLine, rep.endLine - rep.startLine, ...rep.content);
|
|
2055
|
+
let effectiveClassEndLines = classEndLines;
|
|
2056
|
+
if (replacedClasses.size > 0) effectiveClassEndLines = await extractClassEndLines(result.join("\n"), language);
|
|
2057
|
+
const insertions = [];
|
|
2058
|
+
for (const [className, classBlocks] of blocksByClass) {
|
|
2059
|
+
if (handledBlockKeys.has(className) || replacedClasses.has(className)) continue;
|
|
2060
|
+
const info = className ? effectiveClassEndLines.get(className) : void 0;
|
|
2061
|
+
const insertLine = info ? info.bodyEndLine : result.length;
|
|
2062
|
+
const content = [];
|
|
2063
|
+
for (const block of classBlocks) {
|
|
2064
|
+
content.push("");
|
|
2065
|
+
content.push(...block);
|
|
2066
|
+
}
|
|
2067
|
+
insertions.push({
|
|
2068
|
+
line: insertLine,
|
|
2069
|
+
content
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
insertions.sort((a, b) => b.line - a.line);
|
|
2073
|
+
for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
|
|
2074
|
+
return result.join("\n");
|
|
2075
|
+
}
|
|
2076
|
+
/** Scan backwards from `lineIdx` to find the nearest class declaration name. */
|
|
2077
|
+
function findContainingClass(lines, lineIdx) {
|
|
2078
|
+
for (let j = lineIdx - 1; j >= 0; j--) {
|
|
2079
|
+
const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
|
|
2080
|
+
if (m) return m[1];
|
|
2081
|
+
}
|
|
2082
|
+
return "";
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Find the start line of a class declaration (including decorators above it)
|
|
2086
|
+
* by scanning backwards from `beforeLine`.
|
|
2087
|
+
*/
|
|
2088
|
+
function findClassDeclarationStart(lines, className, beforeLine) {
|
|
2089
|
+
const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
|
|
2090
|
+
for (let i = beforeLine - 1; i >= 0; i--) {
|
|
2091
|
+
if (!classPattern.test(lines[i])) continue;
|
|
2092
|
+
let start = i;
|
|
2093
|
+
while (start > 0 && /^\s*@\w+/.test(lines[start - 1])) start--;
|
|
2094
|
+
return start;
|
|
2095
|
+
}
|
|
2096
|
+
return -1;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Collect `from X import Y, Z` identifiers grouped by module, handling
|
|
2100
|
+
* both single-line and multiline (parenthesized) forms.
|
|
2101
|
+
*/
|
|
2102
|
+
function collectFromImports(lines) {
|
|
2103
|
+
const byModule = /* @__PURE__ */ new Map();
|
|
2104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2105
|
+
if (!/^from\s+/.test(lines[i])) continue;
|
|
2106
|
+
let fullText = lines[i];
|
|
2107
|
+
if (fullText.includes("(") && !fullText.includes(")")) while (i + 1 < lines.length) {
|
|
2108
|
+
i++;
|
|
2109
|
+
fullText += " " + lines[i].trim();
|
|
2110
|
+
if (lines[i].includes(")")) break;
|
|
2111
|
+
}
|
|
2112
|
+
const match = fullText.replace(/[()]/g, "").replace(/\s+/g, " ").trim().match(/^from\s+(\S+)\s+import\s+(.+)$/);
|
|
2113
|
+
if (!match) continue;
|
|
2114
|
+
const mod = match[1];
|
|
2115
|
+
if (!byModule.has(mod)) byModule.set(mod, /* @__PURE__ */ new Set());
|
|
2116
|
+
for (const id of match[2].split(",").map((s) => s.trim()).filter(Boolean)) byModule.get(mod).add(id);
|
|
2117
|
+
}
|
|
2118
|
+
return byModule;
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Add imports from the existing file that are absent from the generated
|
|
2122
|
+
* result. Handles both `import X` and `from X import Y` forms.
|
|
2123
|
+
* Mutates `resultLines` in place.
|
|
2124
|
+
*/
|
|
2125
|
+
function spliceExtraImports(existingLines, resultLines) {
|
|
2126
|
+
const isSimpleImport = (l) => /^import\s+\w/.test(l);
|
|
2127
|
+
const existingSimple = new Set(existingLines.filter(isSimpleImport).map((l) => l.trim()));
|
|
2128
|
+
const generatedSimple = new Set(resultLines.filter(isSimpleImport).map((l) => l.trim()));
|
|
2129
|
+
const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
|
|
2130
|
+
const existingFrom = collectFromImports(existingLines);
|
|
2131
|
+
const generatedFrom = collectFromImports(resultLines);
|
|
2132
|
+
const extraFromLines = [];
|
|
2133
|
+
for (const [mod, existIds] of existingFrom) {
|
|
2134
|
+
const genIds = generatedFrom.get(mod);
|
|
2135
|
+
if (!genIds) extraFromLines.push(`from ${mod} import ${[...existIds].join(", ")}`);
|
|
2136
|
+
else {
|
|
2137
|
+
const extra = [...existIds].filter((id) => !genIds.has(id));
|
|
2138
|
+
if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
const allExtra = [...extraSimple, ...extraFromLines];
|
|
2142
|
+
if (allExtra.length === 0) return;
|
|
2143
|
+
let lastImportIdx = -1;
|
|
2144
|
+
for (let i = 0; i < resultLines.length; i++) if (isSimpleImport(resultLines[i]) || /^from\s+/.test(resultLines[i])) {
|
|
2145
|
+
lastImportIdx = i;
|
|
2146
|
+
if (resultLines[i].includes("(") && !resultLines[i].includes(")")) while (i + 1 < resultLines.length) {
|
|
2147
|
+
i++;
|
|
2148
|
+
lastImportIdx = i;
|
|
2149
|
+
if (resultLines[i].includes(")")) break;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
if (lastImportIdx >= 0) resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
|
|
2153
|
+
}
|
|
1816
2154
|
//#endregion
|
|
1817
2155
|
//#region src/engine/generate-files.ts
|
|
1818
2156
|
function buildEmitterContext(spec, options) {
|
|
@@ -1822,7 +2160,8 @@ function buildEmitterContext(spec, options) {
|
|
|
1822
2160
|
spec,
|
|
1823
2161
|
outputDir: options.outputDir,
|
|
1824
2162
|
apiSurface: options.apiSurface,
|
|
1825
|
-
overlayLookup: options.overlayLookup
|
|
2163
|
+
overlayLookup: options.overlayLookup,
|
|
2164
|
+
resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
|
|
1826
2165
|
};
|
|
1827
2166
|
}
|
|
1828
2167
|
/**
|
|
@@ -1852,6 +2191,7 @@ function collectReferencedNames(services, models) {
|
|
|
1852
2191
|
for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
|
|
1853
2192
|
if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
|
|
1854
2193
|
}
|
|
2194
|
+
for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
|
|
1855
2195
|
const modelsByName = new Map(models.map((m) => [m.name, m]));
|
|
1856
2196
|
const visited = /* @__PURE__ */ new Set();
|
|
1857
2197
|
const queue = [...referencedModels];
|
|
@@ -1871,6 +2211,9 @@ function collectReferencedNames(services, models) {
|
|
|
1871
2211
|
enums: referencedEnums
|
|
1872
2212
|
};
|
|
1873
2213
|
}
|
|
2214
|
+
function isDiscriminatedModel(model) {
|
|
2215
|
+
return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
|
|
2216
|
+
}
|
|
1874
2217
|
/** Collect all generated files from an emitter (no headers, no path prefixes). */
|
|
1875
2218
|
function generateAllFiles(spec, emitter, ctx) {
|
|
1876
2219
|
const referenced = collectReferencedNames(spec.services, spec.models);
|
|
@@ -1971,11 +2314,33 @@ async function treeShakeFiles(files, language) {
|
|
|
1971
2314
|
}
|
|
1972
2315
|
deps.set(file.path, fileDeps);
|
|
1973
2316
|
}
|
|
2317
|
+
const codeExtensions = new Set([
|
|
2318
|
+
".ts",
|
|
2319
|
+
".js",
|
|
2320
|
+
".tsx",
|
|
2321
|
+
".jsx",
|
|
2322
|
+
".py",
|
|
2323
|
+
".rb",
|
|
2324
|
+
".go",
|
|
2325
|
+
".rs",
|
|
2326
|
+
".kt",
|
|
2327
|
+
".cs",
|
|
2328
|
+
".ex",
|
|
2329
|
+
".exs",
|
|
2330
|
+
".php"
|
|
2331
|
+
]);
|
|
1974
2332
|
const reachable = /* @__PURE__ */ new Set();
|
|
1975
2333
|
const queue = [];
|
|
1976
|
-
for (const file of files)
|
|
1977
|
-
|
|
1978
|
-
|
|
2334
|
+
for (const file of files) {
|
|
2335
|
+
const ext = file.path.slice(file.path.lastIndexOf("."));
|
|
2336
|
+
if (!codeExtensions.has(ext)) {
|
|
2337
|
+
reachable.add(file.path);
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
if (isRootFile(file.path)) {
|
|
2341
|
+
reachable.add(file.path);
|
|
2342
|
+
queue.push(file.path);
|
|
2343
|
+
}
|
|
1979
2344
|
}
|
|
1980
2345
|
while (queue.length > 0) {
|
|
1981
2346
|
const current = queue.pop();
|
|
@@ -1999,11 +2364,61 @@ function mapFilesForTargetIntegration(files, language) {
|
|
|
1999
2364
|
};
|
|
2000
2365
|
});
|
|
2001
2366
|
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Resolve file-to-directory conflicts in the target.
|
|
2369
|
+
*
|
|
2370
|
+
* When a generated file creates a package directory (e.g., `api_keys/__init__.py`)
|
|
2371
|
+
* that would shadow an existing module file (e.g., `api_keys.py`), move the
|
|
2372
|
+
* module file's content into `_compat.py` inside the package and add a
|
|
2373
|
+
* re-export from `__init__.py`. This preserves backward-compatible imports
|
|
2374
|
+
* (e.g., `from workos.api_keys import ApiKeysModule`) while the package directory
|
|
2375
|
+
* provides the generated models and resource classes.
|
|
2376
|
+
*/
|
|
2377
|
+
async function resolveFileToDirectoryConflicts(files, targetDir) {
|
|
2378
|
+
const result = [...files];
|
|
2379
|
+
const removals = [];
|
|
2380
|
+
for (let i = 0; i < result.length; i++) {
|
|
2381
|
+
const file = result[i];
|
|
2382
|
+
const match = file.path.match(/^(.+)\/__init__\.py$/);
|
|
2383
|
+
if (!match) continue;
|
|
2384
|
+
const dirPath = match[1];
|
|
2385
|
+
const moduleFilePath = `${dirPath}.py`;
|
|
2386
|
+
const fullModulePath = path.join(targetDir, moduleFilePath);
|
|
2387
|
+
let moduleContent;
|
|
2388
|
+
try {
|
|
2389
|
+
moduleContent = await fs.readFile(fullModulePath, "utf-8");
|
|
2390
|
+
} catch {
|
|
2391
|
+
continue;
|
|
2392
|
+
}
|
|
2393
|
+
const compatPath = `${dirPath}/_compat.py`;
|
|
2394
|
+
result.push({
|
|
2395
|
+
path: compatPath,
|
|
2396
|
+
content: moduleContent,
|
|
2397
|
+
overwriteExisting: true
|
|
2398
|
+
});
|
|
2399
|
+
const compatImport = "from ._compat import * # noqa: F401,F403 # pyright: ignore[reportUnusedImport]";
|
|
2400
|
+
const existingContent = file.content.endsWith("\n") ? file.content : file.content + "\n";
|
|
2401
|
+
result[i] = {
|
|
2402
|
+
...file,
|
|
2403
|
+
content: existingContent + compatImport + "\n"
|
|
2404
|
+
};
|
|
2405
|
+
removals.push(fullModulePath);
|
|
2406
|
+
}
|
|
2407
|
+
return {
|
|
2408
|
+
files: result,
|
|
2409
|
+
removals
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2002
2412
|
async function integrateGeneratedFiles(opts) {
|
|
2003
|
-
|
|
2413
|
+
const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
|
|
2414
|
+
const result = await writeFiles(resolved, opts.targetDir, {
|
|
2004
2415
|
language: opts.language,
|
|
2005
2416
|
header: opts.header
|
|
2006
2417
|
});
|
|
2418
|
+
for (const filePath of removals) try {
|
|
2419
|
+
await fs.unlink(filePath);
|
|
2420
|
+
} catch {}
|
|
2421
|
+
return result;
|
|
2007
2422
|
}
|
|
2008
2423
|
//#endregion
|
|
2009
2424
|
//#region src/engine/formatter.ts
|
|
@@ -2064,7 +2479,11 @@ async function generate(spec, emitter, options) {
|
|
|
2064
2479
|
if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
|
|
2065
2480
|
if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
|
|
2066
2481
|
if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
|
|
2067
|
-
const allTargetFiles = [
|
|
2482
|
+
const allTargetFiles = [
|
|
2483
|
+
...targetResult.written,
|
|
2484
|
+
...targetResult.merged,
|
|
2485
|
+
...targetResult.identical
|
|
2486
|
+
];
|
|
2068
2487
|
if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
|
|
2069
2488
|
}
|
|
2070
2489
|
return withHeaders;
|
|
@@ -2427,6 +2846,6 @@ function summarize(changes) {
|
|
|
2427
2846
|
};
|
|
2428
2847
|
}
|
|
2429
2848
|
//#endregion
|
|
2430
|
-
export { integrateGeneratedFiles as a, applyFileHeaders as c, generateAllFiles as d, generateFiles as f, generate as i, buildEmitterContext as l, getEmitter as n, mapFilesForTargetIntegration as o, registerEmitter as r, treeShakeFiles as s, diffSpecs as t, collectReferencedNames as u };
|
|
2849
|
+
export { integrateGeneratedFiles as a, applyFileHeaders as c, generateAllFiles as d, generateFiles as f, generate as i, buildEmitterContext as l, resolveOperations as m, getEmitter as n, mapFilesForTargetIntegration as o, deriveMethodName as p, registerEmitter as r, treeShakeFiles as s, diffSpecs as t, collectReferencedNames as u };
|
|
2431
2850
|
|
|
2432
|
-
//# sourceMappingURL=diff-
|
|
2851
|
+
//# sourceMappingURL=diff-DcY93sii.mjs.map
|