@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.
@@ -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-DFRXfWg9.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
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
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
917
- return mod;
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
- result.skipped.push(file.path);
1765
- continue;
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
- 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");
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) if (isRootFile(file.path)) {
1977
- reachable.add(file.path);
1978
- queue.push(file.path);
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
- return writeFiles(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), 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, {
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 = [...targetResult.written, ...targetResult.merged];
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-Cn6S74vV.mjs.map
2851
+ //# sourceMappingURL=diff-DcY93sii.mjs.map