@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.
@@ -1,7 +1,147 @@
1
- import { C as assertNever, f as toSnakeCase, i as normalizeJsExtension, t as safeParse, x as RegistryError } from "./tree-sitter-BPNfbEzY.mjs";
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
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
914
- return mod;
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 = newImports.filter((imp) => {
1731
+ const filteredImports = [];
1732
+ for (const imp of newImports) {
1562
1733
  const braceMatch = imp.text.match(/\{([^}]+)\}/);
1563
- if (!braceMatch) return true;
1564
- return braceMatch[1].split(",").map((n) => n.replace(/\btype\b/, "").trim()).filter(Boolean).some((name) => addedCodeText.includes(name));
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
- result.skipped.push(file.path);
1746
- continue;
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
- await fs.writeFile(fullPath, file.content, "utf-8");
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(spec.models, ctx),
1813
- ...emitter.generateEnums(spec.enums, ctx),
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?.(spec, ctx) ?? [],
1819
- ...emitter.generateTests(spec, ctx),
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
- return writeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.targetDir, {
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, buildEmitterContext as c, generate as i, generateAllFiles as l, getEmitter as n, mapFilesForTargetIntegration as o, registerEmitter as r, applyFileHeaders as s, diffSpecs as t, generateFiles 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 };
2249
2850
 
2250
- //# sourceMappingURL=diff-DyQF4sel.mjs.map
2851
+ //# sourceMappingURL=diff-DcY93sii.mjs.map