@workos/oagen 0.5.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({
@@ -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;
@@ -1757,10 +1820,10 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
1757
1820
  if (newMembers.length > 0) {
1758
1821
  if (adapter.shouldSkipDeepMerge?.(symbolName, existingMemberKeys, newMembers)) continue;
1759
1822
  const indent = existSymbol.memberIndent ?? " ";
1760
- const insertText = newMembers.map((m) => indent + m.text).join("\n");
1823
+ const insertText = newMembers.map((m) => indent + m.text).join("\n\n");
1761
1824
  insertions.push({
1762
1825
  line: existSymbol.bodyEndLine,
1763
- text: insertText
1826
+ text: "\n" + insertText
1764
1827
  });
1765
1828
  deepAdded += newMembers.length;
1766
1829
  }
@@ -2241,7 +2304,9 @@ function buildEmitterContext(spec, options) {
2241
2304
  outputDir: options.outputDir,
2242
2305
  apiSurface: options.apiSurface,
2243
2306
  overlayLookup: options.overlayLookup,
2244
- resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
2307
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules),
2308
+ targetDir: options.target,
2309
+ priorTargetManifestPaths: options.priorTargetManifestPaths
2245
2310
  };
2246
2311
  }
2247
2312
  /**
@@ -2360,7 +2425,7 @@ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
2360
2425
  */
2361
2426
  function isRootFile(filePath) {
2362
2427
  if (filePath.includes("/fixtures/")) return false;
2363
- if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
2428
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return true;
2364
2429
  if (filePath.includes("/serializers/")) return false;
2365
2430
  if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
2366
2431
  return true;
@@ -2497,7 +2562,10 @@ async function integrateGeneratedFiles(opts) {
2497
2562
  for (const filePath of removals) try {
2498
2563
  await fs.unlink(filePath);
2499
2564
  } catch {}
2500
- return result;
2565
+ return {
2566
+ ...result,
2567
+ emittedPaths: resolved.map((f) => f.path)
2568
+ };
2501
2569
  }
2502
2570
  //#endregion
2503
2571
  //#region src/engine/formatter.ts
@@ -2532,9 +2600,141 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
2532
2600
  }
2533
2601
  }
2534
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
2535
2730
  //#region src/engine/orchestrator.ts
2536
2731
  async function generate(spec, emitter, options) {
2537
- 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
+ });
2538
2738
  if (options.dryRun) {
2539
2739
  if (options.target) {
2540
2740
  console.log(`\nTarget integration (${options.target}):`);
@@ -2542,13 +2742,25 @@ async function generate(spec, emitter, options) {
2542
2742
  }
2543
2743
  return withHeaders;
2544
2744
  }
2745
+ const outputPrevManifest = options.noPrune ? null : await readManifest(options.outputDir);
2545
2746
  const writeResult = await writeFiles(withHeaders, options.outputDir, {
2546
2747
  language: emitter.language,
2547
2748
  header
2548
2749
  });
2549
2750
  if (writeResult.merged.length > 0) console.log(`Merged into ${writeResult.merged.length} existing files (additive only)`);
2550
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
+ });
2551
2762
  if (options.target) {
2763
+ const targetPrevManifest = targetManifestForCtx;
2552
2764
  const targetResult = await integrateGeneratedFiles({
2553
2765
  files: withHeaders,
2554
2766
  language: emitter.language,
@@ -2558,6 +2770,15 @@ async function generate(spec, emitter, options) {
2558
2770
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
2559
2771
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
2560
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
+ });
2561
2782
  const allTargetFiles = [
2562
2783
  ...targetResult.written,
2563
2784
  ...targetResult.merged,
@@ -2567,6 +2788,40 @@ async function generate(spec, emitter, options) {
2567
2788
  }
2568
2789
  return withHeaders;
2569
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
+ }
2570
2825
  //#endregion
2571
2826
  //#region src/engine/registry.ts
2572
2827
  const emitters = /* @__PURE__ */ new Map();
@@ -2927,4 +3182,4 @@ function summarize(changes) {
2927
3182
  //#endregion
2928
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 };
2929
3184
 
2930
- //# sourceMappingURL=diff-CSy3g-Bq.mjs.map
3185
+ //# sourceMappingURL=diff-CltJMcwG.mjs.map