@workos/oagen 0.4.0 → 0.6.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.
@@ -94,7 +94,7 @@ function deriveMethodName(op, _service) {
94
94
  const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
95
95
  if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
96
96
  }
97
- const noun = trailingParam ? singularize(resource) : resource;
97
+ const noun = trailingParam || verb === "create" ? singularize(resource) : resource;
98
98
  return `${verb}_${verb === "list" ? resource : noun}`;
99
99
  }
100
100
  /**
@@ -120,7 +120,7 @@ function resolveOperations(spec, hints, mountRules) {
120
120
  defaults: sh.defaults ?? {},
121
121
  inferFromClient: sh.inferFromClient ?? [],
122
122
  exposedParams: sh.exposedParams ?? [],
123
- optionalParams: [],
123
+ optionalParams: sh.optionalParams ?? [],
124
124
  responseModelName: resolveResponseModelName(op)
125
125
  }));
126
126
  resolved.push({
@@ -128,7 +128,10 @@ function resolveOperations(spec, hints, mountRules) {
128
128
  service,
129
129
  methodName,
130
130
  mountOn,
131
- wrappers
131
+ wrappers,
132
+ defaults: hint?.defaults ?? {},
133
+ inferFromClient: hint?.inferFromClient ?? [],
134
+ urlBuilder: hint?.urlBuilder ?? false
132
135
  });
133
136
  }
134
137
  return resolved;
@@ -673,6 +676,41 @@ function findPrecedingKdoc(children, index, source) {
673
676
  }
674
677
  return null;
675
678
  }
679
+ function extractKotlinClassMembers(classBody, source) {
680
+ const members = [];
681
+ const children = classBody.namedChildren;
682
+ for (let i = 0; i < children.length; i++) {
683
+ const child = children[i];
684
+ let memberName = null;
685
+ if (child.type === "property_declaration") memberName = child.children.find((c) => c.type === "variable_declaration")?.children.find((c) => c.type === "simple_identifier")?.text ?? null;
686
+ else if (child.type === "function_declaration") memberName = child.children.find((c) => c.type === "simple_identifier")?.text ?? null;
687
+ else if (child.type === "companion_object") memberName = "companion";
688
+ if (!memberName) continue;
689
+ let endIdx = child.endIndex;
690
+ if (child.type === "property_declaration") while (i + 1 < children.length) {
691
+ const next = children[i + 1];
692
+ if (next.type === "getter" || next.type === "setter") {
693
+ endIdx = next.endIndex;
694
+ i++;
695
+ } else break;
696
+ }
697
+ let startIdx = child.startIndex;
698
+ for (let k = i - 1; k >= 0; k--) {
699
+ const prev = children[k];
700
+ if (prev.type === "multiline_comment") {
701
+ if (source.slice(prev.startIndex, prev.endIndex).startsWith("/**")) startIdx = prev.startIndex;
702
+ break;
703
+ }
704
+ if (prev.type === "line_comment") continue;
705
+ break;
706
+ }
707
+ members.push({
708
+ key: memberName,
709
+ text: source.slice(startIdx, endIdx)
710
+ });
711
+ }
712
+ return members;
713
+ }
676
714
  const kotlinMergeAdapter = {
677
715
  language: "kotlin",
678
716
  grammarModule: "tree-sitter-kotlin",
@@ -713,6 +751,33 @@ const kotlinMergeAdapter = {
713
751
  renderImports(imports) {
714
752
  return imports.map((entry) => entry.text);
715
753
  },
754
+ shouldSkipDeepMerge(_symbolName, existingMemberKeys, newMembers) {
755
+ const newText = newMembers.map((m) => m.text).join("\n");
756
+ for (const match of newText.matchAll(/this\.(\w+)/g)) {
757
+ const propName = match[1];
758
+ if (propName && !existingMemberKeys.has(propName)) return true;
759
+ }
760
+ return false;
761
+ },
762
+ extractMembers(tree, source) {
763
+ const result = /* @__PURE__ */ new Map();
764
+ for (const child of tree.rootNode.children) {
765
+ if (child.type !== "class_declaration") continue;
766
+ const nameNode = child.children.find((c) => c.type === "type_identifier");
767
+ if (!nameNode) continue;
768
+ const body = child.children.find((c) => c.type === "class_body");
769
+ if (!body) continue;
770
+ const members = extractKotlinClassMembers(body, source);
771
+ const firstMember = body.firstNamedChild;
772
+ const memberIndent = firstMember ? " ".repeat(firstMember.startPosition.column) : void 0;
773
+ result.set(nameNode.text, {
774
+ members,
775
+ bodyEndLine: body.endPosition.row,
776
+ memberIndent
777
+ });
778
+ }
779
+ return result;
780
+ },
716
781
  extractDocstrings(tree, source) {
717
782
  const result = /* @__PURE__ */ new Map();
718
783
  const rootChildren = tree.rootNode.children;
@@ -1496,6 +1561,30 @@ function getMergeAdapter(language) {
1496
1561
  * Supports any language with a tree-sitter grammar package.
1497
1562
  */
1498
1563
  const parserCache = /* @__PURE__ */ new Map();
1564
+ /**
1565
+ * When replacing an existing docstring with a generated one, carry over any
1566
+ * `@deprecated` lines from the existing doc that aren't present in the
1567
+ * generated doc. This prevents hand-written deprecation notices from being
1568
+ * silently dropped during docstring refresh.
1569
+ */
1570
+ function preserveDeprecatedTags(existingDoc, generatedDoc) {
1571
+ if (!existingDoc.includes("@deprecated")) return generatedDoc;
1572
+ if (generatedDoc.includes("@deprecated")) return generatedDoc;
1573
+ const deprecatedParts = [];
1574
+ for (const line of existingDoc.split("\n")) if (/@deprecated\b/.test(line)) {
1575
+ const match = line.match(/@deprecated\b.*/);
1576
+ if (match) {
1577
+ const cleaned = match[0].replace(/\s*\*\/\s*$/, "");
1578
+ deprecatedParts.push(` * ${cleaned}`);
1579
+ }
1580
+ }
1581
+ if (deprecatedParts.length === 0) return generatedDoc;
1582
+ const closingIdx = generatedDoc.lastIndexOf("*/");
1583
+ if (closingIdx === -1) return generatedDoc;
1584
+ const before = generatedDoc.slice(0, closingIdx);
1585
+ const after = generatedDoc.slice(closingIdx);
1586
+ return before + deprecatedParts.join("\n") + "\n " + after;
1587
+ }
1499
1588
  function findIgnoredRegions(source) {
1500
1589
  const regions = [];
1501
1590
  const startTag = "@oagen-ignore-start";
@@ -1609,10 +1698,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1609
1698
  }
1610
1699
  for (const anchor of existingStatements.importAnchors) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(anchor)).split("\n").length - 1 + anchor.split("\n").length - 1;
1611
1700
  if (lastImportEndIndex === -1 && existingStatements.importInsertionAnchor) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(existingStatements.importInsertionAnchor)).split("\n").length - 1 + existingStatements.importInsertionAnchor.split("\n").length - 1;
1701
+ const existingExportedNames = /* @__PURE__ */ new Set();
1612
1702
  for (const stmt of existingStatements.statements) {
1613
- if (stmt.kind === "reexport") existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
1614
- if (stmt.key) existingKeys.add(stmt.key);
1615
- else existingUnnamedTexts.add(stmt.text.trim());
1703
+ if (stmt.kind === "reexport") {
1704
+ existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
1705
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
1706
+ if (braceMatch) for (const part of braceMatch[1].split(",")) {
1707
+ const segments = part.trim().split(/\s+as\s+/);
1708
+ const exportedName = (segments[1] ?? segments[0]).trim();
1709
+ if (exportedName) existingExportedNames.add(exportedName);
1710
+ }
1711
+ }
1712
+ if (stmt.key) {
1713
+ existingKeys.add(stmt.key);
1714
+ existingExportedNames.add(stmt.key);
1715
+ } else existingUnnamedTexts.add(stmt.text.trim());
1616
1716
  }
1617
1717
  const headerLine = header.trim();
1618
1718
  const newImports = [];
@@ -1672,6 +1772,17 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1672
1772
  preserved++;
1673
1773
  continue;
1674
1774
  }
1775
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
1776
+ if (braceMatch) {
1777
+ const exportedNames = braceMatch[1].split(",").map((n) => {
1778
+ const parts = n.trim().split(/\s+as\s+/);
1779
+ return (parts[1] ?? parts[0]).trim();
1780
+ }).filter(Boolean);
1781
+ if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) {
1782
+ preserved++;
1783
+ continue;
1784
+ }
1785
+ }
1675
1786
  }
1676
1787
  if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) {
1677
1788
  preserved++;
@@ -1709,10 +1820,10 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1709
1820
  if (newMembers.length > 0) {
1710
1821
  if (adapter.shouldSkipDeepMerge?.(symbolName, existingMemberKeys, newMembers)) continue;
1711
1822
  const indent = existSymbol.memberIndent ?? " ";
1712
- const insertText = newMembers.map((m) => indent + m.text).join("\n");
1823
+ const insertText = newMembers.map((m) => indent + m.text).join("\n\n");
1713
1824
  insertions.push({
1714
1825
  line: existSymbol.bodyEndLine,
1715
- text: insertText
1826
+ text: "\n" + insertText
1716
1827
  });
1717
1828
  deepAdded += newMembers.length;
1718
1829
  }
@@ -1773,10 +1884,11 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1773
1884
  const genDoc = genInfo.docstring && genInfo.docstring.text.trim() !== headerLine ? genInfo.docstring : null;
1774
1885
  if (genDoc) if (existInfo.docstring) {
1775
1886
  if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
1887
+ const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
1776
1888
  edits.push({
1777
1889
  start: existInfo.docstring.startIndex,
1778
1890
  end: existInfo.docstring.endIndex,
1779
- newText: genDoc.text
1891
+ newText
1780
1892
  });
1781
1893
  docstringUpdates++;
1782
1894
  }
@@ -1793,14 +1905,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1793
1905
  const matchedExistMembers = /* @__PURE__ */ new Set();
1794
1906
  for (const [memberName, genMember] of genInfo.members) {
1795
1907
  const existMember = existInfo.members.get(memberName);
1796
- if (!existMember || !genMember.docstring) continue;
1908
+ if (!existMember) continue;
1797
1909
  matchedExistMembers.add(memberName);
1910
+ if (!genMember.docstring) continue;
1798
1911
  if (existMember.docstring) {
1799
1912
  if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
1913
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
1800
1914
  edits.push({
1801
1915
  start: existMember.docstring.startIndex,
1802
1916
  end: existMember.docstring.endIndex,
1803
- newText: genMember.docstring.text
1917
+ newText
1804
1918
  });
1805
1919
  docstringUpdates++;
1806
1920
  }
@@ -1815,34 +1929,59 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1815
1929
  docstringUpdates++;
1816
1930
  }
1817
1931
  }
1818
- for (const [_genName, genMember] of genInfo.members) {
1932
+ const unmatchedGenByFp = /* @__PURE__ */ new Map();
1933
+ for (const [genName, genMember] of genInfo.members) {
1934
+ if (matchedExistMembers.has(genName)) continue;
1819
1935
  if (!genMember.docstring || !genMember.urlFingerprint) continue;
1820
- for (const [existName, existMember] of existInfo.members) {
1821
- if (matchedExistMembers.has(existName)) continue;
1822
- if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue;
1823
- if (existMember.docstring) {
1824
- if (existMember.docstring.text.includes("@oagen-ignore")) continue;
1825
- if (existMember.docstring.text !== genMember.docstring.text) {
1826
- edits.push({
1827
- start: existMember.docstring.startIndex,
1828
- end: existMember.docstring.endIndex,
1829
- newText: genMember.docstring.text
1830
- });
1831
- docstringUpdates++;
1832
- }
1833
- } else {
1834
- const lineStart = existMember.declStartIndex - existMember.declColumn;
1835
- const indent = " ".repeat(existMember.declColumn);
1936
+ if (existInfo.members.has(genName)) continue;
1937
+ const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? [];
1938
+ bucket.push({
1939
+ name: genName,
1940
+ member: genMember
1941
+ });
1942
+ unmatchedGenByFp.set(genMember.urlFingerprint, bucket);
1943
+ }
1944
+ const unmatchedExistByFp = /* @__PURE__ */ new Map();
1945
+ for (const [existName, existMember] of existInfo.members) {
1946
+ if (matchedExistMembers.has(existName)) continue;
1947
+ if (!existMember.urlFingerprint) continue;
1948
+ const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? [];
1949
+ bucket.push({
1950
+ name: existName,
1951
+ member: existMember
1952
+ });
1953
+ unmatchedExistByFp.set(existMember.urlFingerprint, bucket);
1954
+ }
1955
+ for (const [fp, genBucket] of unmatchedGenByFp) {
1956
+ if (genBucket.length !== 1) continue;
1957
+ const existBucket = unmatchedExistByFp.get(fp);
1958
+ if (!existBucket || existBucket.length !== 1) continue;
1959
+ const genMember = genBucket[0].member;
1960
+ const existEntry = existBucket[0];
1961
+ const existMember = existEntry.member;
1962
+ if (!genMember.docstring) continue;
1963
+ if (existMember.docstring) {
1964
+ if (existMember.docstring.text.includes("@oagen-ignore")) continue;
1965
+ if (existMember.docstring.text !== genMember.docstring.text) {
1966
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
1836
1967
  edits.push({
1837
- start: lineStart,
1838
- end: lineStart,
1839
- newText: indent + genMember.docstring.text + "\n"
1968
+ start: existMember.docstring.startIndex,
1969
+ end: existMember.docstring.endIndex,
1970
+ newText
1840
1971
  });
1841
1972
  docstringUpdates++;
1842
1973
  }
1843
- matchedExistMembers.add(existName);
1844
- break;
1974
+ } else {
1975
+ const lineStart = existMember.declStartIndex - existMember.declColumn;
1976
+ const indent = " ".repeat(existMember.declColumn);
1977
+ edits.push({
1978
+ start: lineStart,
1979
+ end: lineStart,
1980
+ newText: indent + genMember.docstring.text + "\n"
1981
+ });
1982
+ docstringUpdates++;
1845
1983
  }
1984
+ matchedExistMembers.add(existEntry.name);
1846
1985
  }
1847
1986
  }
1848
1987
  if (edits.length > 0) {
@@ -2129,12 +2268,16 @@ function spliceExtraImports(existingLines, resultLines) {
2129
2268
  const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
2130
2269
  const existingFrom = collectFromImports(existingLines);
2131
2270
  const generatedFrom = collectFromImports(resultLines);
2271
+ const allGeneratedIds = /* @__PURE__ */ new Set();
2272
+ for (const ids of generatedFrom.values()) for (const id of ids) allGeneratedIds.add(id);
2132
2273
  const extraFromLines = [];
2133
2274
  for (const [mod, existIds] of existingFrom) {
2134
2275
  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));
2276
+ if (!genIds) {
2277
+ const extra = [...existIds].filter((id) => !allGeneratedIds.has(id));
2278
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
2279
+ } else {
2280
+ const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id));
2138
2281
  if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
2139
2282
  }
2140
2283
  }
@@ -2161,7 +2304,9 @@ function buildEmitterContext(spec, options) {
2161
2304
  outputDir: options.outputDir,
2162
2305
  apiSurface: options.apiSurface,
2163
2306
  overlayLookup: options.overlayLookup,
2164
- resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
2307
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules),
2308
+ targetDir: options.target,
2309
+ priorTargetManifestPaths: options.priorTargetManifestPaths
2165
2310
  };
2166
2311
  }
2167
2312
  /**
@@ -2230,7 +2375,6 @@ function generateAllFiles(spec, emitter, ctx) {
2230
2375
  ...emitter.generateResources(spec.services, ctx),
2231
2376
  ...emitter.generateClient(spec, ctx),
2232
2377
  ...emitter.generateErrors(ctx),
2233
- ...emitter.generateConfig(ctx),
2234
2378
  ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
2235
2379
  ...emitter.generateTests(reachableSpec, ctx),
2236
2380
  ...emitter.generateManifest?.(spec, ctx) ?? []
@@ -2281,7 +2425,7 @@ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
2281
2425
  */
2282
2426
  function isRootFile(filePath) {
2283
2427
  if (filePath.includes("/fixtures/")) return false;
2284
- if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
2428
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return true;
2285
2429
  if (filePath.includes("/serializers/")) return false;
2286
2430
  if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
2287
2431
  return true;
@@ -2418,7 +2562,10 @@ async function integrateGeneratedFiles(opts) {
2418
2562
  for (const filePath of removals) try {
2419
2563
  await fs.unlink(filePath);
2420
2564
  } catch {}
2421
- return result;
2565
+ return {
2566
+ ...result,
2567
+ emittedPaths: resolved.map((f) => f.path)
2568
+ };
2422
2569
  }
2423
2570
  //#endregion
2424
2571
  //#region src/engine/formatter.ts
@@ -2453,9 +2600,141 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
2453
2600
  }
2454
2601
  }
2455
2602
  //#endregion
2603
+ //#region src/engine/manifest.ts
2604
+ /**
2605
+ * Prune-manifest support for `oagen generate`.
2606
+ *
2607
+ * The manifest records every auto-generated file emitted on the previous run,
2608
+ * relative to the directory it lives in (either the `--output` standalone dir
2609
+ * or the `--target` integration dir). On the next run we diff the previous
2610
+ * list against the current emission set and delete anything that's no longer
2611
+ * produced — preventing stale-file accumulation across regens.
2612
+ *
2613
+ * Design notes:
2614
+ * - The manifest is versioned so future format changes can be handled safely.
2615
+ * - Paths are stored sorted for deterministic diffs in git.
2616
+ * - Deletion is gated on a header guard when one is available: we only remove
2617
+ * files whose contents start with the auto-generated header. This protects
2618
+ * a hand-maintained file that happens to share a name with a previously
2619
+ * generated one (extremely rare, but worth catching).
2620
+ * - On first adoption (no previous manifest), pruning is skipped and a
2621
+ * one-line notice is printed — no surprise deletions.
2622
+ */
2623
+ const MANIFEST_FILENAME = ".oagen-manifest.json";
2624
+ const MANIFEST_VERSION = 1;
2625
+ /** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
2626
+ async function readManifest(dir) {
2627
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
2628
+ let raw;
2629
+ try {
2630
+ raw = await fs.readFile(manifestPath, "utf-8");
2631
+ } catch {
2632
+ return null;
2633
+ }
2634
+ try {
2635
+ const parsed = JSON.parse(raw);
2636
+ if (typeof parsed.version !== "number" || typeof parsed.language !== "string" || typeof parsed.generatedAt !== "string" || !Array.isArray(parsed.files) || !parsed.files.every((p) => typeof p === "string")) return null;
2637
+ if (parsed.version > MANIFEST_VERSION) {
2638
+ console.warn(`[oagen] ${MANIFEST_FILENAME} schema version ${parsed.version} is newer than supported (${MANIFEST_VERSION}); ignoring for pruning.`);
2639
+ return null;
2640
+ }
2641
+ return parsed;
2642
+ } catch {
2643
+ return null;
2644
+ }
2645
+ }
2646
+ /** Write `.oagen-manifest.json` to a directory with sorted paths. */
2647
+ async function writeManifest(dir, opts) {
2648
+ const manifest = {
2649
+ version: MANIFEST_VERSION,
2650
+ language: opts.language,
2651
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2652
+ files: [...new Set(opts.files)].sort()
2653
+ };
2654
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
2655
+ await fs.mkdir(dir, { recursive: true });
2656
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
2657
+ }
2658
+ /** Return the set of previous paths no longer present in the current emission. */
2659
+ function computeStalePaths(prev, currentPaths) {
2660
+ const current = new Set(currentPaths);
2661
+ return prev.files.filter((p) => !current.has(p)).sort();
2662
+ }
2663
+ /**
2664
+ * Delete stale files from `dir`.
2665
+ *
2666
+ * When `header` is provided, a file is only deleted if its contents start with
2667
+ * that header — so hand-maintained files that somehow collide with a previously
2668
+ * generated path can't be clobbered. When `header` is omitted, the guard is
2669
+ * skipped (useful for non-source artifacts like fixture JSON).
2670
+ *
2671
+ * Returns lists for reporting. Empty parent directories are removed after
2672
+ * deletion, up to (but not including) `dir` itself.
2673
+ */
2674
+ async function pruneStaleFiles(dir, paths, opts = {}) {
2675
+ const pruned = [];
2676
+ const preserved = [];
2677
+ const missing = [];
2678
+ for (const relPath of paths) {
2679
+ const fullPath = path$1.join(dir, relPath);
2680
+ let content;
2681
+ try {
2682
+ content = await fs.readFile(fullPath, "utf-8");
2683
+ } catch {
2684
+ missing.push(relPath);
2685
+ continue;
2686
+ }
2687
+ if (opts.header && !content.startsWith(opts.header)) {
2688
+ preserved.push(relPath);
2689
+ continue;
2690
+ }
2691
+ try {
2692
+ await fs.unlink(fullPath);
2693
+ pruned.push(relPath);
2694
+ } catch {
2695
+ missing.push(relPath);
2696
+ continue;
2697
+ }
2698
+ await removeEmptyParents(path$1.dirname(fullPath), dir);
2699
+ }
2700
+ return {
2701
+ pruned,
2702
+ preserved,
2703
+ missing
2704
+ };
2705
+ }
2706
+ /**
2707
+ * Walk up from `startDir` removing empty directories until we hit `stopDir`
2708
+ * (exclusive) or a non-empty directory. Silently ignores errors.
2709
+ */
2710
+ async function removeEmptyParents(startDir, stopDir) {
2711
+ let current = path$1.resolve(startDir);
2712
+ const stop = path$1.resolve(stopDir);
2713
+ while (current.startsWith(stop + path$1.sep) && current !== stop) {
2714
+ let entries;
2715
+ try {
2716
+ entries = await fs.readdir(current);
2717
+ } catch {
2718
+ return;
2719
+ }
2720
+ if (entries.length > 0) return;
2721
+ try {
2722
+ await fs.rmdir(current);
2723
+ } catch {
2724
+ return;
2725
+ }
2726
+ current = path$1.dirname(current);
2727
+ }
2728
+ }
2729
+ //#endregion
2456
2730
  //#region src/engine/orchestrator.ts
2457
2731
  async function generate(spec, emitter, options) {
2458
- const { files: withHeaders, header } = generateFiles(spec, emitter, options);
2732
+ const targetManifestForCtx = options.target && !options.noPrune ? await readManifest(options.target) : null;
2733
+ const priorTargetManifestPaths = targetManifestForCtx ? new Set(targetManifestForCtx.files) : void 0;
2734
+ const { files: withHeaders, header } = generateFiles(spec, emitter, {
2735
+ ...options,
2736
+ priorTargetManifestPaths
2737
+ });
2459
2738
  if (options.dryRun) {
2460
2739
  if (options.target) {
2461
2740
  console.log(`\nTarget integration (${options.target}):`);
@@ -2463,13 +2742,25 @@ async function generate(spec, emitter, options) {
2463
2742
  }
2464
2743
  return withHeaders;
2465
2744
  }
2745
+ const outputPrevManifest = options.noPrune ? null : await readManifest(options.outputDir);
2466
2746
  const writeResult = await writeFiles(withHeaders, options.outputDir, {
2467
2747
  language: emitter.language,
2468
2748
  header
2469
2749
  });
2470
2750
  if (writeResult.merged.length > 0) console.log(`Merged into ${writeResult.merged.length} existing files (additive only)`);
2471
2751
  if (writeResult.ignored.length > 0) console.log(`Ignored ${writeResult.ignored.length} files (@oagen-ignore-file)`);
2752
+ const outputEmittedPaths = withHeaders.map((f) => f.path);
2753
+ await applyManifestPrune({
2754
+ dir: options.outputDir,
2755
+ label: "Output",
2756
+ prevManifest: outputPrevManifest,
2757
+ currentPaths: outputEmittedPaths,
2758
+ language: emitter.language,
2759
+ header,
2760
+ noPrune: options.noPrune
2761
+ });
2472
2762
  if (options.target) {
2763
+ const targetPrevManifest = targetManifestForCtx;
2473
2764
  const targetResult = await integrateGeneratedFiles({
2474
2765
  files: withHeaders,
2475
2766
  language: emitter.language,
@@ -2479,6 +2770,15 @@ async function generate(spec, emitter, options) {
2479
2770
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
2480
2771
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
2481
2772
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
2773
+ await applyManifestPrune({
2774
+ dir: options.target,
2775
+ label: "Target",
2776
+ prevManifest: targetPrevManifest,
2777
+ currentPaths: targetResult.emittedPaths,
2778
+ language: emitter.language,
2779
+ header,
2780
+ noPrune: options.noPrune
2781
+ });
2482
2782
  const allTargetFiles = [
2483
2783
  ...targetResult.written,
2484
2784
  ...targetResult.merged,
@@ -2488,6 +2788,40 @@ async function generate(spec, emitter, options) {
2488
2788
  }
2489
2789
  return withHeaders;
2490
2790
  }
2791
+ /**
2792
+ * Prune files recorded in the previous manifest but absent from the current emission,
2793
+ * then write the fresh manifest. Safe on first adoption (no previous manifest).
2794
+ */
2795
+ async function applyManifestPrune(opts) {
2796
+ if (opts.noPrune) {
2797
+ await writeManifest(opts.dir, {
2798
+ language: opts.language,
2799
+ files: opts.currentPaths
2800
+ });
2801
+ return;
2802
+ }
2803
+ if (!opts.prevManifest) {
2804
+ console.log(`${opts.label}: no ${MANIFEST_FILENAME} found — skipping prune (this baseline manifest will be written now).`);
2805
+ await writeManifest(opts.dir, {
2806
+ language: opts.language,
2807
+ files: opts.currentPaths
2808
+ });
2809
+ return;
2810
+ }
2811
+ const stale = computeStalePaths(opts.prevManifest, opts.currentPaths);
2812
+ if (stale.length > 0) {
2813
+ const { pruned, preserved } = await pruneStaleFiles(opts.dir, stale, { header: opts.header });
2814
+ if (pruned.length > 0) console.log(`${opts.label}: pruned ${pruned.length} stale file${pruned.length === 1 ? "" : "s"}`);
2815
+ if (preserved.length > 0) {
2816
+ console.log(`${opts.label}: preserved ${preserved.length} file${preserved.length === 1 ? "" : "s"} in previous manifest but lacking the auto-generated header (possibly hand-edited).`);
2817
+ for (const p of preserved) console.log(` ${p}`);
2818
+ }
2819
+ }
2820
+ await writeManifest(opts.dir, {
2821
+ language: opts.language,
2822
+ files: opts.currentPaths
2823
+ });
2824
+ }
2491
2825
  //#endregion
2492
2826
  //#region src/engine/registry.ts
2493
2827
  const emitters = /* @__PURE__ */ new Map();
@@ -2848,4 +3182,4 @@ function summarize(changes) {
2848
3182
  //#endregion
2849
3183
  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 };
2850
3184
 
2851
- //# sourceMappingURL=diff-DcY93sii.mjs.map
3185
+ //# sourceMappingURL=diff-CltJMcwG.mjs.map