@workos/oagen 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/cli/index.mjs +1076 -73
- package/dist/cli/index.mjs.map +1 -1
- package/dist/compat/index.d.mts +1 -1
- package/dist/compat/index.mjs +9 -4
- package/dist/compat/index.mjs.map +1 -1
- package/dist/{diff-DyQF4sel.mjs → diff-DcY93sii.mjs} +619 -18
- package/dist/diff-DcY93sii.mjs.map +1 -0
- package/dist/{differ-BwelBJN0.mjs → differ-CG5ZErKl.mjs} +2 -2
- package/dist/{differ-BwelBJN0.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
- package/dist/index.d.mts +136 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +337 -29
- package/dist/index.mjs.map +1 -1
- package/dist/scripts/smoke/baseline.mjs +351 -34
- package/dist/scripts/smoke/baseline.mjs.map +1 -1
- package/dist/smoke.d.mts +138 -0
- package/dist/smoke.d.mts.map +1 -1
- package/dist/smoke.mjs +351 -34
- package/dist/smoke.mjs.map +1 -1
- package/dist/{tree-sitter-BPNfbEzY.mjs → tree-sitter-C53w_0WE.mjs} +42 -9
- package/dist/tree-sitter-C53w_0WE.mjs.map +1 -0
- package/dist/{types-O25qZxMl.d.mts → types-D1tMqLVB.d.mts} +165 -2
- package/dist/types-D1tMqLVB.d.mts.map +1 -0
- package/dist/verify/index.d.mts +1 -1
- package/dist/verify/index.mjs +5 -5
- package/dist/verify/index.mjs.map +1 -1
- package/docs/architecture/ir-types.md +73 -1
- package/docs/cli.md +29 -8
- package/package.json +4 -4
- package/skills/review-operations/SKILL.md +83 -0
- package/dist/diff-DyQF4sel.mjs.map +0 -1
- package/dist/tree-sitter-BPNfbEzY.mjs.map +0 -1
- package/dist/types-O25qZxMl.d.mts.map +0 -1
|
@@ -1,7 +1,147 @@
|
|
|
1
|
-
import { 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
|
+
import path from "node:path";
|
|
4
5
|
import Parser from "tree-sitter";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
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
|
|
5
145
|
//#region src/engine/merge-adapters/url-fingerprint.ts
|
|
6
146
|
/**
|
|
7
147
|
* Extract a URL path fingerprint from a method body using tree-sitter AST traversal.
|
|
@@ -910,8 +1050,9 @@ const phpMergeAdapter = {
|
|
|
910
1050
|
testFilePatterns: [/Test\.php$/],
|
|
911
1051
|
urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
|
|
912
1052
|
resolveGrammar: (mod) => {
|
|
913
|
-
|
|
914
|
-
return
|
|
1053
|
+
const m = mod?.default ?? mod;
|
|
1054
|
+
if (typeof m === "object" && m !== null && "php" in m) return m.php;
|
|
1055
|
+
return m;
|
|
915
1056
|
},
|
|
916
1057
|
parseStatements(tree, source) {
|
|
917
1058
|
const imports = [];
|
|
@@ -1410,6 +1551,35 @@ async function extractStatements(source, language) {
|
|
|
1410
1551
|
return adapter.parseStatements(tree, source);
|
|
1411
1552
|
}
|
|
1412
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
|
+
/**
|
|
1413
1583
|
* Merge generated content into an existing file.
|
|
1414
1584
|
*
|
|
1415
1585
|
* - Parses both files with tree-sitter
|
|
@@ -1558,11 +1728,27 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
|
|
|
1558
1728
|
const addedParts = [...toAppend];
|
|
1559
1729
|
if (insertions) for (const ins of insertions) addedParts.push(ins.text);
|
|
1560
1730
|
const addedCodeText = addedParts.join("\n");
|
|
1561
|
-
const filteredImports =
|
|
1731
|
+
const filteredImports = [];
|
|
1732
|
+
for (const imp of newImports) {
|
|
1562
1733
|
const braceMatch = imp.text.match(/\{([^}]+)\}/);
|
|
1563
|
-
if (!braceMatch)
|
|
1564
|
-
|
|
1565
|
-
|
|
1734
|
+
if (!braceMatch) {
|
|
1735
|
+
filteredImports.push(imp);
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
const names = braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean);
|
|
1739
|
+
const usedNames = names.filter((name) => addedCodeText.includes(name));
|
|
1740
|
+
if (usedNames.length === 0) continue;
|
|
1741
|
+
if (usedNames.length === names.length) filteredImports.push(imp);
|
|
1742
|
+
else {
|
|
1743
|
+
const prefix = imp.text.trimStart().startsWith("import type") ? "import type" : "import";
|
|
1744
|
+
const sourceMatch = imp.text.match(/from\s+(['"][^'"]+['"]);?/);
|
|
1745
|
+
if (sourceMatch) filteredImports.push({
|
|
1746
|
+
key: imp.key,
|
|
1747
|
+
text: `${prefix} { ${usedNames.join(", ")} } from ${sourceMatch[1]};`
|
|
1748
|
+
});
|
|
1749
|
+
else filteredImports.push(imp);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1566
1752
|
if (filteredImports.length > 0) {
|
|
1567
1753
|
const renderedImports = adapter.renderImports ? adapter.renderImports(filteredImports) : filteredImports.map((entry) => entry.text);
|
|
1568
1754
|
const lines = result.split("\n");
|
|
@@ -1742,15 +1928,18 @@ async function writeFiles(files, outputDir, options) {
|
|
|
1742
1928
|
}
|
|
1743
1929
|
const adapter = language ? getMergeAdapter(language) : void 0;
|
|
1744
1930
|
if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
|
|
1745
|
-
|
|
1746
|
-
|
|
1931
|
+
if (!(header && existingContent.startsWith(header))) {
|
|
1932
|
+
result.skipped.push(file.path);
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1747
1935
|
}
|
|
1748
1936
|
if (existingContent === file.content) {
|
|
1749
1937
|
result.identical.push(file.path);
|
|
1750
1938
|
continue;
|
|
1751
1939
|
}
|
|
1752
1940
|
if (file.overwriteExisting) {
|
|
1753
|
-
|
|
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");
|
|
1754
1943
|
result.written.push(file.path);
|
|
1755
1944
|
continue;
|
|
1756
1945
|
}
|
|
@@ -1794,6 +1983,174 @@ async function writeFiles(files, outputDir, options) {
|
|
|
1794
1983
|
function isDefaultTestFile(filePath) {
|
|
1795
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);
|
|
1796
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
|
+
}
|
|
1797
2154
|
//#endregion
|
|
1798
2155
|
//#region src/engine/generate-files.ts
|
|
1799
2156
|
function buildEmitterContext(spec, options) {
|
|
@@ -1803,20 +2160,79 @@ function buildEmitterContext(spec, options) {
|
|
|
1803
2160
|
spec,
|
|
1804
2161
|
outputDir: options.outputDir,
|
|
1805
2162
|
apiSurface: options.apiSurface,
|
|
1806
|
-
overlayLookup: options.overlayLookup
|
|
2163
|
+
overlayLookup: options.overlayLookup,
|
|
2164
|
+
resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
|
|
1807
2165
|
};
|
|
1808
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* Collect model and enum names transitively referenced by service operations.
|
|
2169
|
+
* Walks operation parameters, request bodies, responses, and pagination item
|
|
2170
|
+
* types, then chases model field references until the set stabilizes.
|
|
2171
|
+
*/
|
|
2172
|
+
function collectReferencedNames(services, models) {
|
|
2173
|
+
const referencedModels = /* @__PURE__ */ new Set();
|
|
2174
|
+
const referencedEnums = /* @__PURE__ */ new Set();
|
|
2175
|
+
const collectFromTypeRef = (ref) => {
|
|
2176
|
+
walkTypeRef(ref, {
|
|
2177
|
+
model: (r) => referencedModels.add(r.name),
|
|
2178
|
+
enum: (r) => referencedEnums.add(r.name)
|
|
2179
|
+
});
|
|
2180
|
+
};
|
|
2181
|
+
for (const service of services) for (const op of service.operations) {
|
|
2182
|
+
for (const p of [
|
|
2183
|
+
...op.pathParams,
|
|
2184
|
+
...op.queryParams,
|
|
2185
|
+
...op.headerParams,
|
|
2186
|
+
...op.cookieParams ?? []
|
|
2187
|
+
]) collectFromTypeRef(p.type);
|
|
2188
|
+
if (op.requestBody) collectFromTypeRef(op.requestBody);
|
|
2189
|
+
collectFromTypeRef(op.response);
|
|
2190
|
+
if (op.pagination) collectFromTypeRef(op.pagination.itemType);
|
|
2191
|
+
for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
|
|
2192
|
+
if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
|
|
2193
|
+
}
|
|
2194
|
+
for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
|
|
2195
|
+
const modelsByName = new Map(models.map((m) => [m.name, m]));
|
|
2196
|
+
const visited = /* @__PURE__ */ new Set();
|
|
2197
|
+
const queue = [...referencedModels];
|
|
2198
|
+
while (queue.length > 0) {
|
|
2199
|
+
const name = queue.pop();
|
|
2200
|
+
if (visited.has(name)) continue;
|
|
2201
|
+
visited.add(name);
|
|
2202
|
+
const model = modelsByName.get(name);
|
|
2203
|
+
if (!model) continue;
|
|
2204
|
+
for (const field of model.fields) {
|
|
2205
|
+
collectFromTypeRef(field.type);
|
|
2206
|
+
for (const m of referencedModels) if (!visited.has(m)) queue.push(m);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
return {
|
|
2210
|
+
models: referencedModels,
|
|
2211
|
+
enums: referencedEnums
|
|
2212
|
+
};
|
|
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
|
+
}
|
|
1809
2217
|
/** Collect all generated files from an emitter (no headers, no path prefixes). */
|
|
1810
2218
|
function generateAllFiles(spec, emitter, ctx) {
|
|
2219
|
+
const referenced = collectReferencedNames(spec.services, spec.models);
|
|
2220
|
+
const reachableModels = spec.models.filter((m) => referenced.models.has(m.name));
|
|
2221
|
+
const reachableEnums = spec.enums.filter((e) => referenced.enums.has(e.name));
|
|
2222
|
+
const reachableSpec = {
|
|
2223
|
+
...spec,
|
|
2224
|
+
models: reachableModels,
|
|
2225
|
+
enums: reachableEnums
|
|
2226
|
+
};
|
|
1811
2227
|
return [
|
|
1812
|
-
...emitter.generateModels(
|
|
1813
|
-
...emitter.generateEnums(
|
|
2228
|
+
...emitter.generateModels(reachableModels, ctx),
|
|
2229
|
+
...emitter.generateEnums(reachableEnums, ctx),
|
|
1814
2230
|
...emitter.generateResources(spec.services, ctx),
|
|
1815
2231
|
...emitter.generateClient(spec, ctx),
|
|
1816
2232
|
...emitter.generateErrors(ctx),
|
|
1817
2233
|
...emitter.generateConfig(ctx),
|
|
1818
|
-
...emitter.generateTypeSignatures?.(
|
|
1819
|
-
...emitter.generateTests(
|
|
2234
|
+
...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
|
|
2235
|
+
...emitter.generateTests(reachableSpec, ctx),
|
|
1820
2236
|
...emitter.generateManifest?.(spec, ctx) ?? []
|
|
1821
2237
|
];
|
|
1822
2238
|
}
|
|
@@ -1840,6 +2256,103 @@ function generateFiles(spec, emitter, options) {
|
|
|
1840
2256
|
}
|
|
1841
2257
|
//#endregion
|
|
1842
2258
|
//#region src/engine/integrate.ts
|
|
2259
|
+
/**
|
|
2260
|
+
* Resolve a relative import path against a source file path to get the
|
|
2261
|
+
* target file path. Handles implicit .ts extensions and /index.ts.
|
|
2262
|
+
*/
|
|
2263
|
+
function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
|
|
2264
|
+
const cleaned = importModulePath.replace(/^['"]|['"]$/g, "");
|
|
2265
|
+
if (!cleaned.startsWith(".")) return void 0;
|
|
2266
|
+
const dir = path.dirname(fromFilePath);
|
|
2267
|
+
const resolved = path.normalize(path.join(dir, cleaned));
|
|
2268
|
+
for (const candidate of [
|
|
2269
|
+
resolved,
|
|
2270
|
+
`${resolved}.ts`,
|
|
2271
|
+
`${resolved}/index.ts`
|
|
2272
|
+
]) if (knownPaths.has(candidate)) return candidate;
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Determine whether a generated file is a "root" — a file that should always
|
|
2276
|
+
* be integrated into the target. Roots are entry points from which we trace
|
|
2277
|
+
* the import graph. Everything else must be reachable from a root.
|
|
2278
|
+
*
|
|
2279
|
+
* Roots: resource classes, client, config, errors, enums, barrel index files.
|
|
2280
|
+
* Non-roots: serializers, fixtures, standalone interfaces, test files.
|
|
2281
|
+
*/
|
|
2282
|
+
function isRootFile(filePath) {
|
|
2283
|
+
if (filePath.includes("/fixtures/")) return false;
|
|
2284
|
+
if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
|
|
2285
|
+
if (filePath.includes("/serializers/")) return false;
|
|
2286
|
+
if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
|
|
2287
|
+
return true;
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Build a file-level dependency graph from generated files and return only
|
|
2291
|
+
* files reachable from root entry points. Uses tree-sitter to parse imports.
|
|
2292
|
+
*/
|
|
2293
|
+
async function treeShakeFiles(files, language) {
|
|
2294
|
+
if (!hasGrammar(language)) return files;
|
|
2295
|
+
const knownPaths = new Set(files.map((f) => f.path));
|
|
2296
|
+
const deps = /* @__PURE__ */ new Map();
|
|
2297
|
+
for (const file of files) {
|
|
2298
|
+
if (!file.path.endsWith(".ts") && !file.path.endsWith(".js")) {
|
|
2299
|
+
deps.set(file.path, /* @__PURE__ */ new Set());
|
|
2300
|
+
continue;
|
|
2301
|
+
}
|
|
2302
|
+
const parsed = await extractStatements(file.content, language);
|
|
2303
|
+
const fileDeps = /* @__PURE__ */ new Set();
|
|
2304
|
+
for (const imp of parsed.imports) {
|
|
2305
|
+
const resolved = resolveImportPath(file.path, imp.key, knownPaths);
|
|
2306
|
+
if (resolved) fileDeps.add(resolved);
|
|
2307
|
+
}
|
|
2308
|
+
for (const stmt of parsed.statements) if (stmt.kind === "reexport") {
|
|
2309
|
+
const match = stmt.text.match(/from\s+['"]([^'"]+)['"]/);
|
|
2310
|
+
if (match) {
|
|
2311
|
+
const resolved = resolveImportPath(file.path, match[1], knownPaths);
|
|
2312
|
+
if (resolved) fileDeps.add(resolved);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
deps.set(file.path, fileDeps);
|
|
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
|
+
]);
|
|
2332
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
2333
|
+
const queue = [];
|
|
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
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
while (queue.length > 0) {
|
|
2346
|
+
const current = queue.pop();
|
|
2347
|
+
const fileDeps = deps.get(current);
|
|
2348
|
+
if (!fileDeps) continue;
|
|
2349
|
+
for (const dep of fileDeps) if (!reachable.has(dep)) {
|
|
2350
|
+
reachable.add(dep);
|
|
2351
|
+
queue.push(dep);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
return files.filter((f) => reachable.has(f.path));
|
|
2355
|
+
}
|
|
1843
2356
|
function mapFilesForTargetIntegration(files, language) {
|
|
1844
2357
|
const langPrefix = `${language}/`;
|
|
1845
2358
|
return files.filter((f) => f.integrateTarget !== false).map((f) => {
|
|
@@ -1851,11 +2364,93 @@ function mapFilesForTargetIntegration(files, language) {
|
|
|
1851
2364
|
};
|
|
1852
2365
|
});
|
|
1853
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
|
+
}
|
|
1854
2412
|
async function integrateGeneratedFiles(opts) {
|
|
1855
|
-
|
|
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, {
|
|
1856
2415
|
language: opts.language,
|
|
1857
2416
|
header: opts.header
|
|
1858
2417
|
});
|
|
2418
|
+
for (const filePath of removals) try {
|
|
2419
|
+
await fs.unlink(filePath);
|
|
2420
|
+
} catch {}
|
|
2421
|
+
return result;
|
|
2422
|
+
}
|
|
2423
|
+
//#endregion
|
|
2424
|
+
//#region src/engine/formatter.ts
|
|
2425
|
+
/**
|
|
2426
|
+
* Post-integration formatter.
|
|
2427
|
+
*
|
|
2428
|
+
* After generating and merging files into a target directory, runs the
|
|
2429
|
+
* emitter-provided format command on all written/merged files so that
|
|
2430
|
+
* generated code matches the target project's style conventions.
|
|
2431
|
+
*/
|
|
2432
|
+
const execFileAsync = promisify(execFile);
|
|
2433
|
+
/**
|
|
2434
|
+
* Run the emitter's format command on the given target files.
|
|
2435
|
+
* Silently skips if the emitter has no formatCommand or returns null.
|
|
2436
|
+
*/
|
|
2437
|
+
async function formatTargetFiles(emitter, targetDir, filePaths) {
|
|
2438
|
+
if (filePaths.length === 0) return;
|
|
2439
|
+
if (!emitter.formatCommand) return;
|
|
2440
|
+
const formatCmd = emitter.formatCommand(targetDir);
|
|
2441
|
+
if (!formatCmd) return;
|
|
2442
|
+
const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
|
|
2443
|
+
const batchSize = formatCmd.batchSize ?? 100;
|
|
2444
|
+
for (let i = 0; i < absolutePaths.length; i += batchSize) {
|
|
2445
|
+
const batch = absolutePaths.slice(i, i + batchSize);
|
|
2446
|
+
try {
|
|
2447
|
+
await execFileAsync(formatCmd.cmd, [...formatCmd.args, ...batch], {
|
|
2448
|
+
cwd: targetDir,
|
|
2449
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
2450
|
+
timeout: 12e4
|
|
2451
|
+
});
|
|
2452
|
+
} catch {}
|
|
2453
|
+
}
|
|
1859
2454
|
}
|
|
1860
2455
|
//#endregion
|
|
1861
2456
|
//#region src/engine/orchestrator.ts
|
|
@@ -1884,6 +2479,12 @@ async function generate(spec, emitter, options) {
|
|
|
1884
2479
|
if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
|
|
1885
2480
|
if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
|
|
1886
2481
|
if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
|
|
2482
|
+
const allTargetFiles = [
|
|
2483
|
+
...targetResult.written,
|
|
2484
|
+
...targetResult.merged,
|
|
2485
|
+
...targetResult.identical
|
|
2486
|
+
];
|
|
2487
|
+
if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
|
|
1887
2488
|
}
|
|
1888
2489
|
return withHeaders;
|
|
1889
2490
|
}
|
|
@@ -2245,6 +2846,6 @@ function summarize(changes) {
|
|
|
2245
2846
|
};
|
|
2246
2847
|
}
|
|
2247
2848
|
//#endregion
|
|
2248
|
-
export { integrateGeneratedFiles as a,
|
|
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 };
|
|
2249
2850
|
|
|
2250
|
-
//# sourceMappingURL=diff-
|
|
2851
|
+
//# sourceMappingURL=diff-DcY93sii.mjs.map
|