@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.
@@ -679,6 +679,86 @@ function buildFieldFromSchema(fieldName, fieldSchema, contextName, requiredSet)
679
679
  example: fieldSchema.example
680
680
  };
681
681
  }
682
+ /**
683
+ * If every non-null oneOf/anyOf variant is a string-valued const, return the
684
+ * list of those const values so the union can collapse into a single enum.
685
+ * Returns null when any variant isn't a pure string-const (model ref,
686
+ * number const, nullable, etc.).
687
+ */
688
+ function collectLiteralStringConsts(variants) {
689
+ if (variants.length === 0) return null;
690
+ const values = [];
691
+ for (const v of variants) {
692
+ if (typeof v.const === "string") {
693
+ values.push(v.const);
694
+ continue;
695
+ }
696
+ if (Array.isArray(v.enum) && v.enum.length === 1 && typeof v.enum[0] === "string") {
697
+ values.push(v.enum[0]);
698
+ continue;
699
+ }
700
+ return null;
701
+ }
702
+ return values;
703
+ }
704
+ /** Build an EnumRef the same way the regular enum path does for naming. */
705
+ function buildSyntheticEnumRef(values, contextName, parentModelName) {
706
+ const baseName = toPascalCase(contextName ?? "UnknownEnum");
707
+ const cleanParent = parentModelName ? stripListItemMarkers(parentModelName) : void 0;
708
+ return {
709
+ kind: "enum",
710
+ name: cleanParent && !baseName.startsWith(cleanParent) ? `${cleanParent}${baseName}` : baseName,
711
+ values
712
+ };
713
+ }
714
+ /**
715
+ * Detect an implicit discriminator on an oneOf where every variant is an
716
+ * object schema that pins the same property to a const value. Returns
717
+ * { property, mapping } so the IR can represent the union as discriminated
718
+ * without needing an explicit `discriminator:` key in the spec.
719
+ *
720
+ * Both shapes are supported:
721
+ * { properties: { event: { const: "x" } }, required: ["event"] }
722
+ * { properties: { event: { type: "string", enum: ["x"] } } }
723
+ *
724
+ * Returns null when any variant doesn't fit the pattern or when variants
725
+ * disagree on which property carries the discriminator.
726
+ */
727
+ function detectConstPropertyDiscriminator(variants) {
728
+ if (variants.length < 2) return null;
729
+ let candidateProperty = null;
730
+ const candidates = Object.keys(variants[0]?.properties ?? {});
731
+ for (const propName of candidates) if (variants.every((v) => getConstPropertyValue(v, propName) !== null)) {
732
+ candidateProperty = propName;
733
+ break;
734
+ }
735
+ if (!candidateProperty) return null;
736
+ const mapping = {};
737
+ for (const v of variants) {
738
+ const value = getConstPropertyValue(v, candidateProperty);
739
+ if (value === null) return null;
740
+ const variantName = resolveVariantModelName(v);
741
+ if (!variantName) return null;
742
+ mapping[value] = variantName;
743
+ }
744
+ return {
745
+ property: candidateProperty,
746
+ mapping
747
+ };
748
+ }
749
+ function getConstPropertyValue(schema, property) {
750
+ const prop = schema.properties?.[property];
751
+ if (!prop) return null;
752
+ if (typeof prop.const === "string") return prop.const;
753
+ if (Array.isArray(prop.enum) && prop.enum.length === 1 && typeof prop.enum[0] === "string") return prop.enum[0];
754
+ return null;
755
+ }
756
+ function resolveVariantModelName(schema) {
757
+ if (schema.$ref) return resolveSchemaName(schema.$ref.replace(/^#\/components\/schemas\//, ""));
758
+ const title = typeof schema["title"] === "string" ? schema["title"] : null;
759
+ if (title) return resolveSchemaName(title);
760
+ return null;
761
+ }
682
762
  function extractEnum(name, schema) {
683
763
  return {
684
764
  name,
@@ -902,6 +982,15 @@ function schemaToTypeRef(schema, contextName, parentModelName) {
902
982
  kind: "nullable",
903
983
  inner: schemaToTypeRef(nonNullVariants[0], contextName, parentModelName)
904
984
  };
985
+ const literalStrings = collectLiteralStringConsts(nonNullVariants);
986
+ if (literalStrings !== null && literalStrings.length >= 2) {
987
+ const enumRef = buildSyntheticEnumRef(literalStrings, contextName, parentModelName);
988
+ return nullVariant ? {
989
+ kind: "nullable",
990
+ inner: enumRef
991
+ } : enumRef;
992
+ }
993
+ const syntheticDiscriminator = !schema.discriminator && compositionKind === "oneOf" ? detectConstPropertyDiscriminator(nonNullVariants) : null;
905
994
  const variants = rawVariants.filter((v) => v.type !== "null").map((v) => schemaToTypeRef(v, contextName, parentModelName));
906
995
  const hasNull = !!nullVariant;
907
996
  const union = {
@@ -911,7 +1000,7 @@ function schemaToTypeRef(schema, contextName, parentModelName) {
911
1000
  ...schema.discriminator ? { discriminator: {
912
1001
  property: schema.discriminator.propertyName,
913
1002
  mapping: Object.fromEntries(Object.entries(schema.discriminator.mapping ?? {}).map(([k, v]) => [k, v.replace(/^#\/components\/schemas\//, "")]))
914
- } } : {}
1003
+ } } : syntheticDiscriminator ? { discriminator: syntheticDiscriminator } : {}
915
1004
  };
916
1005
  return hasNull ? {
917
1006
  kind: "nullable",
@@ -1662,7 +1751,9 @@ function extractParams(params, location, operationContext) {
1662
1751
  description: p.description,
1663
1752
  deprecated: p.deprecated || p.schema?.deprecated || void 0,
1664
1753
  default: p.schema?.default,
1665
- example: p.example ?? p.schema?.example
1754
+ example: p.example ?? p.schema?.example,
1755
+ style: p.style,
1756
+ explode: p.explode
1666
1757
  }));
1667
1758
  }
1668
1759
  function extractRequestBody(body, op, inlineModels) {
@@ -2596,6 +2687,41 @@ function findPrecedingKdoc(children, index, source) {
2596
2687
  }
2597
2688
  return null;
2598
2689
  }
2690
+ function extractKotlinClassMembers(classBody, source) {
2691
+ const members = [];
2692
+ const children = classBody.namedChildren;
2693
+ for (let i = 0; i < children.length; i++) {
2694
+ const child = children[i];
2695
+ let memberName = null;
2696
+ if (child.type === "property_declaration") memberName = child.children.find((c) => c.type === "variable_declaration")?.children.find((c) => c.type === "simple_identifier")?.text ?? null;
2697
+ else if (child.type === "function_declaration") memberName = child.children.find((c) => c.type === "simple_identifier")?.text ?? null;
2698
+ else if (child.type === "companion_object") memberName = "companion";
2699
+ if (!memberName) continue;
2700
+ let endIdx = child.endIndex;
2701
+ if (child.type === "property_declaration") while (i + 1 < children.length) {
2702
+ const next = children[i + 1];
2703
+ if (next.type === "getter" || next.type === "setter") {
2704
+ endIdx = next.endIndex;
2705
+ i++;
2706
+ } else break;
2707
+ }
2708
+ let startIdx = child.startIndex;
2709
+ for (let k = i - 1; k >= 0; k--) {
2710
+ const prev = children[k];
2711
+ if (prev.type === "multiline_comment") {
2712
+ if (source.slice(prev.startIndex, prev.endIndex).startsWith("/**")) startIdx = prev.startIndex;
2713
+ break;
2714
+ }
2715
+ if (prev.type === "line_comment") continue;
2716
+ break;
2717
+ }
2718
+ members.push({
2719
+ key: memberName,
2720
+ text: source.slice(startIdx, endIdx)
2721
+ });
2722
+ }
2723
+ return members;
2724
+ }
2599
2725
  const kotlinMergeAdapter = {
2600
2726
  language: "kotlin",
2601
2727
  grammarModule: "tree-sitter-kotlin",
@@ -2636,6 +2762,33 @@ const kotlinMergeAdapter = {
2636
2762
  renderImports(imports) {
2637
2763
  return imports.map((entry) => entry.text);
2638
2764
  },
2765
+ shouldSkipDeepMerge(_symbolName, existingMemberKeys, newMembers) {
2766
+ const newText = newMembers.map((m) => m.text).join("\n");
2767
+ for (const match of newText.matchAll(/this\.(\w+)/g)) {
2768
+ const propName = match[1];
2769
+ if (propName && !existingMemberKeys.has(propName)) return true;
2770
+ }
2771
+ return false;
2772
+ },
2773
+ extractMembers(tree, source) {
2774
+ const result = /* @__PURE__ */ new Map();
2775
+ for (const child of tree.rootNode.children) {
2776
+ if (child.type !== "class_declaration") continue;
2777
+ const nameNode = child.children.find((c) => c.type === "type_identifier");
2778
+ if (!nameNode) continue;
2779
+ const body = child.children.find((c) => c.type === "class_body");
2780
+ if (!body) continue;
2781
+ const members = extractKotlinClassMembers(body, source);
2782
+ const firstMember = body.firstNamedChild;
2783
+ const memberIndent = firstMember ? " ".repeat(firstMember.startPosition.column) : void 0;
2784
+ result.set(nameNode.text, {
2785
+ members,
2786
+ bodyEndLine: body.endPosition.row,
2787
+ memberIndent
2788
+ });
2789
+ }
2790
+ return result;
2791
+ },
2639
2792
  extractDocstrings(tree, source) {
2640
2793
  const result = /* @__PURE__ */ new Map();
2641
2794
  const rootChildren = tree.rootNode.children;
@@ -3426,6 +3579,30 @@ function safeParse(parser, source) {
3426
3579
  * Supports any language with a tree-sitter grammar package.
3427
3580
  */
3428
3581
  const parserCache = /* @__PURE__ */ new Map();
3582
+ /**
3583
+ * When replacing an existing docstring with a generated one, carry over any
3584
+ * `@deprecated` lines from the existing doc that aren't present in the
3585
+ * generated doc. This prevents hand-written deprecation notices from being
3586
+ * silently dropped during docstring refresh.
3587
+ */
3588
+ function preserveDeprecatedTags(existingDoc, generatedDoc) {
3589
+ if (!existingDoc.includes("@deprecated")) return generatedDoc;
3590
+ if (generatedDoc.includes("@deprecated")) return generatedDoc;
3591
+ const deprecatedParts = [];
3592
+ for (const line of existingDoc.split("\n")) if (/@deprecated\b/.test(line)) {
3593
+ const match = line.match(/@deprecated\b.*/);
3594
+ if (match) {
3595
+ const cleaned = match[0].replace(/\s*\*\/\s*$/, "");
3596
+ deprecatedParts.push(` * ${cleaned}`);
3597
+ }
3598
+ }
3599
+ if (deprecatedParts.length === 0) return generatedDoc;
3600
+ const closingIdx = generatedDoc.lastIndexOf("*/");
3601
+ if (closingIdx === -1) return generatedDoc;
3602
+ const before = generatedDoc.slice(0, closingIdx);
3603
+ const after = generatedDoc.slice(closingIdx);
3604
+ return before + deprecatedParts.join("\n") + "\n " + after;
3605
+ }
3429
3606
  function findIgnoredRegions(source) {
3430
3607
  const regions = [];
3431
3608
  const startTag = "@oagen-ignore-start";
@@ -3539,10 +3716,21 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3539
3716
  }
3540
3717
  for (const anchor of existingStatements.importAnchors) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(anchor)).split("\n").length - 1 + anchor.split("\n").length - 1;
3541
3718
  if (lastImportEndIndex === -1 && existingStatements.importInsertionAnchor) lastImportEndIndex = existingContent.slice(0, existingContent.indexOf(existingStatements.importInsertionAnchor)).split("\n").length - 1 + existingStatements.importInsertionAnchor.split("\n").length - 1;
3719
+ const existingExportedNames = /* @__PURE__ */ new Set();
3542
3720
  for (const stmt of existingStatements.statements) {
3543
- if (stmt.kind === "reexport") existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
3544
- if (stmt.key) existingKeys.add(stmt.key);
3545
- else existingUnnamedTexts.add(stmt.text.trim());
3721
+ if (stmt.kind === "reexport") {
3722
+ existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
3723
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
3724
+ if (braceMatch) for (const part of braceMatch[1].split(",")) {
3725
+ const segments = part.trim().split(/\s+as\s+/);
3726
+ const exportedName = (segments[1] ?? segments[0]).trim();
3727
+ if (exportedName) existingExportedNames.add(exportedName);
3728
+ }
3729
+ }
3730
+ if (stmt.key) {
3731
+ existingKeys.add(stmt.key);
3732
+ existingExportedNames.add(stmt.key);
3733
+ } else existingUnnamedTexts.add(stmt.text.trim());
3546
3734
  }
3547
3735
  const headerLine = header.trim();
3548
3736
  const newImports = [];
@@ -3602,6 +3790,17 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3602
3790
  preserved++;
3603
3791
  continue;
3604
3792
  }
3793
+ const braceMatch = stmt.text.match(/\{([^}]+)\}/);
3794
+ if (braceMatch) {
3795
+ const exportedNames = braceMatch[1].split(",").map((n) => {
3796
+ const parts = n.trim().split(/\s+as\s+/);
3797
+ return (parts[1] ?? parts[0]).trim();
3798
+ }).filter(Boolean);
3799
+ if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) {
3800
+ preserved++;
3801
+ continue;
3802
+ }
3803
+ }
3605
3804
  }
3606
3805
  if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) {
3607
3806
  preserved++;
@@ -3639,10 +3838,10 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3639
3838
  if (newMembers.length > 0) {
3640
3839
  if (adapter.shouldSkipDeepMerge?.(symbolName, existingMemberKeys, newMembers)) continue;
3641
3840
  const indent = existSymbol.memberIndent ?? " ";
3642
- const insertText = newMembers.map((m) => indent + m.text).join("\n");
3841
+ const insertText = newMembers.map((m) => indent + m.text).join("\n\n");
3643
3842
  insertions.push({
3644
3843
  line: existSymbol.bodyEndLine,
3645
- text: insertText
3844
+ text: "\n" + insertText
3646
3845
  });
3647
3846
  deepAdded += newMembers.length;
3648
3847
  }
@@ -3703,10 +3902,11 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3703
3902
  const genDoc = genInfo.docstring && genInfo.docstring.text.trim() !== headerLine ? genInfo.docstring : null;
3704
3903
  if (genDoc) if (existInfo.docstring) {
3705
3904
  if (!existInfo.docstring.text.includes("@oagen-ignore") && existInfo.docstring.text !== genDoc.text) {
3905
+ const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
3706
3906
  edits.push({
3707
3907
  start: existInfo.docstring.startIndex,
3708
3908
  end: existInfo.docstring.endIndex,
3709
- newText: genDoc.text
3909
+ newText
3710
3910
  });
3711
3911
  docstringUpdates++;
3712
3912
  }
@@ -3723,14 +3923,16 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3723
3923
  const matchedExistMembers = /* @__PURE__ */ new Set();
3724
3924
  for (const [memberName, genMember] of genInfo.members) {
3725
3925
  const existMember = existInfo.members.get(memberName);
3726
- if (!existMember || !genMember.docstring) continue;
3926
+ if (!existMember) continue;
3727
3927
  matchedExistMembers.add(memberName);
3928
+ if (!genMember.docstring) continue;
3728
3929
  if (existMember.docstring) {
3729
3930
  if (!existMember.docstring.text.includes("@oagen-ignore") && existMember.docstring.text !== genMember.docstring.text) {
3931
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
3730
3932
  edits.push({
3731
3933
  start: existMember.docstring.startIndex,
3732
3934
  end: existMember.docstring.endIndex,
3733
- newText: genMember.docstring.text
3935
+ newText
3734
3936
  });
3735
3937
  docstringUpdates++;
3736
3938
  }
@@ -3745,34 +3947,59 @@ async function mergeIntoExisting(existingContent, generatedContent, language, he
3745
3947
  docstringUpdates++;
3746
3948
  }
3747
3949
  }
3748
- for (const [_genName, genMember] of genInfo.members) {
3950
+ const unmatchedGenByFp = /* @__PURE__ */ new Map();
3951
+ for (const [genName, genMember] of genInfo.members) {
3952
+ if (matchedExistMembers.has(genName)) continue;
3749
3953
  if (!genMember.docstring || !genMember.urlFingerprint) continue;
3750
- for (const [existName, existMember] of existInfo.members) {
3751
- if (matchedExistMembers.has(existName)) continue;
3752
- if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue;
3753
- if (existMember.docstring) {
3754
- if (existMember.docstring.text.includes("@oagen-ignore")) continue;
3755
- if (existMember.docstring.text !== genMember.docstring.text) {
3756
- edits.push({
3757
- start: existMember.docstring.startIndex,
3758
- end: existMember.docstring.endIndex,
3759
- newText: genMember.docstring.text
3760
- });
3761
- docstringUpdates++;
3762
- }
3763
- } else {
3764
- const lineStart = existMember.declStartIndex - existMember.declColumn;
3765
- const indent = " ".repeat(existMember.declColumn);
3954
+ if (existInfo.members.has(genName)) continue;
3955
+ const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? [];
3956
+ bucket.push({
3957
+ name: genName,
3958
+ member: genMember
3959
+ });
3960
+ unmatchedGenByFp.set(genMember.urlFingerprint, bucket);
3961
+ }
3962
+ const unmatchedExistByFp = /* @__PURE__ */ new Map();
3963
+ for (const [existName, existMember] of existInfo.members) {
3964
+ if (matchedExistMembers.has(existName)) continue;
3965
+ if (!existMember.urlFingerprint) continue;
3966
+ const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? [];
3967
+ bucket.push({
3968
+ name: existName,
3969
+ member: existMember
3970
+ });
3971
+ unmatchedExistByFp.set(existMember.urlFingerprint, bucket);
3972
+ }
3973
+ for (const [fp, genBucket] of unmatchedGenByFp) {
3974
+ if (genBucket.length !== 1) continue;
3975
+ const existBucket = unmatchedExistByFp.get(fp);
3976
+ if (!existBucket || existBucket.length !== 1) continue;
3977
+ const genMember = genBucket[0].member;
3978
+ const existEntry = existBucket[0];
3979
+ const existMember = existEntry.member;
3980
+ if (!genMember.docstring) continue;
3981
+ if (existMember.docstring) {
3982
+ if (existMember.docstring.text.includes("@oagen-ignore")) continue;
3983
+ if (existMember.docstring.text !== genMember.docstring.text) {
3984
+ const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
3766
3985
  edits.push({
3767
- start: lineStart,
3768
- end: lineStart,
3769
- newText: indent + genMember.docstring.text + "\n"
3986
+ start: existMember.docstring.startIndex,
3987
+ end: existMember.docstring.endIndex,
3988
+ newText
3770
3989
  });
3771
3990
  docstringUpdates++;
3772
3991
  }
3773
- matchedExistMembers.add(existName);
3774
- break;
3992
+ } else {
3993
+ const lineStart = existMember.declStartIndex - existMember.declColumn;
3994
+ const indent = " ".repeat(existMember.declColumn);
3995
+ edits.push({
3996
+ start: lineStart,
3997
+ end: lineStart,
3998
+ newText: indent + genMember.docstring.text + "\n"
3999
+ });
4000
+ docstringUpdates++;
3775
4001
  }
4002
+ matchedExistMembers.add(existEntry.name);
3776
4003
  }
3777
4004
  }
3778
4005
  if (edits.length > 0) {
@@ -4059,12 +4286,16 @@ function spliceExtraImports(existingLines, resultLines) {
4059
4286
  const extraSimple = [...existingSimple].filter((l) => !generatedSimple.has(l));
4060
4287
  const existingFrom = collectFromImports(existingLines);
4061
4288
  const generatedFrom = collectFromImports(resultLines);
4289
+ const allGeneratedIds = /* @__PURE__ */ new Set();
4290
+ for (const ids of generatedFrom.values()) for (const id of ids) allGeneratedIds.add(id);
4062
4291
  const extraFromLines = [];
4063
4292
  for (const [mod, existIds] of existingFrom) {
4064
4293
  const genIds = generatedFrom.get(mod);
4065
- if (!genIds) extraFromLines.push(`from ${mod} import ${[...existIds].join(", ")}`);
4066
- else {
4067
- const extra = [...existIds].filter((id) => !genIds.has(id));
4294
+ if (!genIds) {
4295
+ const extra = [...existIds].filter((id) => !allGeneratedIds.has(id));
4296
+ if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
4297
+ } else {
4298
+ const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id));
4068
4299
  if (extra.length > 0) extraFromLines.push(`from ${mod} import ${extra.join(", ")}`);
4069
4300
  }
4070
4301
  }
@@ -4171,7 +4402,7 @@ function deriveMethodName(op, _service) {
4171
4402
  const termIdx = segments.lastIndexOf(staticSegments[staticSegments.length - 1]);
4172
4403
  if (segments.slice(penIdx + 1, termIdx).some((s) => s.startsWith("{"))) resource = `${toSnake(singularize(penultimate))}_${resource}`;
4173
4404
  }
4174
- const noun = trailingParam ? singularize(resource) : resource;
4405
+ const noun = trailingParam || verb === "create" ? singularize(resource) : resource;
4175
4406
  return `${verb}_${verb === "list" ? resource : noun}`;
4176
4407
  }
4177
4408
  /**
@@ -4197,7 +4428,7 @@ function resolveOperations(spec, hints, mountRules) {
4197
4428
  defaults: sh.defaults ?? {},
4198
4429
  inferFromClient: sh.inferFromClient ?? [],
4199
4430
  exposedParams: sh.exposedParams ?? [],
4200
- optionalParams: [],
4431
+ optionalParams: sh.optionalParams ?? [],
4201
4432
  responseModelName: resolveResponseModelName(op)
4202
4433
  }));
4203
4434
  resolved.push({
@@ -4205,7 +4436,10 @@ function resolveOperations(spec, hints, mountRules) {
4205
4436
  service,
4206
4437
  methodName,
4207
4438
  mountOn,
4208
- wrappers
4439
+ wrappers,
4440
+ defaults: hint?.defaults ?? {},
4441
+ inferFromClient: hint?.inferFromClient ?? [],
4442
+ urlBuilder: hint?.urlBuilder ?? false
4209
4443
  });
4210
4444
  }
4211
4445
  return resolved;
@@ -4228,7 +4462,9 @@ function buildEmitterContext(spec, options) {
4228
4462
  outputDir: options.outputDir,
4229
4463
  apiSurface: options.apiSurface,
4230
4464
  overlayLookup: options.overlayLookup,
4231
- resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules)
4465
+ resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules),
4466
+ targetDir: options.target,
4467
+ priorTargetManifestPaths: options.priorTargetManifestPaths
4232
4468
  };
4233
4469
  }
4234
4470
  /**
@@ -4297,7 +4533,6 @@ function generateAllFiles(spec, emitter, ctx) {
4297
4533
  ...emitter.generateResources(spec.services, ctx),
4298
4534
  ...emitter.generateClient(spec, ctx),
4299
4535
  ...emitter.generateErrors(ctx),
4300
- ...emitter.generateConfig(ctx),
4301
4536
  ...emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? [],
4302
4537
  ...emitter.generateTests(reachableSpec, ctx),
4303
4538
  ...emitter.generateManifest?.(spec, ctx) ?? []
@@ -4348,7 +4583,7 @@ function resolveImportPath(fromFilePath, importModulePath, knownPaths) {
4348
4583
  */
4349
4584
  function isRootFile(filePath) {
4350
4585
  if (filePath.includes("/fixtures/")) return false;
4351
- if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return false;
4586
+ if (filePath.endsWith(".spec.ts") || filePath.endsWith(".test.ts")) return true;
4352
4587
  if (filePath.includes("/serializers/")) return false;
4353
4588
  if (filePath.includes("/interfaces/") && !filePath.endsWith("/index.ts")) return false;
4354
4589
  return true;
@@ -4485,7 +4720,10 @@ async function integrateGeneratedFiles(opts) {
4485
4720
  for (const filePath of removals) try {
4486
4721
  await fs.unlink(filePath);
4487
4722
  } catch {}
4488
- return result;
4723
+ return {
4724
+ ...result,
4725
+ emittedPaths: resolved.map((f) => f.path)
4726
+ };
4489
4727
  }
4490
4728
  //#endregion
4491
4729
  //#region src/engine/formatter.ts
@@ -4520,9 +4758,141 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
4520
4758
  }
4521
4759
  }
4522
4760
  //#endregion
4761
+ //#region src/engine/manifest.ts
4762
+ /**
4763
+ * Prune-manifest support for `oagen generate`.
4764
+ *
4765
+ * The manifest records every auto-generated file emitted on the previous run,
4766
+ * relative to the directory it lives in (either the `--output` standalone dir
4767
+ * or the `--target` integration dir). On the next run we diff the previous
4768
+ * list against the current emission set and delete anything that's no longer
4769
+ * produced — preventing stale-file accumulation across regens.
4770
+ *
4771
+ * Design notes:
4772
+ * - The manifest is versioned so future format changes can be handled safely.
4773
+ * - Paths are stored sorted for deterministic diffs in git.
4774
+ * - Deletion is gated on a header guard when one is available: we only remove
4775
+ * files whose contents start with the auto-generated header. This protects
4776
+ * a hand-maintained file that happens to share a name with a previously
4777
+ * generated one (extremely rare, but worth catching).
4778
+ * - On first adoption (no previous manifest), pruning is skipped and a
4779
+ * one-line notice is printed — no surprise deletions.
4780
+ */
4781
+ const MANIFEST_FILENAME = ".oagen-manifest.json";
4782
+ const MANIFEST_VERSION = 1;
4783
+ /** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
4784
+ async function readManifest(dir) {
4785
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
4786
+ let raw;
4787
+ try {
4788
+ raw = await fs.readFile(manifestPath, "utf-8");
4789
+ } catch {
4790
+ return null;
4791
+ }
4792
+ try {
4793
+ const parsed = JSON.parse(raw);
4794
+ 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;
4795
+ if (parsed.version > MANIFEST_VERSION) {
4796
+ console.warn(`[oagen] ${MANIFEST_FILENAME} schema version ${parsed.version} is newer than supported (${MANIFEST_VERSION}); ignoring for pruning.`);
4797
+ return null;
4798
+ }
4799
+ return parsed;
4800
+ } catch {
4801
+ return null;
4802
+ }
4803
+ }
4804
+ /** Write `.oagen-manifest.json` to a directory with sorted paths. */
4805
+ async function writeManifest(dir, opts) {
4806
+ const manifest = {
4807
+ version: MANIFEST_VERSION,
4808
+ language: opts.language,
4809
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4810
+ files: [...new Set(opts.files)].sort()
4811
+ };
4812
+ const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
4813
+ await fs.mkdir(dir, { recursive: true });
4814
+ await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
4815
+ }
4816
+ /** Return the set of previous paths no longer present in the current emission. */
4817
+ function computeStalePaths(prev, currentPaths) {
4818
+ const current = new Set(currentPaths);
4819
+ return prev.files.filter((p) => !current.has(p)).sort();
4820
+ }
4821
+ /**
4822
+ * Delete stale files from `dir`.
4823
+ *
4824
+ * When `header` is provided, a file is only deleted if its contents start with
4825
+ * that header — so hand-maintained files that somehow collide with a previously
4826
+ * generated path can't be clobbered. When `header` is omitted, the guard is
4827
+ * skipped (useful for non-source artifacts like fixture JSON).
4828
+ *
4829
+ * Returns lists for reporting. Empty parent directories are removed after
4830
+ * deletion, up to (but not including) `dir` itself.
4831
+ */
4832
+ async function pruneStaleFiles(dir, paths, opts = {}) {
4833
+ const pruned = [];
4834
+ const preserved = [];
4835
+ const missing = [];
4836
+ for (const relPath of paths) {
4837
+ const fullPath = path$1.join(dir, relPath);
4838
+ let content;
4839
+ try {
4840
+ content = await fs.readFile(fullPath, "utf-8");
4841
+ } catch {
4842
+ missing.push(relPath);
4843
+ continue;
4844
+ }
4845
+ if (opts.header && !content.startsWith(opts.header)) {
4846
+ preserved.push(relPath);
4847
+ continue;
4848
+ }
4849
+ try {
4850
+ await fs.unlink(fullPath);
4851
+ pruned.push(relPath);
4852
+ } catch {
4853
+ missing.push(relPath);
4854
+ continue;
4855
+ }
4856
+ await removeEmptyParents(path$1.dirname(fullPath), dir);
4857
+ }
4858
+ return {
4859
+ pruned,
4860
+ preserved,
4861
+ missing
4862
+ };
4863
+ }
4864
+ /**
4865
+ * Walk up from `startDir` removing empty directories until we hit `stopDir`
4866
+ * (exclusive) or a non-empty directory. Silently ignores errors.
4867
+ */
4868
+ async function removeEmptyParents(startDir, stopDir) {
4869
+ let current = path$1.resolve(startDir);
4870
+ const stop = path$1.resolve(stopDir);
4871
+ while (current.startsWith(stop + path$1.sep) && current !== stop) {
4872
+ let entries;
4873
+ try {
4874
+ entries = await fs.readdir(current);
4875
+ } catch {
4876
+ return;
4877
+ }
4878
+ if (entries.length > 0) return;
4879
+ try {
4880
+ await fs.rmdir(current);
4881
+ } catch {
4882
+ return;
4883
+ }
4884
+ current = path$1.dirname(current);
4885
+ }
4886
+ }
4887
+ //#endregion
4523
4888
  //#region src/engine/orchestrator.ts
4524
4889
  async function generate(spec, emitter, options) {
4525
- const { files: withHeaders, header } = generateFiles(spec, emitter, options);
4890
+ const targetManifestForCtx = options.target && !options.noPrune ? await readManifest(options.target) : null;
4891
+ const priorTargetManifestPaths = targetManifestForCtx ? new Set(targetManifestForCtx.files) : void 0;
4892
+ const { files: withHeaders, header } = generateFiles(spec, emitter, {
4893
+ ...options,
4894
+ priorTargetManifestPaths
4895
+ });
4526
4896
  if (options.dryRun) {
4527
4897
  if (options.target) {
4528
4898
  console.log(`\nTarget integration (${options.target}):`);
@@ -4530,13 +4900,25 @@ async function generate(spec, emitter, options) {
4530
4900
  }
4531
4901
  return withHeaders;
4532
4902
  }
4903
+ const outputPrevManifest = options.noPrune ? null : await readManifest(options.outputDir);
4533
4904
  const writeResult = await writeFiles(withHeaders, options.outputDir, {
4534
4905
  language: emitter.language,
4535
4906
  header
4536
4907
  });
4537
4908
  if (writeResult.merged.length > 0) console.log(`Merged into ${writeResult.merged.length} existing files (additive only)`);
4538
4909
  if (writeResult.ignored.length > 0) console.log(`Ignored ${writeResult.ignored.length} files (@oagen-ignore-file)`);
4910
+ const outputEmittedPaths = withHeaders.map((f) => f.path);
4911
+ await applyManifestPrune({
4912
+ dir: options.outputDir,
4913
+ label: "Output",
4914
+ prevManifest: outputPrevManifest,
4915
+ currentPaths: outputEmittedPaths,
4916
+ language: emitter.language,
4917
+ header,
4918
+ noPrune: options.noPrune
4919
+ });
4539
4920
  if (options.target) {
4921
+ const targetPrevManifest = targetManifestForCtx;
4540
4922
  const targetResult = await integrateGeneratedFiles({
4541
4923
  files: withHeaders,
4542
4924
  language: emitter.language,
@@ -4546,6 +4928,15 @@ async function generate(spec, emitter, options) {
4546
4928
  if (targetResult.written.length > 0) console.log(`Target: created ${targetResult.written.length} new files`);
4547
4929
  if (targetResult.merged.length > 0) console.log(`Target: merged into ${targetResult.merged.length} existing files (additive only)`);
4548
4930
  if (targetResult.skipped.length > 0) console.log(`Target: skipped ${targetResult.skipped.length} files (excluded or no grammar)`);
4931
+ await applyManifestPrune({
4932
+ dir: options.target,
4933
+ label: "Target",
4934
+ prevManifest: targetPrevManifest,
4935
+ currentPaths: targetResult.emittedPaths,
4936
+ language: emitter.language,
4937
+ header,
4938
+ noPrune: options.noPrune
4939
+ });
4549
4940
  const allTargetFiles = [
4550
4941
  ...targetResult.written,
4551
4942
  ...targetResult.merged,
@@ -4555,6 +4946,40 @@ async function generate(spec, emitter, options) {
4555
4946
  }
4556
4947
  return withHeaders;
4557
4948
  }
4949
+ /**
4950
+ * Prune files recorded in the previous manifest but absent from the current emission,
4951
+ * then write the fresh manifest. Safe on first adoption (no previous manifest).
4952
+ */
4953
+ async function applyManifestPrune(opts) {
4954
+ if (opts.noPrune) {
4955
+ await writeManifest(opts.dir, {
4956
+ language: opts.language,
4957
+ files: opts.currentPaths
4958
+ });
4959
+ return;
4960
+ }
4961
+ if (!opts.prevManifest) {
4962
+ console.log(`${opts.label}: no ${MANIFEST_FILENAME} found — skipping prune (this baseline manifest will be written now).`);
4963
+ await writeManifest(opts.dir, {
4964
+ language: opts.language,
4965
+ files: opts.currentPaths
4966
+ });
4967
+ return;
4968
+ }
4969
+ const stale = computeStalePaths(opts.prevManifest, opts.currentPaths);
4970
+ if (stale.length > 0) {
4971
+ const { pruned, preserved } = await pruneStaleFiles(opts.dir, stale, { header: opts.header });
4972
+ if (pruned.length > 0) console.log(`${opts.label}: pruned ${pruned.length} stale file${pruned.length === 1 ? "" : "s"}`);
4973
+ if (preserved.length > 0) {
4974
+ console.log(`${opts.label}: preserved ${preserved.length} file${preserved.length === 1 ? "" : "s"} in previous manifest but lacking the auto-generated header (possibly hand-edited).`);
4975
+ for (const p of preserved) console.log(` ${p}`);
4976
+ }
4977
+ }
4978
+ await writeManifest(opts.dir, {
4979
+ language: opts.language,
4980
+ files: opts.currentPaths
4981
+ });
4982
+ }
4558
4983
  //#endregion
4559
4984
  //#region src/engine/registry.ts
4560
4985
  const emitters = /* @__PURE__ */ new Map();
@@ -5239,7 +5664,8 @@ async function generateCommand(opts) {
5239
5664
  apiSurface,
5240
5665
  overlayLookup,
5241
5666
  operationHints: opts.operationHints,
5242
- mountRules: opts.mountRules
5667
+ mountRules: opts.mountRules,
5668
+ noPrune: opts.prune === false
5243
5669
  });
5244
5670
  if (opts.dryRun) for (const f of files) console.log(f.path);
5245
5671
  else {
@@ -6755,10 +7181,6 @@ export const ${`${toCamelCase(lang)}Emitter`}: Emitter = {
6755
7181
  return [];
6756
7182
  },
6757
7183
 
6758
- generateConfig(_ctx: EmitterContext): GeneratedFile[] {
6759
- return [];
6760
- },
6761
-
6762
7184
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
6763
7185
  return [];
6764
7186
  },
@@ -6959,7 +7381,7 @@ program.command("parse").description("Parse an OpenAPI spec and output IR as JSO
6959
7381
  }
6960
7382
  return parseCommand(opts);
6961
7383
  });
6962
- program.command("generate").description("Run a registered emitter against an OpenAPI spec").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Output directory").option("--target <dir>", "Target directory for live SDK integration (merged output)").option("--namespace <name>", "SDK namespace/package name").option("--dry-run", "Preview files without writing").option("--api-surface <path>", "Path to baseline API surface JSON for compat overlay").option("--manifest <path>", "Path to smoke-manifest.json for method overlay").option("--no-compat-check", "Skip compat overlay even if --api-surface is provided").action((opts) => {
7384
+ program.command("generate").description("Run a registered emitter against an OpenAPI spec").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Output directory").option("--target <dir>", "Target directory for live SDK integration (merged output)").option("--namespace <name>", "SDK namespace/package name").option("--dry-run", "Preview files without writing").option("--api-surface <path>", "Path to baseline API surface JSON for compat overlay").option("--manifest <path>", "Path to smoke-manifest.json for method overlay").option("--no-compat-check", "Skip compat overlay even if --api-surface is provided").option("--no-prune", "Skip deletion of stale files recorded in the previous .oagen-manifest.json").action((opts) => {
6963
7385
  opts.spec ??= process.env.OPENAPI_SPEC_PATH;
6964
7386
  if (!opts.spec) {
6965
7387
  console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");