Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ const myEmitter: Emitter = {
/* ... */
],
generateErrors: () => [],
generateConfig: () => [],
generateTests: () => [],
fileHeader: () => "// Auto-generated by oagen. Do not edit.",
};
Expand Down
2 changes: 1 addition & 1 deletion docs/agents/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Three stages transform an OpenAPI spec into SDK files:

1. **Parse** (`src/parser/parse.ts`) — Load and bundle the spec via `@redocly/openapi-core`, extract `Model[]`, `Enum[]`, and `Service[]` into the IR (`ApiSpec`). Sub-modules handle ref resolution (`refs.ts`), schema walking (`schemas.ts`), operation grouping (`operations.ts`), pagination detection (`pagination.ts`), and inline model extraction (`inline-models.ts`).

2. **Emit** (`src/engine/orchestrator.ts`) — Call each emitter method in order (`generateModels` → `generateEnums` → `generateResources` → `generateClient` → `generateErrors` → `generateConfig` → `generateTypeSignatures` → `generateTests` → `generateManifest`), collect `GeneratedFile[]`, prepend file headers.
2. **Emit** (`src/engine/orchestrator.ts`) — Call each emitter method in order (`generateModels` → `generateEnums` → `generateResources` → `generateClient` → `generateErrors` → `generateTypeSignatures` → `generateTests` → `generateManifest`), collect `GeneratedFile[]`, prepend file headers.

3. **Write** (`src/engine/writer.ts`) — Write files to disk. New files are created in full. Existing files are merged at the AST level via `merger.ts` (additive-only — new symbols appended, existing symbols untouched). Files marked `skipIfExists` are never overwritten.

Expand Down
3 changes: 1 addition & 2 deletions docs/architecture/emitter-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ interface Emitter {
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[];
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
generateErrors(ctx: EmitterContext): GeneratedFile[];
generateConfig(ctx: EmitterContext): GeneratedFile[];
generateTypeSignatures?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
generateManifest?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
Expand Down Expand Up @@ -100,7 +99,7 @@ The `httpKeyByMethod` reverse map is only populated when a manifest (`smoke-mani

5. **Inapplicable methods return `[]`** — If a language doesn't need type signature files, `generateTypeSignatures` returns `[]`. Optional methods like `generateManifest` can simply be omitted.

6. **Composable generators** — Interface methods can compose multiple internal generators. Example: Node's `generateConfig` returns config files plus shared utility files.
6. **Composable generators** — Interface methods can compose multiple internal generators when a language needs them.

## New IR Variants for Emitters

Expand Down
1 change: 0 additions & 1 deletion docs/architecture/pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ generateEnums(enums, ctx); // Enum types
generateResources(services, ctx); // API resource classes
generateClient(spec, ctx); // HTTP client
generateErrors(ctx); // Error hierarchy
generateConfig(ctx); // Configuration
generateTypeSignatures(spec, ctx); // Type annotations (optional)
generateTests(spec, ctx); // Tests + fixtures
generateManifest?.(spec, ctx); // Smoke-test manifest (optional)
Expand Down
4 changes: 0 additions & 4 deletions docs/core/minimal-emitter.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ export const demoEmitter: Emitter = {
return [];
},

generateConfig(): GeneratedFile[] {
return [];
},

generateTypeSignatures(): GeneratedFile[] {
return [];
},
Expand Down
1 change: 0 additions & 1 deletion docs/core/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ export const demoEmitter: Emitter = {
},
],
generateErrors: () => [],
generateConfig: () => [],
generateTypeSignatures: () => [],
generateTests: () => [],
fileHeader: () => "# Generated by oagen",
Expand Down
1 change: 0 additions & 1 deletion examples/reference-emitter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ A minimal but working TypeScript emitter for oagen, demonstrating the Emitter in
- `generateResources` — resource classes using `planOperation`
- `generateClient` — top-level client with resource accessors
- `generateErrors` — error class hierarchy
- `generateConfig` — config types and base resource class
- Type mapping via `mapTypeRef`

## What it does NOT cover
Expand Down
5 changes: 0 additions & 5 deletions examples/reference-emitter/src/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { generateEnums } from './generators/enums.js';
import { generateResources } from './generators/resources.js';
import { generateClient } from './generators/client.js';
import { generateErrors } from './generators/errors.js';
import { generateConfig } from './generators/config.js';
import { generateTests } from './generators/tests.js';

/**
Expand Down Expand Up @@ -38,10 +37,6 @@ export const typescriptEmitter: Emitter = {
return generateErrors(ctx);
},

generateConfig(ctx: EmitterContext): GeneratedFile[] {
return generateConfig(ctx);
},

generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
return generateTests(spec, ctx);
},
Expand Down
19 changes: 0 additions & 19 deletions examples/reference-emitter/src/generators/config.ts

This file was deleted.

4 changes: 0 additions & 4 deletions src/cli/templates/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,6 @@ export const ${varName}: Emitter = {
return [];
},

generateConfig(_ctx: EmitterContext): GeneratedFile[] {
return [];
},

generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
return [];
},
Expand Down
1 change: 0 additions & 1 deletion src/engine/generate-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ export function generateAllFiles(spec: ApiSpec, emitter: Emitter, ctx: EmitterCo
...emitter.generateResources(spec.services, ctx),
...emitter.generateClient(spec, ctx),
...emitter.generateErrors(ctx),
...emitter.generateConfig(ctx),
...(emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? []),
...emitter.generateTests(reachableSpec, ctx),
...(emitter.generateManifest?.(spec, ctx) ?? []),
Expand Down
161 changes: 134 additions & 27 deletions src/engine/merger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ const parserCache = new Map<string, Parser>();

import { safeParse } from '../utils/tree-sitter.js';

// --- @deprecated preservation ---

/**
* When replacing an existing docstring with a generated one, carry over any
* `@deprecated` lines from the existing doc that aren't present in the
* generated doc. This prevents hand-written deprecation notices from being
* silently dropped during docstring refresh.
*/
function preserveDeprecatedTags(existingDoc: string, generatedDoc: string): string {
// Check if the existing docstring contains @deprecated anywhere
if (!existingDoc.includes('@deprecated')) return generatedDoc;

// If generated doc already has @deprecated, no need to merge
if (generatedDoc.includes('@deprecated')) return generatedDoc;

// Extract the @deprecated content from the existing doc.
// Handle both multi-line (`* @deprecated ...`) and one-liner (`/** @deprecated ... */`) formats.
const deprecatedParts: string[] = [];
for (const line of existingDoc.split('\n')) {
if (/@deprecated\b/.test(line)) {
// Normalize to JSDoc member format: ` * @deprecated ...`
const match = line.match(/@deprecated\b.*/);
if (match) {
// Strip trailing */ and whitespace (from one-liner JSDoc like `/** @deprecated ... */`)
const cleaned = match[0].replace(/\s*\*\/\s*$/, '');
deprecatedParts.push(` * ${cleaned}`);
}
}
}
if (deprecatedParts.length === 0) return generatedDoc;

// Insert @deprecated lines before the closing */ of the generated doc
const closingIdx = generatedDoc.lastIndexOf('*/');
if (closingIdx === -1) return generatedDoc;
const before = generatedDoc.slice(0, closingIdx);
const after = generatedDoc.slice(closingIdx);
return before + deprecatedParts.join('\n') + '\n ' + after;
}

// --- @oagen-ignore region helpers ---

interface IgnoredRegion {
Expand Down Expand Up @@ -257,12 +296,27 @@ export async function mergeIntoExisting(
lastImportEndIndex = linesBefore + stmtLines - 1;
}

// Track all names exported by existing statements (both direct exports and
// re-export aliases like `export { X as Y }`). Used to prevent the merger
// from appending a re-export that introduces a duplicate exported name.
const existingExportedNames = new Set<string>();

for (const stmt of existingStatements.statements) {
if (stmt.kind === 'reexport') {
existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim());
// Collect aliased export names from re-exports
const braceMatch = stmt.text.match(/\{([^}]+)\}/);
if (braceMatch) {
for (const part of braceMatch[1].split(',')) {
const segments = part.trim().split(/\s+as\s+/);
const exportedName = (segments[1] ?? segments[0]).trim();
if (exportedName) existingExportedNames.add(exportedName);
}
}
}
if (stmt.key) {
existingKeys.add(stmt.key);
existingExportedNames.add(stmt.key);
} else {
existingUnnamedTexts.add(stmt.text.trim());
}
Expand Down Expand Up @@ -354,6 +408,24 @@ export async function mergeIntoExisting(
preserved++;
continue;
}
// Also skip re-exports whose aliased names are already defined as
// top-level exports in the existing file (e.g., generated file has
// `export { X as deserializeFoo } from '...'` but existing file
// already has `export const deserializeFoo = ...`).
const braceMatch = stmt.text.match(/\{([^}]+)\}/);
if (braceMatch) {
const exportedNames = braceMatch[1]
.split(',')
.map((n) => {
const parts = n.trim().split(/\s+as\s+/);
return (parts[1] ?? parts[0]).trim();
})
.filter(Boolean);
if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) {
preserved++;
continue;
}
}
}

if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) {
Expand Down Expand Up @@ -526,10 +598,11 @@ export async function mergeIntoExisting(
if (existInfo.docstring) {
const isPreserved = existInfo.docstring.text.includes('@oagen-ignore');
if (!isPreserved && existInfo.docstring.text !== genDoc.text) {
const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text);
edits.push({
start: existInfo.docstring.startIndex,
end: existInfo.docstring.endIndex,
newText: genDoc.text,
newText,
});
docstringUpdates++;
}
Expand All @@ -549,16 +622,18 @@ export async function mergeIntoExisting(
const matchedExistMembers = new Set<string>();
for (const [memberName, genMember] of genInfo.members) {
const existMember = existInfo.members.get(memberName);
if (!existMember || !genMember.docstring) continue;
if (!existMember) continue;
matchedExistMembers.add(memberName);
if (!genMember.docstring) continue;

if (existMember.docstring) {
const isPreserved = existMember.docstring.text.includes('@oagen-ignore');
if (!isPreserved && existMember.docstring.text !== genMember.docstring.text) {
const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
edits.push({
start: existMember.docstring.startIndex,
end: existMember.docstring.endIndex,
newText: genMember.docstring.text,
newText,
});
docstringUpdates++;
}
Expand All @@ -578,36 +653,68 @@ export async function mergeIntoExisting(
// Match generated members to existing members by URL pattern when
// name-based matching fails (e.g., generated "find" vs existing "getOrganization"
// both call this.workos.get('/organizations/${id}')).
for (const [_genName, genMember] of genInfo.members) {
//
// Safety: only attempt fingerprint matching when there is exactly ONE
// unmatched generated member and ONE unmatched existing member for a
// given fingerprint. When multiple members share the same URL path
// (e.g., POST and GET on /authorization/roles), fingerprint matching
// is ambiguous and would swap docstrings between methods.
const unmatchedGenByFp = new Map<
string,
{ name: string; member: typeof genInfo.members extends Map<string, infer V> ? V : never }[]
>();
for (const [genName, genMember] of genInfo.members) {
if (matchedExistMembers.has(genName)) continue; // name-matched in pass 1 (as an exist member) — skip
if (!genMember.docstring || !genMember.urlFingerprint) continue;
// Find unmatched existing member with the same URL fingerprint
for (const [existName, existMember] of existInfo.members) {
if (matchedExistMembers.has(existName)) continue;
if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue;
if (existMember.docstring) {
const isPreserved = existMember.docstring.text.includes('@oagen-ignore');
if (isPreserved) continue;
if (existMember.docstring.text !== genMember.docstring.text) {
edits.push({
start: existMember.docstring.startIndex,
end: existMember.docstring.endIndex,
newText: genMember.docstring.text,
});
docstringUpdates++;
}
} else {
const lineStart = existMember.declStartIndex - existMember.declColumn;
const indent = ' '.repeat(existMember.declColumn);
// Skip if this generated member was already name-matched
if (existInfo.members.has(genName)) continue;
const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? [];
bucket.push({ name: genName, member: genMember });
unmatchedGenByFp.set(genMember.urlFingerprint, bucket);
}
const unmatchedExistByFp = new Map<
string,
{ name: string; member: typeof existInfo.members extends Map<string, infer V> ? V : never }[]
>();
for (const [existName, existMember] of existInfo.members) {
if (matchedExistMembers.has(existName)) continue;
if (!existMember.urlFingerprint) continue;
const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? [];
bucket.push({ name: existName, member: existMember });
unmatchedExistByFp.set(existMember.urlFingerprint, bucket);
}
for (const [fp, genBucket] of unmatchedGenByFp) {
if (genBucket.length !== 1) continue; // ambiguous — skip
const existBucket = unmatchedExistByFp.get(fp);
if (!existBucket || existBucket.length !== 1) continue; // ambiguous — skip
const genMember = genBucket[0].member;
const existEntry = existBucket[0];
const existMember = existEntry.member;
if (!genMember.docstring) continue;

if (existMember.docstring) {
const isPreserved = existMember.docstring.text.includes('@oagen-ignore');
if (isPreserved) continue;
if (existMember.docstring.text !== genMember.docstring.text) {
const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text);
edits.push({
start: lineStart,
end: lineStart,
newText: indent + genMember.docstring.text + '\n',
start: existMember.docstring.startIndex,
end: existMember.docstring.endIndex,
newText,
});
docstringUpdates++;
}
matchedExistMembers.add(existName);
break; // Only match one existing member per generated member
} else {
const lineStart = existMember.declStartIndex - existMember.declColumn;
const indent = ' '.repeat(existMember.declColumn);
edits.push({
start: lineStart,
end: lineStart,
newText: indent + genMember.docstring.text + '\n',
});
docstringUpdates++;
}
matchedExistMembers.add(existEntry.name);
}
}

Expand Down
2 changes: 0 additions & 2 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ export interface Emitter {

generateErrors(ctx: EmitterContext): GeneratedFile[];

generateConfig(ctx: EmitterContext): GeneratedFile[];

generateTypeSignatures?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];

generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
Expand Down
17 changes: 15 additions & 2 deletions src/engine/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,13 +390,26 @@ function spliceExtraImports(existingLines: string[], resultLines: string[]): voi
const existingFrom = collectFromImports(existingLines);
const generatedFrom = collectFromImports(resultLines);

// Build a set of all identifiers imported by the generated file (across all modules)
// so we can detect when an existing barrel import's identifiers are covered by
// more-specific generated imports (e.g., `from x.y import Z` covers `from x import Z`).
const allGeneratedIds = new Set<string>();
for (const ids of generatedFrom.values()) {
for (const id of ids) allGeneratedIds.add(id);
}

const extraFromLines: string[] = [];
for (const [mod, existIds] of existingFrom) {
const genIds = generatedFrom.get(mod);
if (!genIds) {
extraFromLines.push(`from ${mod} import ${[...existIds].join(', ')}`);
// The exact module isn't in the generated file — but check whether every
// identifier is already imported from a different (e.g., more-specific) module.
const extra = [...existIds].filter((id) => !allGeneratedIds.has(id));
if (extra.length > 0) {
extraFromLines.push(`from ${mod} import ${extra.join(', ')}`);
}
} else {
const extra = [...existIds].filter((id) => !genIds.has(id));
const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id));
if (extra.length > 0) {
extraFromLines.push(`from ${mod} import ${extra.join(', ')}`);
}
Expand Down
Loading
Loading