@workos/oagen 0.3.0 → 0.5.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.
Files changed (42) hide show
  1. package/README.md +72 -1
  2. package/dist/cli/index.mjs +955 -90
  3. package/dist/cli/index.mjs.map +1 -1
  4. package/dist/compat/index.d.mts +1 -1
  5. package/dist/compat/index.mjs +9 -4
  6. package/dist/compat/index.mjs.map +1 -1
  7. package/dist/{diff-Cn6S74vV.mjs → diff-CSy3g-Bq.mjs} +541 -43
  8. package/dist/diff-CSy3g-Bq.mjs.map +1 -0
  9. package/dist/{differ-B6472Yef.mjs → differ-CG5ZErKl.mjs} +2 -2
  10. package/dist/{differ-B6472Yef.mjs.map → differ-CG5ZErKl.mjs.map} +1 -1
  11. package/dist/index.d.mts +111 -3
  12. package/dist/index.d.mts.map +1 -1
  13. package/dist/index.mjs +340 -30
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/scripts/smoke/baseline.mjs +317 -33
  16. package/dist/scripts/smoke/baseline.mjs.map +1 -1
  17. package/dist/smoke.d.mts +140 -0
  18. package/dist/smoke.d.mts.map +1 -1
  19. package/dist/smoke.mjs +317 -33
  20. package/dist/smoke.mjs.map +1 -1
  21. package/dist/{tree-sitter-DFRXfWg9.mjs → tree-sitter-C53w_0WE.mjs} +5 -7
  22. package/dist/tree-sitter-C53w_0WE.mjs.map +1 -0
  23. package/dist/{types-O25qZxMl.d.mts → types-D84LjrVG.d.mts} +167 -2
  24. package/dist/types-D84LjrVG.d.mts.map +1 -0
  25. package/dist/verify/index.d.mts +1 -1
  26. package/dist/verify/index.mjs +4 -4
  27. package/dist/verify/index.mjs.map +1 -1
  28. package/docs/agents/architecture.md +1 -1
  29. package/docs/architecture/emitter-contract.md +1 -2
  30. package/docs/architecture/ir-types.md +73 -1
  31. package/docs/architecture/pipeline.md +0 -1
  32. package/docs/cli.md +29 -8
  33. package/docs/core/minimal-emitter.md +0 -4
  34. package/docs/core/quickstart.md +0 -1
  35. package/examples/reference-emitter/README.md +0 -1
  36. package/examples/reference-emitter/src/emitter.ts +0 -5
  37. package/package.json +4 -4
  38. package/skills/review-operations/SKILL.md +83 -0
  39. package/dist/diff-Cn6S74vV.mjs.map +0 -1
  40. package/dist/tree-sitter-DFRXfWg9.mjs.map +0 -1
  41. package/dist/types-O25qZxMl.d.mts.map +0 -1
  42. package/examples/reference-emitter/src/generators/config.ts +0 -19
@@ -1,10 +1,149 @@
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
+ defaults: hint?.defaults ?? {},
133
+ inferFromClient: hint?.inferFromClient ?? []
134
+ });
135
+ }
136
+ return resolved;
137
+ }
138
+ /** Extract the response model name from an operation (for wrapper metadata). */
139
+ function resolveResponseModelName(op) {
140
+ if (op.httpMethod === "delete") return null;
141
+ const ref = op.response;
142
+ if (ref.kind === "model") return ref.name;
143
+ if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
144
+ return null;
145
+ }
146
+ //#endregion
8
147
  //#region src/engine/merge-adapters/url-fingerprint.ts
9
148
  /**
10
149
  * Extract a URL path fingerprint from a method body using tree-sitter AST traversal.
@@ -913,8 +1052,9 @@ const phpMergeAdapter = {
913
1052
  testFilePatterns: [/Test\.php$/],
914
1053
  urlFingerprintConfig: PHP_URL_FINGERPRINT_CONFIG,
915
1054
  resolveGrammar: (mod) => {
916
- if (typeof mod === "object" && mod !== null && "php" in mod) return mod.php;
917
- return mod;
1055
+ const m = mod?.default ?? mod;
1056
+ if (typeof m === "object" && m !== null && "php" in m) return m.php;
1057
+ return m;
918
1058
  },
919
1059
  parseStatements(tree, source) {
920
1060
  const imports = [];
@@ -1358,6 +1498,30 @@ function getMergeAdapter(language) {
1358
1498
  * Supports any language with a tree-sitter grammar package.
1359
1499
  */
1360
1500
  const parserCache = /* @__PURE__ */ new Map();
1501
+ /**
1502
+ * When replacing an existing docstring with a generated one, carry over any
1503
+ * `@deprecated` lines from the existing doc that aren't present in the
1504
+ * generated doc. This prevents hand-written deprecation notices from being
1505
+ * silently dropped during docstring refresh.
1506
+ */
1507
+ function preserveDeprecatedTags(existingDoc, generatedDoc) {
1508
+ if (!existingDoc.includes("@deprecated")) return generatedDoc;
1509
+ if (generatedDoc.includes("@deprecated")) return generatedDoc;
1510
+ const deprecatedParts = [];
1511
+ for (const line of existingDoc.split("\n")) if (/@deprecated\b/.test(line)) {
1512
+ const match = line.match(/@deprecated\b.*/);
1513
+ if (match) {
1514
+ const cleaned = match[0].replace(/\s*\*\/\s*$/, "");
1515
+ deprecatedParts.push(` * ${cleaned}`);
1516
+ }
1517
+ }
1518
+ if (deprecatedParts.length === 0) return generatedDoc;
1519
+ const closingIdx = generatedDoc.lastIndexOf("*/");
1520
+ if (closingIdx === -1) return generatedDoc;
1521
+ const before = generatedDoc.slice(0, closingIdx);
1522
+ const after = generatedDoc.slice(closingIdx);
1523
+ return before + deprecatedParts.join("\n") + "\n " + after;
1524
+ }
1361
1525
  function findIgnoredRegions(source) {
1362
1526
  const regions = [];
1363
1527
  const startTag = "@oagen-ignore-start";
@@ -1413,6 +1577,35 @@ async function extractStatements(source, language) {
1413
1577
  return adapter.parseStatements(tree, source);
1414
1578
  }
1415
1579
  /**
1580
+ * Return a map of top-level class name → { bodyEndLine, bodyEndCol } using
1581
+ * tree-sitter. `bodyEndLine` is 0-based. For brace-delimited classes the
1582
+ * end points at the closing brace line; for indentation-delimited classes
1583
+ * it points one past the last line of the class body.
1584
+ */
1585
+ async function extractClassEndLines(source, language) {
1586
+ const tree = safeParse(await getParser(language), source);
1587
+ const map = /* @__PURE__ */ new Map();
1588
+ function processClassNode(node) {
1589
+ const nameNode = node.childForFieldName("name");
1590
+ if (!nameNode) return;
1591
+ const name = nameNode.text;
1592
+ const body = node.childForFieldName("body");
1593
+ const endRow = body ? body.endPosition.row : node.endPosition.row;
1594
+ if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
1595
+ else map.set(name, { bodyEndLine: endRow + 1 });
1596
+ }
1597
+ for (const child of tree.rootNode.children) {
1598
+ if (child.type === "class_definition" || child.type === "class_declaration") processClassNode(child);
1599
+ if (child.type === "decorated_definition") {
1600
+ for (const inner of child.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
1601
+ }
1602
+ if (child.type === "export_statement") {
1603
+ for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
1604
+ }
1605
+ }
1606
+ return map;
1607
+ }
1608
+ /**
1416
1609
  * Merge generated content into an existing file.
1417
1610
  *
1418
1611
  * - Parses both files with tree-sitter
@@ -1442,10 +1635,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1442
1635
  }
1443
1636
  for (const anchor of existingStatements.importAnchors) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(anchor)).split("\n").length - 1 + anchor.split("\n").length - 1;
1444
1637
  if (lastImportEndIndex === -1 && existingStatements.importInsertionAnchor) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(existingStatements.importInsertionAnchor)).split("\n").length - 1 + existingStatements.importInsertionAnchor.split("\n").length - 1;
1638
+ const existingExportedNames = /* @__PURE__ */ new Set();
1445
1639
  for (const stmt of existingStatements.statements) {
1446
- if (stmt.kind === "reexport") existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
1447
- if (stmt.key) existingKeys.add(stmt.key);
1448
- else existingUnnamedTexts.add(stmt.text.trim());
1640
+ if (stmt.kind === "reexport") {
1641
+ existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
1642
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
1643
+ if (braceMatch) for (const part of braceMatch[1].split(",")) {
1644
+ const segments = part.trim().split(/\s+as\s+/);
1645
+ const exportedName = (segments[1] ?? segments[0]).trim();
1646
+ if (exportedName) existingExportedNames.add(exportedName);
1647
+ }
1648
+ }
1649
+ if (stmt.key) {
1650
+ existingKeys.add(stmt.key);
1651
+ existingExportedNames.add(stmt.key);
1652
+ } else existingUnnamedTexts.add(stmt.text.trim());
1449
1653
  }
1450
1654
  const headerLine = header.trim();
1451
1655
  const newImports = [];
@@ -1505,6 +1709,17 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1505
1709
  preserved++;
1506
1710
  continue;
1507
1711
  }
1712
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
1713
+ if (braceMatch) {
1714
+ const exportedNames = braceMatch[1].split(",").map((n) => {
1715
+ const parts = n.trim().split(/\s+as\s+/);
1716
+ return (parts[1] ?? parts[0]).trim();
1717
+ }).filter(Boolean);
1718
+ if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) {
1719
+ preserved++;
1720
+ continue;
1721
+ }
1722
+ }
1508
1723
  }
1509
1724
  if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) {
1510
1725
  preserved++;
@@ -1606,10 +1821,11 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1606
1821
  const genDoc = genInfo.docstring && genInfo.docstring.text.trim() !== headerLine ? genInfo.docstring : null;
1607
1822
  if (genDoc) if (existInfo.docstring) {
1608
1823
  if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
1824
+ const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
1609
1825
  edits.push({
1610
1826
  start: existInfo.docstring.startIndex,
1611
1827
  end: existInfo.docstring.endIndex,
1612
- newText: genDoc.text
1828
+ newText
1613
1829
  });
1614
1830
  docstringUpdates++;
1615
1831
  }
@@ -1626,14 +1842,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1626
1842
  const matchedExistMembers = /* @__PURE__ */ new Set();
1627
1843
  for (const [memberName, genMember] of genInfo.members) {
1628
1844
  const existMember = existInfo.members.get(memberName);
1629
- if (!existMember || !genMember.docstring) continue;
1845
+ if (!existMember) continue;
1630
1846
  matchedExistMembers.add(memberName);
1847
+ if (!genMember.docstring) continue;
1631
1848
  if (existMember.docstring) {
1632
1849
  if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
1850
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
1633
1851
  edits.push({
1634
1852
  start: existMember.docstring.startIndex,
1635
1853
  end: existMember.docstring.endIndex,
1636
- newText: genMember.docstring.text
1854
+ newText
1637
1855
  });
1638
1856
  docstringUpdates++;
1639
1857
  }
@@ -1648,34 +1866,59 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1648
1866
  docstringUpdates++;
1649
1867
  }
1650
1868
  }
1651
- for (const [_genName, genMember] of genInfo.members) {
1869
+ const unmatchedGenByFp = /* @__PURE__ */ new Map();
1870
+ for (const [genName, genMember] of genInfo.members) {
1871
+ if (matchedExistMembers.has(genName)) continue;
1652
1872
  if (!genMember.docstring || !genMember.urlFingerprint) continue;
1653
- for (const [existName, existMember] of existInfo.members) {
1654
- if (matchedExistMembers.has(existName)) continue;
1655
- if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue;
1656
- if (existMember.docstring) {
1657
- if (existMember.docstring.text.includes("@oagen-ignore")) continue;
1658
- if (existMember.docstring.text !== genMember.docstring.text) {
1659
- edits.push({
1660
- start: existMember.docstring.startIndex,
1661
- end: existMember.docstring.endIndex,
1662
- newText: genMember.docstring.text
1663
- });
1664
- docstringUpdates++;
1665
- }
1666
- } else {
1667
- const lineStart = existMember.declStartIndex - existMember.declColumn;
1668
- const indent = " ".repeat(existMember.declColumn);
1873
+ if (existInfo.members.has(genName)) continue;
1874
+ const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? [];
1875
+ bucket.push({
1876
+ name: genName,
1877
+ member: genMember
1878
+ });
1879
+ unmatchedGenByFp.set(genMember.urlFingerprint, bucket);
1880
+ }
1881
+ const unmatchedExistByFp = /* @__PURE__ */ new Map();
1882
+ for (const [existName, existMember] of existInfo.members) {
1883
+ if (matchedExistMembers.has(existName)) continue;
1884
+ if (!existMember.urlFingerprint) continue;
1885
+ const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? [];
1886
+ bucket.push({
1887
+ name: existName,
1888
+ member: existMember
1889
+ });
1890
+ unmatchedExistByFp.set(existMember.urlFingerprint, bucket);
1891
+ }
1892
+ for (const [fp, genBucket] of unmatchedGenByFp) {
1893
+ if (genBucket.length !== 1) continue;
1894
+ const existBucket = unmatchedExistByFp.get(fp);
1895
+ if (!existBucket || existBucket.length !== 1) continue;
1896
+ const genMember = genBucket[0].member;
1897
+ const existEntry = existBucket[0];
1898
+ const existMember = existEntry.member;
1899
+ if (!genMember.docstring) continue;
1900
+ if (existMember.docstring) {
1901
+ if (existMember.docstring.text.includes("@oagen-ignore")) continue;
1902
+ if (existMember.docstring.text !== genMember.docstring.text) {
1903
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
1669
1904
  edits.push({
1670
- start: lineStart,
1671
- end: lineStart,
1672
- newText: indent + genMember.docstring.text + "\n"
1905
+ start: existMember.docstring.startIndex,
1906
+ end: existMember.docstring.endIndex,
1907
+ newText
1673
1908
  });
1674
1909
  docstringUpdates++;
1675
1910
  }
1676
- matchedExistMembers.add(existName);
1677
- break;
1911
+ } else {
1912
+ const lineStart = existMember.declStartIndex - existMember.declColumn;
1913
+ const indent = " ".repeat(existMember.declColumn);
1914
+ edits.push({
1915
+ start: lineStart,
1916
+ end: lineStart,
1917
+ newText: indent + genMember.docstring.text + "\n"
1918
+ });
1919
+ docstringUpdates++;
1678
1920
  }
1921
+ matchedExistMembers.add(existEntry.name);
1679
1922
  }
1680
1923
  }
1681
1924
  if (edits.length > 0) {
@@ -1761,15 +2004,18 @@ async function writeFiles(files, outputDir, options) {
1761
2004
  }
1762
2005
  const adapter = language ? getMergeAdapter(language) : void 0;
1763
2006
  if (adapter?.testFilePatterns ? adapter.testFilePatterns.some((p) => p.test(file.path)) : isDefaultTestFile(file.path)) {
1764
- result.skipped.push(file.path);
1765
- continue;
2007
+ if (!(header && existingContent.startsWith(header))) {
2008
+ result.skipped.push(file.path);
2009
+ continue;
2010
+ }
1766
2011
  }
1767
2012
  if (existingContent === file.content) {
1768
2013
  result.identical.push(file.path);
1769
2014
  continue;
1770
2015
  }
1771
2016
  if (file.overwriteExisting) {
1772
- await fs.writeFile(fullPath, file.content, "utf-8");
2017
+ const content = existingContent.includes("@oagen-ignore-start") && language && hasGrammar(language) ? await overwriteWithPreservedRegions(existingContent, file.content, language) : file.content;
2018
+ await fs.writeFile(fullPath, content, "utf-8");
1773
2019
  result.written.push(file.path);
1774
2020
  continue;
1775
2021
  }
@@ -1813,6 +2059,178 @@ async function writeFiles(files, outputDir, options) {
1813
2059
  function isDefaultTestFile(filePath) {
1814
2060
  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
2061
  }
2062
+ /**
2063
+ * Overwrite a file with generated content while preserving
2064
+ * `@oagen-ignore-start` / `@oagen-ignore-end` regions (and their
2065
+ * required imports) from the existing file.
2066
+ *
2067
+ * Uses tree-sitter via `extractClassEndLines` to find accurate class
2068
+ * boundaries so ignore blocks are inserted at the correct position even
2069
+ * when docstrings contain unindented text.
2070
+ */
2071
+ async function overwriteWithPreservedRegions(existingContent, generatedContent, language) {
2072
+ const existingLines = existingContent.split("\n");
2073
+ const generatedLines = generatedContent.split("\n");
2074
+ const blocks = [];
2075
+ for (let i = 0; i < existingLines.length; i++) {
2076
+ if (!existingLines[i].includes("@oagen-ignore-start")) continue;
2077
+ const startIdx = i;
2078
+ while (i < existingLines.length && !existingLines[i].includes("@oagen-ignore-end")) i++;
2079
+ blocks.push({
2080
+ containingClass: findContainingClass(existingLines, startIdx),
2081
+ lines: existingLines.slice(startIdx, i + 1)
2082
+ });
2083
+ }
2084
+ if (blocks.length === 0) return generatedContent;
2085
+ const result = [...generatedLines];
2086
+ spliceExtraImports(existingLines, result);
2087
+ const classEndLines = await extractClassEndLines(result.join("\n"), language);
2088
+ const blocksByClass = /* @__PURE__ */ new Map();
2089
+ for (const b of blocks) {
2090
+ if (!blocksByClass.has(b.containingClass)) blocksByClass.set(b.containingClass, []);
2091
+ blocksByClass.get(b.containingClass).push(b.lines);
2092
+ }
2093
+ const replacedClasses = /* @__PURE__ */ new Set();
2094
+ const handledBlockKeys = /* @__PURE__ */ new Set();
2095
+ const replacements = [];
2096
+ for (const [className, classBlocks] of blocksByClass) {
2097
+ let targetClass = className;
2098
+ if (!targetClass) for (const blockLines of classBlocks) {
2099
+ for (const l of blockLines) {
2100
+ const m = l.match(/^(?:export\s+)?class\s+(\w+)/);
2101
+ if (m && classEndLines.has(m[1])) {
2102
+ targetClass = m[1];
2103
+ break;
2104
+ }
2105
+ }
2106
+ if (targetClass) break;
2107
+ }
2108
+ if (!targetClass) continue;
2109
+ if (!classBlocks.some((blockLines) => blockLines.some((l) => {
2110
+ return l.match(/^(?:export\s+)?class\s+(\w+)/)?.[1] === targetClass;
2111
+ }))) continue;
2112
+ const info = classEndLines.get(targetClass);
2113
+ if (!info) continue;
2114
+ const startLine = findClassDeclarationStart(result, targetClass, info.bodyEndLine);
2115
+ if (startLine < 0) continue;
2116
+ const content = [];
2117
+ for (const block of classBlocks) {
2118
+ content.push("");
2119
+ content.push(...block);
2120
+ }
2121
+ replacements.push({
2122
+ startLine,
2123
+ endLine: info.bodyEndLine,
2124
+ content
2125
+ });
2126
+ replacedClasses.add(targetClass);
2127
+ handledBlockKeys.add(className);
2128
+ }
2129
+ replacements.sort((a, b) => b.startLine - a.startLine);
2130
+ for (const rep of replacements) result.splice(rep.startLine, rep.endLine - rep.startLine, ...rep.content);
2131
+ let effectiveClassEndLines = classEndLines;
2132
+ if (replacedClasses.size > 0) effectiveClassEndLines = await extractClassEndLines(result.join("\n"), language);
2133
+ const insertions = [];
2134
+ for (const [className, classBlocks] of blocksByClass) {
2135
+ if (handledBlockKeys.has(className) || replacedClasses.has(className)) continue;
2136
+ const info = className ? effectiveClassEndLines.get(className) : void 0;
2137
+ const insertLine = info ? info.bodyEndLine : result.length;
2138
+ const content = [];
2139
+ for (const block of classBlocks) {
2140
+ content.push("");
2141
+ content.push(...block);
2142
+ }
2143
+ insertions.push({
2144
+ line: insertLine,
2145
+ content
2146
+ });
2147
+ }
2148
+ insertions.sort((a, b) => b.line - a.line);
2149
+ for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
2150
+ return result.join("\n");
2151
+ }
2152
+ /** Scan backwards from `lineIdx` to find the nearest class declaration name. */
2153
+ function findContainingClass(lines, lineIdx) {
2154
+ for (let j = lineIdx - 1; j >= 0; j--) {
2155
+ const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
2156
+ if (m) return m[1];
2157
+ }
2158
+ return "";
2159
+ }
2160
+ /**
2161
+ * Find the start line of a class declaration (including decorators above it)
2162
+ * by scanning backwards from `beforeLine`.
2163
+ */
2164
+ function findClassDeclarationStart(lines, className, beforeLine) {
2165
+ const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
2166
+ for (let i = beforeLine - 1; i >= 0; i--) {
2167
+ if (!classPattern.test(lines[i])) continue;
2168
+ let start = i;
2169
+ while (start > 0 && /^\s*@\w+/.test(lines[start - 1])) start--;
2170
+ return start;
2171
+ }
2172
+ return -1;
2173
+ }
2174
+ /**
2175
+ * Collect `from X import Y, Z` identifiers grouped by module, handling
2176
+ * both single-line and multiline (parenthesized) forms.
2177
+ */
2178
+ function collectFromImports(lines) {
2179
+ const byModule = /* @__PURE__ */ new Map();
2180
+ for (let i = 0; i < lines.length; i++) {
2181
+ if (!/^from\s+/.test(lines[i])) continue;
2182
+ let fullText = lines[i];
2183
+ if (fullText.includes("(") && !fullText.includes(")")) while (i + 1 < lines.length) {
2184
+ i++;
2185
+ fullText += " " + lines[i].trim();
2186
+ if (lines[i].includes(")")) break;
2187
+ }
2188
+ const match = fullText.replace(/[()]/g, "").replace(/\s+/g, " ").trim().match(/^from\s+(\S+)\s+import\s+(.+)$/);
2189
+ if (!match) continue;
2190
+ const mod = match[1];
2191
+ if (!byModule.has(mod)) byModule.set(mod, /* @__PURE__ */ new Set());
2192
+ for (const id of match[2].split(",").map((s) => s.trim()).filter(Boolean)) byModule.get(mod).add(id);
2193
+ }
2194
+ return byModule;
2195
+ }
2196
+ /**
2197
+ * Add imports from the existing file that are absent from the generated
2198
+ * result. Handles both `import X` and `from X import Y` forms.
2199
+ * Mutates `resultLines` in place.
2200
+ */
2201
+ function spliceExtraImports(existingLines, resultLines) {
2202
+ const isSimpleImport = (l) => /^import\s+\w/.test(l);
2203
+ const existingSimple = new Set(existingLines.filter(isSimpleImport).map((l) => l.trim()));
2204
+ const generatedSimple = new Set(resultLines.filter(isSimpleImport).map((l) => l.trim()));
2205
+ const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
2206
+ const existingFrom = collectFromImports(existingLines);
2207
+ const generatedFrom = collectFromImports(resultLines);
2208
+ const allGeneratedIds = /* @__PURE__ */ new Set();
2209
+ for (const ids of generatedFrom.values()) for (const id of ids) allGeneratedIds.add(id);
2210
+ const extraFromLines = [];
2211
+ for (const [mod, existIds] of existingFrom) {
2212
+ const genIds = generatedFrom.get(mod);
2213
+ if (!genIds) {
2214
+ const extra = [...existIds].filter((id) => !allGeneratedIds.has(id));
2215
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
2216
+ } else {
2217
+ const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id));
2218
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
2219
+ }
2220
+ }
2221
+ const allExtra = [...extraSimple, ...extraFromLines];
2222
+ if (allExtra.length === 0) return;
2223
+ let lastImportIdx = -1;
2224
+ for (let i = 0; i < resultLines.length; i++) if (isSimpleImport(resultLines[i]) || /^from\s+/.test(resultLines[i])) {
2225
+ lastImportIdx = i;
2226
+ if (resultLines[i].includes("(") && !resultLines[i].includes(")")) while (i + 1 < resultLines.length) {
2227
+ i++;
2228
+ lastImportIdx = i;
2229
+ if (resultLines[i].includes(")")) break;
2230
+ }
2231
+ }
2232
+ if (lastImportIdx >= 0) resultLines.splice(lastImportIdx + 1, 0, ...allExtra);
2233
+ }
1816
2234
  //#endregion
1817
2235
  //#region src/engine/generate-files.ts
1818
2236
  function buildEmitterContext(spec, options) {
@@ -1822,7 +2240,8 @@ function buildEmitterContext(spec, options) {
1822
2240
  spec,
1823
2241
  outputDir: options.outputDir,
1824
2242
  apiSurface: options.apiSurface,
1825
- overlayLookup: options.overlayLookup
2243
+ overlayLookup: options.overlayLookup,
2244
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
1826
2245
  };
1827
2246
  }
1828
2247
  /**
@@ -1852,6 +2271,7 @@ function collectReferencedNames(services, models) {
1852
2271
  for (const err of op.errors) if (err.type) collectFromTypeRef(err.type);
1853
2272
  if (op.successResponses) for (const sr of op.successResponses) collectFromTypeRef(sr.type);
1854
2273
  }
2274
+ for (const model of models) if (isDiscriminatedModel(model)) referencedModels.add(model.name);
1855
2275
  const modelsByName = new Map(models.map((m) => [m.name, m]));
1856
2276
  const visited = /* @__PURE__ */ new Set();
1857
2277
  const queue = [...referencedModels];
@@ -1871,6 +2291,9 @@ function collectReferencedNames(services, models) {
1871
2291
  enums: referencedEnums
1872
2292
  };
1873
2293
  }
2294
+ function isDiscriminatedModel(model) {
2295
+ return model.fields.some((field) => (field.name === "event" || field.name === "type" || field.name === "object") && field.type.kind === "literal");
2296
+ }
1874
2297
  /** Collect all generated files from an emitter (no headers, no path prefixes). */
1875
2298
  function generateAllFiles(spec, emitter, ctx) {
1876
2299
  const referenced = collectReferencedNames(spec.services, spec.models);
@@ -1887,7 +2310,6 @@ function generateAllFiles(spec, emitter, ctx) {
1887
2310
  ...emitter.generateResources(spec.services, ctx),
1888
2311
  ...emitter.generateClient(spec, ctx),
1889
2312
  ...emitter.generateErrors(ctx),
1890
- ...emitter.generateConfig(ctx),
1891
2313
  ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
1892
2314
  ...emitter.generateTests(reachableSpec, ctx),
1893
2315
  ...emitter.generateManifest?.(spec, ctx) ?? []
@@ -1971,11 +2393,33 @@ async function treeShakeFiles(files, language) {
1971
2393
  }
1972
2394
  deps.set(file.path, fileDeps);
1973
2395
  }
2396
+ const codeExtensions = new Set([
2397
+ ".ts",
2398
+ ".js",
2399
+ ".tsx",
2400
+ ".jsx",
2401
+ ".py",
2402
+ ".rb",
2403
+ ".go",
2404
+ ".rs",
2405
+ ".kt",
2406
+ ".cs",
2407
+ ".ex",
2408
+ ".exs",
2409
+ ".php"
2410
+ ]);
1974
2411
  const reachable = /* @__PURE__ */ new Set();
1975
2412
  const queue = [];
1976
- for (const file of files) if (isRootFile(file.path)) {
1977
- reachable.add(file.path);
1978
- queue.push(file.path);
2413
+ for (const file of files) {
2414
+ const ext = file.path.slice(file.path.lastIndexOf("."));
2415
+ if (!codeExtensions.has(ext)) {
2416
+ reachable.add(file.path);
2417
+ continue;
2418
+ }
2419
+ if (isRootFile(file.path)) {
2420
+ reachable.add(file.path);
2421
+ queue.push(file.path);
2422
+ }
1979
2423
  }
1980
2424
  while (queue.length > 0) {
1981
2425
  const current = queue.pop();
@@ -1999,11 +2443,61 @@ function mapFilesForTargetIntegration(files, language) {
1999
2443
  };
2000
2444
  });
2001
2445
  }
2446
+ /**
2447
+ * Resolve file-to-directory conflicts in the target.
2448
+ *
2449
+ * When a generated file creates a package directory (e.g., `api_keys/__init__.py`)
2450
+ * that would shadow an existing module file (e.g., `api_keys.py`), move the
2451
+ * module file's content into `_compat.py` inside the package and add a
2452
+ * re-export from `__init__.py`. This preserves backward-compatible imports
2453
+ * (e.g., `from workos.api_keys import ApiKeysModule`) while the package directory
2454
+ * provides the generated models and resource classes.
2455
+ */
2456
+ async function resolveFileToDirectoryConflicts(files, targetDir) {
2457
+ const result = [...files];
2458
+ const removals = [];
2459
+ for (let i = 0; i < result.length; i++) {
2460
+ const file = result[i];
2461
+ const match = file.path.match(/^(.+)\/__init__\.py$/);
2462
+ if (!match) continue;
2463
+ const dirPath = match[1];
2464
+ const moduleFilePath = `${dirPath}.py`;
2465
+ const fullModulePath = path.join(targetDir, moduleFilePath);
2466
+ let moduleContent;
2467
+ try {
2468
+ moduleContent = await fs.readFile(fullModulePath, "utf-8");
2469
+ } catch {
2470
+ continue;
2471
+ }
2472
+ const compatPath = `${dirPath}/_compat.py`;
2473
+ result.push({
2474
+ path: compatPath,
2475
+ content: moduleContent,
2476
+ overwriteExisting: true
2477
+ });
2478
+ const compatImport = "from ._compat import * # noqa: F401,F403 # pyright: ignore[reportUnusedImport]";
2479
+ const existingContent = file.content.endsWith("\n") ? file.content : file.content + "\n";
2480
+ result[i] = {
2481
+ ...file,
2482
+ content: existingContent + compatImport + "\n"
2483
+ };
2484
+ removals.push(fullModulePath);
2485
+ }
2486
+ return {
2487
+ files: result,
2488
+ removals
2489
+ };
2490
+ }
2002
2491
  async function integrateGeneratedFiles(opts) {
2003
- return writeFiles(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir, {
2492
+ const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
2493
+ const result = await writeFiles(resolved, opts.targetDir, {
2004
2494
  language: opts.language,
2005
2495
  header: opts.header
2006
2496
  });
2497
+ for (const filePath of removals) try {
2498
+ await fs.unlink(filePath);
2499
+ } catch {}
2500
+ return result;
2007
2501
  }
2008
2502
  //#endregion
2009
2503
  //#region src/engine/formatter.ts
@@ -2064,7 +2558,11 @@ async function generate(spec, emitter, options) {
2064
2558
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
2065
2559
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
2066
2560
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
2067
- const allTargetFiles = [...targetResult.written, ...targetResult.merged];
2561
+ const allTargetFiles = [
2562
+ ...targetResult.written,
2563
+ ...targetResult.merged,
2564
+ ...targetResult.identical
2565
+ ];
2068
2566
  if (allTargetFiles.length > 0) await formatTargetFiles(emitter, options.target, allTargetFiles);
2069
2567
  }
2070
2568
  return withHeaders;
@@ -2427,6 +2925,6 @@ function summarize(changes) {
2427
2925
  };
2428
2926
  }
2429
2927
  //#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 };
2928
+ 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
2929
 
2432
- //# sourceMappingURL=diff-Cn6S74vV.mjs.map
2930
+ //# sourceMappingURL=diff-CSy3g-Bq.mjs.map