@workos/oagen 0.29.3 → 0.30.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.
@@ -3775,6 +3775,7 @@ function extractKotlinClassMembers(classBody, source) {
3775
3775
  const children = classBody.namedChildren;
3776
3776
  for (let i = 0; i < children.length; i++) {
3777
3777
  const child = children[i];
3778
+ const declIndex = i;
3778
3779
  let memberName = null;
3779
3780
  if (child.type === "property_declaration") memberName = child.children.find((c) => c.type === "variable_declaration")?.children.find((c) => c.type === "simple_identifier")?.text ?? null;
3780
3781
  else if (child.type === "function_declaration") memberName = child.children.find((c) => c.type === "simple_identifier")?.text ?? null;
@@ -3789,7 +3790,7 @@ function extractKotlinClassMembers(classBody, source) {
3789
3790
  } else break;
3790
3791
  }
3791
3792
  let startIdx = child.startIndex;
3792
- for (let k = i - 1; k >= 0; k--) {
3793
+ for (let k = declIndex - 1; k >= 0; k--) {
3793
3794
  const prev = children[k];
3794
3795
  if (prev.type === "multiline_comment") {
3795
3796
  if (source.slice(prev.startIndex, prev.endIndex).startsWith("/**")) startIdx = prev.startIndex;
@@ -3858,6 +3859,13 @@ const kotlinMergeAdapter = {
3858
3859
  isManagedMember(member) {
3859
3860
  return KOTLIN_MANAGED_ACCESSOR_DOC.test(member.text);
3860
3861
  },
3862
+ importedNames(imp) {
3863
+ const path = imp.text.replace(/^import\s+/, "").trim();
3864
+ const alias = path.match(/\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/);
3865
+ if (alias) return [alias[1]];
3866
+ const last = path.split(".").pop();
3867
+ return last && last !== "*" ? [last] : [];
3868
+ },
3861
3869
  shouldSkipDeepMerge(_symbolName, existingMemberKeys, newMembers) {
3862
3870
  const newText = newMembers.map((m) => m.text).join("\n");
3863
3871
  for (const match of newText.matchAll(/this\.(\w+)/g)) {
@@ -4740,6 +4748,116 @@ function declarationLineStart(content, declStartIndex) {
4740
4748
  return prevNewline === -1 ? 0 : prevNewline + 1;
4741
4749
  }
4742
4750
  /**
4751
+ * Widen a member's byte range so deleting it removes whole lines.
4752
+ *
4753
+ * The raw range starts at the declaration (or its docstring), which sits after
4754
+ * the member's indentation — splicing it out verbatim leaves that indentation
4755
+ * glued to the following line. Extends backwards over the indentation and
4756
+ * forwards over the trailing newline, then absorbs the blank line the removal
4757
+ * would otherwise duplicate (or strand in front of the block's closing brace).
4758
+ */
4759
+ function expandPruneRange(source, rawStart, rawEnd) {
4760
+ let start = rawStart;
4761
+ while (start > 0 && (source[start - 1] === " " || source[start - 1] === " ")) start--;
4762
+ let end = rawEnd;
4763
+ while (end < source.length && (source[end] === " " || source[end] === " ")) end++;
4764
+ end = skipLineBreak(source, end);
4765
+ if (precededByBlankLine(source, start)) {
4766
+ if (startsWithLineBreak(source, end)) end = skipLineBreak(source, end);
4767
+ else if (/^[ \t]*\}/.test(source.slice(end, end + 40))) start = backOverLineBreak(source, start);
4768
+ }
4769
+ return {
4770
+ start,
4771
+ end
4772
+ };
4773
+ }
4774
+ function startsWithLineBreak(source, idx) {
4775
+ return source[idx] === "\n" || source[idx] === "\r" && source[idx + 1] === "\n";
4776
+ }
4777
+ function skipLineBreak(source, idx) {
4778
+ let i = idx;
4779
+ if (source[i] === "\r") i++;
4780
+ return source[i] === "\n" ? i + 1 : i;
4781
+ }
4782
+ function backOverLineBreak(source, idx) {
4783
+ let i = idx;
4784
+ if (source[i - 1] === "\n") i--;
4785
+ if (source[i - 1] === "\r") i--;
4786
+ return i;
4787
+ }
4788
+ /** Whether the line ending immediately before `idx` (a line start) is empty. */
4789
+ function precededByBlankLine(source, idx) {
4790
+ if (idx === 0 || source[idx - 1] !== "\n") return false;
4791
+ const priorLineEnd = backOverLineBreak(source, idx);
4792
+ return priorLineEnd > 0 && source[priorLineEnd - 1] === "\n";
4793
+ }
4794
+ function referencesIdentifier(source, name) {
4795
+ return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(source);
4796
+ }
4797
+ /**
4798
+ * Blank out comments and string text, preserving offsets and line structure.
4799
+ *
4800
+ * An identifier lookup over raw source can't tell a live reference from a name
4801
+ * that merely survives in prose. That distinction decides whether an import
4802
+ * stays: keeping one whose only remaining mention is a KDoc leaves an import of
4803
+ * a class the generator no longer emits, which doesn't compile — the very
4804
+ * failure the prune exists to prevent.
4805
+ *
4806
+ * Interpolated expressions are the exception: `"${Agents.NAME}"` is code that
4807
+ * sits inside a string literal, and masking it would drop an import the file
4808
+ * genuinely uses. String extents are masked around those holes, not over them.
4809
+ *
4810
+ * Grammar node names come from the adapter's `urlFingerprintConfig`, which
4811
+ * already inventories exactly these three categories per language. Comments are
4812
+ * matched structurally instead — every grammar we support names them `*comment`.
4813
+ */
4814
+ function maskNonCode(tree, source, config) {
4815
+ const stringNodeTypes = new Set(config?.stringNodeTypes ?? []);
4816
+ const interpolationNodeTypes = new Set(config?.interpolationNodeTypes ?? []);
4817
+ const spans = [];
4818
+ const collectStringSpans = (node) => {
4819
+ const holes = [];
4820
+ const findHoles = (n) => {
4821
+ if (interpolationNodeTypes.has(n.type)) {
4822
+ holes.push([n.startIndex, n.endIndex]);
4823
+ return;
4824
+ }
4825
+ for (const child of n.children) findHoles(child);
4826
+ };
4827
+ findHoles(node);
4828
+ let cursor = node.startIndex;
4829
+ for (const [holeStart, holeEnd] of holes) {
4830
+ if (holeStart > cursor) spans.push([cursor, holeStart]);
4831
+ cursor = holeEnd;
4832
+ }
4833
+ if (cursor < node.endIndex) spans.push([cursor, node.endIndex]);
4834
+ };
4835
+ const walk = (node) => {
4836
+ if (node.type.includes("comment")) {
4837
+ spans.push([node.startIndex, node.endIndex]);
4838
+ return;
4839
+ }
4840
+ if (stringNodeTypes.has(node.type)) {
4841
+ collectStringSpans(node);
4842
+ return;
4843
+ }
4844
+ for (const child of node.children) walk(child);
4845
+ };
4846
+ walk(tree.rootNode);
4847
+ if (spans.length === 0) return source;
4848
+ let masked = "";
4849
+ let cursor = 0;
4850
+ for (const [spanStart, spanEnd] of spans) {
4851
+ if (spanStart < cursor) continue;
4852
+ masked += source.slice(cursor, spanStart) + source.slice(spanStart, spanEnd).replace(/[^\r\n]/g, " ");
4853
+ cursor = spanEnd;
4854
+ }
4855
+ return masked + source.slice(cursor);
4856
+ }
4857
+ function removeLines(source, lines) {
4858
+ return source.split("\n").filter((line) => !lines.has(line.trim())).join("\n");
4859
+ }
4860
+ /**
4743
4861
  * Resolve a docstring range by searching for the nearest matching text before
4744
4862
  * the declaration start. Falls back to parser-provided offsets when search fails.
4745
4863
  */
@@ -4954,6 +5072,7 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
4954
5072
  let deepAdded = 0;
4955
5073
  let deepPruned = 0;
4956
5074
  const insertions = [];
5075
+ const prunedTexts = [];
4957
5076
  if (adapter.extractMembers) {
4958
5077
  const parser = await getParser(language);
4959
5078
  const resultTree = safeParse(parser, result);
@@ -4982,10 +5101,9 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
4982
5101
  if (pruneEdits.length > 0) {
4983
5102
  pruneEdits.sort((a, b) => b.start - a.start);
4984
5103
  for (const edit of pruneEdits) {
4985
- let end = edit.end;
4986
- while (end < result.length && (result[end] === " " || result[end] === " ")) end++;
4987
- if (result[end] === "\n") end++;
4988
- result = result.slice(0, edit.start) + result.slice(end);
5104
+ const { start, end } = expandPruneRange(result, edit.start, edit.end);
5105
+ prunedTexts.push(result.slice(start, end));
5106
+ result = result.slice(0, start) + result.slice(end);
4989
5107
  }
4990
5108
  deepPruned = pruneEdits.length;
4991
5109
  const reTree = safeParse(parser, result);
@@ -5050,6 +5168,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
5050
5168
  result = lines.join("\n");
5051
5169
  }
5052
5170
  }
5171
+ if (prunedTexts.length > 0 && adapter.importedNames) {
5172
+ const prunedText = prunedTexts.join("\n");
5173
+ const codeOnly = maskNonCode(safeParse(await getParser(language), result), result, adapter.urlFingerprintConfig);
5174
+ const orphaned = /* @__PURE__ */ new Set();
5175
+ for (const imp of existingStatements.imports) {
5176
+ const names = adapter.importedNames(imp);
5177
+ if (names.length === 0) continue;
5178
+ if (!names.some((n) => referencesIdentifier(prunedText, n))) continue;
5179
+ const line = imp.text.trim();
5180
+ const withoutImport = removeLines(codeOnly, new Set([line]));
5181
+ if (names.some((n) => referencesIdentifier(withoutImport, n))) continue;
5182
+ orphaned.add(line);
5183
+ }
5184
+ if (orphaned.size > 0) result = removeLines(result, orphaned);
5185
+ }
5053
5186
  let docstringUpdates = 0;
5054
5187
  {
5055
5188
  const parser = await getParser(language);
@@ -6227,6 +6360,43 @@ function resolveResponseModelName(op) {
6227
6360
  if (ref.kind === "nullable" && ref.inner.kind === "model") return ref.inner.name;
6228
6361
  return null;
6229
6362
  }
6363
+ /**
6364
+ * Find every pair of distinct paths that resolve to the same SDK method name.
6365
+ *
6366
+ * Emitters already refuse to generate on a collision, but they only run inside
6367
+ * the per-language build matrix — so a spec addition that collides fails every
6368
+ * matrix job minutes in, with no cheap way to catch it first. Surfacing the same
6369
+ * check from `oagen resolve` turns that into a seconds-long precheck.
6370
+ *
6371
+ * Returns every collision rather than throwing on the first, so a spec that
6372
+ * introduces several is fixed in one pass instead of one CI round-trip each.
6373
+ *
6374
+ * Callers that generate a subset of services must filter to the operations they
6375
+ * will actually emit before calling this: a collision between two out-of-scope
6376
+ * paths is not that run's problem.
6377
+ */
6378
+ function findResolvedMethodCollisions(resolved) {
6379
+ const seen = /* @__PURE__ */ new Map();
6380
+ const collisions = [];
6381
+ for (const r of resolved) {
6382
+ const key = `${r.mountOn}.${r.methodName}`;
6383
+ const current = {
6384
+ httpMethod: r.operation.httpMethod.toUpperCase(),
6385
+ path: r.operation.path
6386
+ };
6387
+ const existing = seen.get(key);
6388
+ if (existing && (existing.path !== current.path || existing.httpMethod !== current.httpMethod)) {
6389
+ collisions.push({
6390
+ key,
6391
+ first: existing,
6392
+ second: current
6393
+ });
6394
+ continue;
6395
+ }
6396
+ if (!existing) seen.set(key, current);
6397
+ }
6398
+ return collisions;
6399
+ }
6230
6400
  //#endregion
6231
6401
  //#region src/engine/scoped-services.ts
6232
6402
  /**
@@ -10777,6 +10947,11 @@ async function resolveCommand(opts) {
10777
10947
  });
10778
10948
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
10779
10949
  const resolved = resolveOperations(ir, opts.operationHints, opts.mountRules);
10950
+ const collisions = findResolvedMethodCollisions(resolved);
10951
+ if (collisions.length > 0) {
10952
+ const detail = collisions.map((c) => ` ${c.key}: ${c.first.httpMethod} ${c.first.path} conflicts with ${c.second.httpMethod} ${c.second.path}`).join("\n");
10953
+ throw new CommandError(`Resolved operation name collision${collisions.length > 1 ? "s" : ""}:\n${detail}`, "Give one of each pair an explicit `name` in operationHints. Rename the NEW path — renaming an already-shipped method breaks released SDKs.", 1);
10954
+ }
10780
10955
  if ((opts.format ?? "table") === "json") {
10781
10956
  const output = resolved.map(toJsonRow);
10782
10957
  console.log(JSON.stringify(output, null, 2));
@@ -10861,7 +11036,10 @@ function applyConfig(config) {
10861
11036
  function handleError(err) {
10862
11037
  const exitCode = err instanceof CommandError ? err.exitCode : 1;
10863
11038
  const message = err instanceof Error ? err.message : String(err);
10864
- if (message) console.error(message);
11039
+ if (message) {
11040
+ const label = err instanceof CommandError ? "" : `${err instanceof Error ? err.name : "Error"}: `;
11041
+ console.error(`${label}${message}`);
11042
+ }
10865
11043
  process.exit(exitCode);
10866
11044
  }
10867
11045
  const configArgIdx = process.argv.indexOf("--config");