@workos/oagen 0.5.0 → 0.7.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({
@@ -130,7 +130,8 @@ function resolveOperations(spec, hints, mountRules) {
130
130
  mountOn,
131
131
  wrappers,
132
132
  defaults: hint?.defaults ?? {},
133
- inferFromClient: hint?.inferFromClient ?? []
133
+ inferFromClient: hint?.inferFromClient ?? [],
134
+ urlBuilder: hint?.urlBuilder ?? false
134
135
  });
135
136
  }
136
137
  return resolved;
@@ -675,6 +676,41 @@ function findPrecedingKdoc(children, index, source) {
675
676
  }
676
677
  return null;
677
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
+ }
678
714
  const kotlinMergeAdapter = {
679
715
  language: "kotlin",
680
716
  grammarModule: "tree-sitter-kotlin",
@@ -715,6 +751,33 @@ const kotlinMergeAdapter = {
715
751
  renderImports(imports) {
716
752
  return imports.map((entry) => entry.text);
717
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
+ },
718
781
  extractDocstrings(tree, source) {
719
782
  const result = /* @__PURE__ */ new Map();
720
783
  const rootChildren = tree.rootNode.children;
@@ -897,6 +960,7 @@ const nodeMergeAdapter = {
897
960
  const newText = newMembers.map((m) => m.text).join("\n");
898
961
  for (const match of newText.matchAll(/this\.(\w+)/g)) {
899
962
  const propName = match[1];
963
+ if (propName === "workos") continue;
900
964
  if (propName && !existingMemberKeys.has(propName)) return true;
901
965
  }
902
966
  return false;
@@ -1546,6 +1610,40 @@ function buildIgnoredSymbolNames(docstrings, regions) {
1546
1610
  for (const [name, info] of docstrings) if (regions.some((r) => info.declStartIndex >= r.startIndex && info.declStartIndex <= r.endIndex)) ignored.add(name);
1547
1611
  return ignored;
1548
1612
  }
1613
+ function clampIndex(value, contentLength) {
1614
+ if (!Number.isFinite(value)) return 0;
1615
+ if (value < 0) return 0;
1616
+ if (value > contentLength) return contentLength;
1617
+ return value;
1618
+ }
1619
+ /**
1620
+ * Resolve the declaration line start for insertion using the current content,
1621
+ * instead of relying on parser column arithmetic. This avoids misplaced inserts
1622
+ * when parser columns/offsets drift.
1623
+ */
1624
+ function declarationLineStart(content, declStartIndex) {
1625
+ const idx = clampIndex(declStartIndex, content.length);
1626
+ const prevNewline = content.lastIndexOf("\n", Math.max(0, idx - 1));
1627
+ return prevNewline === -1 ? 0 : prevNewline + 1;
1628
+ }
1629
+ /**
1630
+ * Resolve a docstring range by searching for the nearest matching text before
1631
+ * the declaration start. Falls back to parser-provided offsets when search fails.
1632
+ */
1633
+ function resolveDocRange(content, docText, declStartIndex, fallbackStart, fallbackEnd) {
1634
+ const safeDecl = clampIndex(declStartIndex, content.length);
1635
+ const safeFallbackStart = clampIndex(fallbackStart, content.length);
1636
+ const safeFallbackEnd = clampIndex(fallbackEnd, content.length);
1637
+ const found = content.lastIndexOf(docText, safeDecl);
1638
+ if (found !== -1) return {
1639
+ start: found,
1640
+ end: found + docText.length
1641
+ };
1642
+ return {
1643
+ start: safeFallbackStart,
1644
+ end: safeFallbackEnd
1645
+ };
1646
+ }
1549
1647
  /**
1550
1648
  * Check if a tree-sitter grammar is configured for the given language.
1551
1649
  */
@@ -1594,15 +1692,17 @@ async function extractClassEndLines(source, language) {
1594
1692
  if (source[body ? body.endIndex - 1 : node.endIndex - 1] === "}") map.set(name, { bodyEndLine: endRow });
1595
1693
  else map.set(name, { bodyEndLine: endRow + 1 });
1596
1694
  }
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);
1695
+ function walk(node) {
1696
+ if (node.type === "class" || node.type === "class_definition" || node.type === "class_declaration") processClassNode(node);
1697
+ if (node.type === "decorated_definition") {
1698
+ for (const inner of node.namedChildren) if (inner.type === "class_definition") processClassNode(inner);
1601
1699
  }
1602
- if (child.type === "export_statement") {
1603
- for (const inner of child.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
1700
+ if (node.type === "export_statement") {
1701
+ for (const inner of node.namedChildren) if (inner.type === "class_declaration") processClassNode(inner);
1604
1702
  }
1703
+ for (const child of node.namedChildren) walk(child);
1605
1704
  }
1705
+ for (const child of tree.rootNode.children) walk(child);
1606
1706
  return map;
1607
1707
  }
1608
1708
  /**
@@ -1757,10 +1857,10 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1757
1857
  if (newMembers.length > 0) {
1758
1858
  if (adapter.shouldSkipDeepMerge?.(symbolName, existingMemberKeys, newMembers)) continue;
1759
1859
  const indent = existSymbol.memberIndent ?? " ";
1760
- const insertText = newMembers.map((m) => indent + m.text).join("\n");
1860
+ const insertText = newMembers.map((m) => indent + m.text).join("\n\n");
1761
1861
  insertions.push({
1762
1862
  line: existSymbol.bodyEndLine,
1763
- text: insertText
1863
+ text: "\n" + insertText
1764
1864
  });
1765
1865
  deepAdded += newMembers.length;
1766
1866
  }
@@ -1822,15 +1922,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1822
1922
  if (genDoc) if (existInfo.docstring) {
1823
1923
  if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
1824
1924
  const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
1925
+ const range = resolveDocRange(result, existInfo.docstring.text, existInfo.declStartIndex, existInfo.docstring.startIndex, existInfo.docstring.endIndex);
1825
1926
  edits.push({
1826
- start: existInfo.docstring.startIndex,
1827
- end: existInfo.docstring.endIndex,
1927
+ start: range.start,
1928
+ end: range.end,
1828
1929
  newText
1829
1930
  });
1830
1931
  docstringUpdates++;
1831
1932
  }
1832
1933
  } else {
1833
- const lineStart = existInfo.declStartIndex - existInfo.declColumn;
1934
+ const lineStart = declarationLineStart(result, existInfo.declStartIndex);
1834
1935
  const indent = " ".repeat(existInfo.declColumn);
1835
1936
  edits.push({
1836
1937
  start: lineStart,
@@ -1848,15 +1949,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1848
1949
  if (existMember.docstring) {
1849
1950
  if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
1850
1951
  const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
1952
+ const range = resolveDocRange(result, existMember.docstring.text, existMember.declStartIndex, existMember.docstring.startIndex, existMember.docstring.endIndex);
1851
1953
  edits.push({
1852
- start: existMember.docstring.startIndex,
1853
- end: existMember.docstring.endIndex,
1954
+ start: range.start,
1955
+ end: range.end,
1854
1956
  newText
1855
1957
  });
1856
1958
  docstringUpdates++;
1857
1959
  }
1858
1960
  } else {
1859
- const lineStart = existMember.declStartIndex - existMember.declColumn;
1961
+ const lineStart = declarationLineStart(result, existMember.declStartIndex);
1860
1962
  const indent = " ".repeat(existMember.declColumn);
1861
1963
  edits.push({
1862
1964
  start: lineStart,
@@ -1901,15 +2003,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1901
2003
  if (existMember.docstring.text.includes("@oagen-ignore")) continue;
1902
2004
  if (existMember.docstring.text !== genMember.docstring.text) {
1903
2005
  const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
2006
+ const range = resolveDocRange(result, existMember.docstring.text, existMember.declStartIndex, existMember.docstring.startIndex, existMember.docstring.endIndex);
1904
2007
  edits.push({
1905
- start: existMember.docstring.startIndex,
1906
- end: existMember.docstring.endIndex,
2008
+ start: range.start,
2009
+ end: range.end,
1907
2010
  newText
1908
2011
  });
1909
2012
  docstringUpdates++;
1910
2013
  }
1911
2014
  } else {
1912
- const lineStart = existMember.declStartIndex - existMember.declColumn;
2015
+ const lineStart = declarationLineStart(result, existMember.declStartIndex);
1913
2016
  const indent = " ".repeat(existMember.declColumn);
1914
2017
  edits.push({
1915
2018
  start: lineStart,
@@ -2082,7 +2185,7 @@ async function overwriteWithPreservedRegions(existingContent, generatedContent,
2082
2185
  });
2083
2186
  }
2084
2187
  if (blocks.length === 0) return generatedContent;
2085
- const result = [...generatedLines];
2188
+ const result = stripIgnoreRegions(generatedLines);
2086
2189
  spliceExtraImports(existingLines, result);
2087
2190
  const classEndLines = await extractClassEndLines(result.join("\n"), language);
2088
2191
  const blocksByClass = /* @__PURE__ */ new Map();
@@ -2149,10 +2252,33 @@ async function overwriteWithPreservedRegions(existingContent, generatedContent,
2149
2252
  for (const ins of insertions) result.splice(ins.line, 0, ...ins.content);
2150
2253
  return result.join("\n");
2151
2254
  }
2255
+ /**
2256
+ * Remove every `@oagen-ignore-start`..`@oagen-ignore-end` region from the
2257
+ * supplied lines, collapsing stretches of blank lines that get left behind.
2258
+ * Used to drop generator-emitted seed blocks before splicing the on-disk
2259
+ * versions back in — otherwise every regen doubles up.
2260
+ */
2261
+ function stripIgnoreRegions(lines) {
2262
+ const out = [];
2263
+ let skipping = false;
2264
+ for (const line of lines) {
2265
+ if (!skipping && line.includes("@oagen-ignore-start")) {
2266
+ skipping = true;
2267
+ while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
2268
+ continue;
2269
+ }
2270
+ if (skipping) {
2271
+ if (line.includes("@oagen-ignore-end")) skipping = false;
2272
+ continue;
2273
+ }
2274
+ out.push(line);
2275
+ }
2276
+ return out;
2277
+ }
2152
2278
  /** Scan backwards from `lineIdx` to find the nearest class declaration name. */
2153
2279
  function findContainingClass(lines, lineIdx) {
2154
2280
  for (let j = lineIdx - 1; j >= 0; j--) {
2155
- const m = lines[j].match(/^(?:export\s+)?class\s+(\w+)/);
2281
+ const m = lines[j].match(/^\s*(?:export\s+)?class\s+(\w+)/);
2156
2282
  if (m) return m[1];
2157
2283
  }
2158
2284
  return "";
@@ -2162,7 +2288,7 @@ function findContainingClass(lines, lineIdx) {
2162
2288
  * by scanning backwards from `beforeLine`.
2163
2289
  */
2164
2290
  function findClassDeclarationStart(lines, className, beforeLine) {
2165
- const classPattern = new RegExp(`^(?:export\\s+)?class\\s+${className}\\b`);
2291
+ const classPattern = new RegExp(`^\\s*(?:export\\s+)?class\\s+${className}\\b`);
2166
2292
  for (let i = beforeLine - 1; i >= 0; i--) {
2167
2293
  if (!classPattern.test(lines[i])) continue;
2168
2294
  let start = i;
@@ -2241,7 +2367,9 @@ function buildEmitterContext(spec, options) {
2241
2367
  outputDir: options.outputDir,
2242
2368
  apiSurface: options.apiSurface,
2243
2369
  overlayLookup: options.overlayLookup,
2244
- resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
2370
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules),
2371
+ targetDir: options.target,
2372
+ priorTargetManifestPaths: options.priorTargetManifestPaths
2245
2373
  };
2246
2374
  }
2247
2375
  /**
@@ -2360,7 +2488,7 @@ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
2360
2488
  */
2361
2489
  function isRootFile(filePath) {
2362
2490
  if (filePath.includes("/fixtures/")) return false;
2363
- if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
2491
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return true;
2364
2492
  if (filePath.includes("/serializers/")) return false;
2365
2493
  if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
2366
2494
  return true;
@@ -2489,7 +2617,8 @@ async function resolveFileToDirectoryConflicts(files, targetDir) {
2489
2617
  };
2490
2618
  }
2491
2619
  async function integrateGeneratedFiles(opts) {
2492
- const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapFilesForTargetIntegration(opts.files, opts.language), opts.language), opts.targetDir);
2620
+ const mapped = mapFilesForTargetIntegration(opts.files, opts.language);
2621
+ const { files: resolved, removals } = await resolveFileToDirectoryConflicts(await treeShakeFiles(mapped, opts.language), opts.targetDir);
2493
2622
  const result = await writeFiles(resolved, opts.targetDir, {
2494
2623
  language: opts.language,
2495
2624
  header: opts.header
@@ -2497,7 +2626,10 @@ async function integrateGeneratedFiles(opts) {
2497
2626
  for (const filePath of removals) try {
2498
2627
  await fs.unlink(filePath);
2499
2628
  } catch {}
2500
- return result;
2629
+ return {
2630
+ ...result,
2631
+ emittedPaths: mapped.map((f) => f.path)
2632
+ };
2501
2633
  }
2502
2634
  //#endregion
2503
2635
  //#region src/engine/formatter.ts
@@ -2520,6 +2652,7 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
2520
2652
  if (!formatCmd) return;
2521
2653
  const absolutePaths = filePaths.map((f) => path$1.resolve(targetDir, f));
2522
2654
  const batchSize = formatCmd.batchSize ?? 100;
2655
+ process.stderr.write(`[oagen] formatting ${absolutePaths.length} file(s) with ${formatCmd.cmd} (batch ${batchSize})\n`);
2523
2656
  for (let i = 0; i < absolutePaths.length; i += batchSize) {
2524
2657
  const batch = absolutePaths.slice(i, i + batchSize);
2525
2658
  try {
@@ -2528,13 +2661,148 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
2528
2661
  maxBuffer: 10 * 1024 * 1024,
2529
2662
  timeout: 12e4
2530
2663
  });
2531
- } catch {}
2664
+ } catch (err) {
2665
+ const msg = err instanceof Error ? err.message : String(err);
2666
+ process.stderr.write(`[oagen] formatter batch failed: ${msg}\n`);
2667
+ }
2668
+ }
2669
+ }
2670
+ //#endregion
2671
+ //#region src/engine/manifest.ts
2672
+ /**
2673
+ * Prune-manifest support for `oagen generate`.
2674
+ *
2675
+ * The manifest records every auto-generated file emitted on the previous run,
2676
+ * relative to the directory it lives in (either the `--output` standalone dir
2677
+ * or the `--target` integration dir). On the next run we diff the previous
2678
+ * list against the current emission set and delete anything that's no longer
2679
+ * produced — preventing stale-file accumulation across regens.
2680
+ *
2681
+ * Design notes:
2682
+ * - The manifest is versioned so future format changes can be handled safely.
2683
+ * - Paths are stored sorted for deterministic diffs in git.
2684
+ * - Deletion is gated on a header guard when one is available: we only remove
2685
+ * files whose contents start with the auto-generated header. This protects
2686
+ * a hand-maintained file that happens to share a name with a previously
2687
+ * generated one (extremely rare, but worth catching).
2688
+ * - On first adoption (no previous manifest), pruning is skipped and a
2689
+ * one-line notice is printed — no surprise deletions.
2690
+ */
2691
+ const MANIFEST_FILENAME = ".oagen-manifest.json";
2692
+ const MANIFEST_VERSION = 1;
2693
+ /** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
2694
+ async function readManifest(dir) {
2695
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
2696
+ let raw;
2697
+ try {
2698
+ raw = await fs.readFile(manifestPath, "utf-8");
2699
+ } catch {
2700
+ return null;
2701
+ }
2702
+ try {
2703
+ const parsed = JSON.parse(raw);
2704
+ 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;
2705
+ if (parsed.version > MANIFEST_VERSION) {
2706
+ console.warn(`[oagen] ${MANIFEST_FILENAME} schema version ${parsed.version} is newer than supported (${MANIFEST_VERSION}); ignoring for pruning.`);
2707
+ return null;
2708
+ }
2709
+ return parsed;
2710
+ } catch {
2711
+ return null;
2712
+ }
2713
+ }
2714
+ /** Write `.oagen-manifest.json` to a directory with sorted paths. */
2715
+ async function writeManifest(dir, opts) {
2716
+ const manifest = {
2717
+ version: MANIFEST_VERSION,
2718
+ language: opts.language,
2719
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2720
+ files: [...new Set(opts.files)].sort()
2721
+ };
2722
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
2723
+ await fs.mkdir(dir, { recursive: true });
2724
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
2725
+ }
2726
+ /** Return the set of previous paths no longer present in the current emission. */
2727
+ function computeStalePaths(prev, currentPaths) {
2728
+ const current = new Set(currentPaths);
2729
+ return prev.files.filter((p) => !current.has(p)).sort();
2730
+ }
2731
+ /**
2732
+ * Delete stale files from `dir`.
2733
+ *
2734
+ * When `header` is provided, a file is only deleted if its contents start with
2735
+ * that header — so hand-maintained files that somehow collide with a previously
2736
+ * generated path can't be clobbered. When `header` is omitted, the guard is
2737
+ * skipped (useful for non-source artifacts like fixture JSON).
2738
+ *
2739
+ * Returns lists for reporting. Empty parent directories are removed after
2740
+ * deletion, up to (but not including) `dir` itself.
2741
+ */
2742
+ async function pruneStaleFiles(dir, paths, opts = {}) {
2743
+ const pruned = [];
2744
+ const preserved = [];
2745
+ const missing = [];
2746
+ for (const relPath of paths) {
2747
+ const fullPath = path$1.join(dir, relPath);
2748
+ let content;
2749
+ try {
2750
+ content = await fs.readFile(fullPath, "utf-8");
2751
+ } catch {
2752
+ missing.push(relPath);
2753
+ continue;
2754
+ }
2755
+ if (opts.header && !content.startsWith(opts.header)) {
2756
+ preserved.push(relPath);
2757
+ continue;
2758
+ }
2759
+ try {
2760
+ await fs.unlink(fullPath);
2761
+ pruned.push(relPath);
2762
+ } catch {
2763
+ missing.push(relPath);
2764
+ continue;
2765
+ }
2766
+ await removeEmptyParents(path$1.dirname(fullPath), dir);
2767
+ }
2768
+ return {
2769
+ pruned,
2770
+ preserved,
2771
+ missing
2772
+ };
2773
+ }
2774
+ /**
2775
+ * Walk up from `startDir` removing empty directories until we hit `stopDir`
2776
+ * (exclusive) or a non-empty directory. Silently ignores errors.
2777
+ */
2778
+ async function removeEmptyParents(startDir, stopDir) {
2779
+ let current = path$1.resolve(startDir);
2780
+ const stop = path$1.resolve(stopDir);
2781
+ while (current.startsWith(stop + path$1.sep) && current !== stop) {
2782
+ let entries;
2783
+ try {
2784
+ entries = await fs.readdir(current);
2785
+ } catch {
2786
+ return;
2787
+ }
2788
+ if (entries.length > 0) return;
2789
+ try {
2790
+ await fs.rmdir(current);
2791
+ } catch {
2792
+ return;
2793
+ }
2794
+ current = path$1.dirname(current);
2532
2795
  }
2533
2796
  }
2534
2797
  //#endregion
2535
2798
  //#region src/engine/orchestrator.ts
2536
2799
  async function generate(spec, emitter, options) {
2537
- const { files: withHeaders, header } = generateFiles(spec, emitter, options);
2800
+ const targetManifestForCtx = options.target && !options.noPrune ? await readManifest(options.target) : null;
2801
+ const priorTargetManifestPaths = targetManifestForCtx ? new Set(targetManifestForCtx.files) : void 0;
2802
+ const { files: withHeaders, header } = generateFiles(spec, emitter, {
2803
+ ...options,
2804
+ priorTargetManifestPaths
2805
+ });
2538
2806
  if (options.dryRun) {
2539
2807
  if (options.target) {
2540
2808
  console.log(`\nTarget integration (${options.target}):`);
@@ -2542,13 +2810,25 @@ async function generate(spec, emitter, options) {
2542
2810
  }
2543
2811
  return withHeaders;
2544
2812
  }
2813
+ const outputPrevManifest = options.noPrune ? null : await readManifest(options.outputDir);
2545
2814
  const writeResult = await writeFiles(withHeaders, options.outputDir, {
2546
2815
  language: emitter.language,
2547
2816
  header
2548
2817
  });
2549
2818
  if (writeResult.merged.length > 0) console.log(`Merged into ${writeResult.merged.length} existing files (additive only)`);
2550
2819
  if (writeResult.ignored.length > 0) console.log(`Ignored ${writeResult.ignored.length} files (@oagen-ignore-file)`);
2820
+ const outputEmittedPaths = withHeaders.map((f) => f.path);
2821
+ await applyManifestPrune({
2822
+ dir: options.outputDir,
2823
+ label: "Output",
2824
+ prevManifest: outputPrevManifest,
2825
+ currentPaths: outputEmittedPaths,
2826
+ language: emitter.language,
2827
+ header,
2828
+ noPrune: options.noPrune
2829
+ });
2551
2830
  if (options.target) {
2831
+ const targetPrevManifest = targetManifestForCtx;
2552
2832
  const targetResult = await integrateGeneratedFiles({
2553
2833
  files: withHeaders,
2554
2834
  language: emitter.language,
@@ -2558,6 +2838,15 @@ async function generate(spec, emitter, options) {
2558
2838
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
2559
2839
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
2560
2840
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
2841
+ await applyManifestPrune({
2842
+ dir: options.target,
2843
+ label: "Target",
2844
+ prevManifest: targetPrevManifest,
2845
+ currentPaths: targetResult.emittedPaths,
2846
+ language: emitter.language,
2847
+ header,
2848
+ noPrune: options.noPrune
2849
+ });
2561
2850
  const allTargetFiles = [
2562
2851
  ...targetResult.written,
2563
2852
  ...targetResult.merged,
@@ -2567,6 +2856,40 @@ async function generate(spec, emitter, options) {
2567
2856
  }
2568
2857
  return withHeaders;
2569
2858
  }
2859
+ /**
2860
+ * Prune files recorded in the previous manifest but absent from the current emission,
2861
+ * then write the fresh manifest. Safe on first adoption (no previous manifest).
2862
+ */
2863
+ async function applyManifestPrune(opts) {
2864
+ if (opts.noPrune) {
2865
+ await writeManifest(opts.dir, {
2866
+ language: opts.language,
2867
+ files: opts.currentPaths
2868
+ });
2869
+ return;
2870
+ }
2871
+ if (!opts.prevManifest) {
2872
+ console.log(`${opts.label}: no ${MANIFEST_FILENAME} found — skipping prune (this baseline manifest will be written now).`);
2873
+ await writeManifest(opts.dir, {
2874
+ language: opts.language,
2875
+ files: opts.currentPaths
2876
+ });
2877
+ return;
2878
+ }
2879
+ const stale = computeStalePaths(opts.prevManifest, opts.currentPaths);
2880
+ if (stale.length > 0) {
2881
+ const { pruned, preserved } = await pruneStaleFiles(opts.dir, stale, { header: opts.header });
2882
+ if (pruned.length > 0) console.log(`${opts.label}: pruned ${pruned.length} stale file${pruned.length === 1 ? "" : "s"}`);
2883
+ if (preserved.length > 0) {
2884
+ console.log(`${opts.label}: preserved ${preserved.length} file${preserved.length === 1 ? "" : "s"} in previous manifest but lacking the auto-generated header (possibly hand-edited).`);
2885
+ for (const p of preserved) console.log(` ${p}`);
2886
+ }
2887
+ }
2888
+ await writeManifest(opts.dir, {
2889
+ language: opts.language,
2890
+ files: opts.currentPaths
2891
+ });
2892
+ }
2570
2893
  //#endregion
2571
2894
  //#region src/engine/registry.ts
2572
2895
  const emitters = /* @__PURE__ */ new Map();
@@ -2927,4 +3250,4 @@ function summarize(changes) {
2927
3250
  //#endregion
2928
3251
  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 };
2929
3252
 
2930
- //# sourceMappingURL=diff-CSy3g-Bq.mjs.map
3253
+ //# sourceMappingURL=diff-BYadQQV7.mjs.map