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
3 changes: 3 additions & 0 deletions src/cli/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export async function generateCommand(opts: {
apiSurface?: string;
manifest?: string;
compatCheck?: boolean;
/** From `--no-prune`. When false, manifest-driven stale-file pruning is skipped. */
prune?: boolean;
operationIdTransform?: (id: string) => string;
schemaNameTransform?: (name: string) => string;
docUrl?: string;
Expand Down Expand Up @@ -55,6 +57,7 @@ export async function generateCommand(opts: {
overlayLookup,
operationHints: opts.operationHints,
mountRules: opts.mountRules,
noPrune: opts.prune === false,
});

if (opts.dryRun) {
Expand Down
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ program
.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) => {
opts.spec ??= process.env.OPENAPI_SPEC_PATH;
if (!opts.spec) {
Expand Down
20 changes: 20 additions & 0 deletions src/compat/extractors/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,31 @@ function extractClass(sym: ts.Symbol, checker: ts.TypeChecker): ApiClass {
const methods: Record<string, ApiMethod[]> = {};
const properties: Record<string, ApiProperty> = {};
const constructorParams: ApiParam[] = [];
let deprecationMessage: string | undefined;

// Extract constructor params
const declarations = sym.getDeclarations();
if (declarations) {
for (const decl of declarations) {
if (ts.isClassDeclaration(decl)) {
// Look for `@deprecated` in the class's JSDoc. JSDoc nodes are
// attached to the declaration's parent range; TS exposes them via
// ts.getJSDocDeprecatedTag.
const deprecatedTag = ts.getJSDocDeprecatedTag(decl);
if (deprecatedTag) {
const comment = deprecatedTag.comment;
if (typeof comment === 'string') {
deprecationMessage = comment.trim() || '';
} else if (Array.isArray(comment)) {
deprecationMessage =
comment
.map((c) => c.text)
.join('')
.trim() || '';
} else {
deprecationMessage = '';
}
}
for (const member of decl.members) {
if (ts.isConstructorDeclaration(member)) {
for (const param of member.parameters) {
Expand Down Expand Up @@ -280,6 +299,7 @@ function extractClass(sym: ts.Symbol, checker: ts.TypeChecker): ApiClass {
methods: sortRecord(methods),
properties: sortRecord(properties),
constructorParams,
...(deprecationMessage !== undefined && { deprecationMessage }),
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/compat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface ApiClass {
methods: Record<string, ApiMethod[]>;
properties: Record<string, ApiProperty>;
constructorParams: ApiParam[];
/** Present when the class JSDoc carries `@deprecated`. Emitters use this
* to propagate deprecation to the service property on the client class
* so IDEs surface the strikethrough at the access site, not just when
* users instantiate the class directly. */
deprecationMessage?: string;
}

export interface ApiMethod {
Expand Down
6 changes: 6 additions & 0 deletions src/engine/generate-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function buildEmitterContext(
overlayLookup?: OverlayLookup;
operationHints?: Record<string, OperationHint>;
mountRules?: Record<string, string>;
target?: string;
priorTargetManifestPaths?: Set<string>;
},
): EmitterContext {
return {
Expand All @@ -25,6 +27,8 @@ export function buildEmitterContext(
apiSurface: options.apiSurface,
overlayLookup: options.overlayLookup,
resolvedOperations: resolveOperations(spec, options.operationHints, options.mountRules),
targetDir: options.target,
priorTargetManifestPaths: options.priorTargetManifestPaths,
};
}

Expand Down Expand Up @@ -145,6 +149,8 @@ export function generateFiles(
overlayLookup?: OverlayLookup;
operationHints?: Record<string, OperationHint>;
mountRules?: Record<string, string>;
target?: string;
priorTargetManifestPaths?: Set<string>;
},
): { files: GeneratedFile[]; ctx: EmitterContext; header: string } {
const ctx = buildEmitterContext(spec, options);
Expand Down
14 changes: 10 additions & 4 deletions src/engine/integrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ function resolveImportPath(
function isRootFile(filePath: string): boolean {
// Fixtures are never roots (and should be filtered by integrateTarget already)
if (filePath.includes('/fixtures/')) return false;
// Test files
if (filePath.endsWith('.spec.ts') || filePath.endsWith('.test.ts')) return false;
// Test files are roots — they are standalone entry points that belong in the
// target repo even though nothing imports them.
if (filePath.endsWith('.spec.ts') || filePath.endsWith('.test.ts')) return true;
// Serializer files
if (filePath.includes('/serializers/')) return false;
// Standalone interface files (not barrel/index)
Expand Down Expand Up @@ -208,12 +209,17 @@ async function resolveFileToDirectoryConflicts(
return { files: result, removals };
}

export interface IntegrateResult extends WriteResult {
/** Final set of paths this run claims ownership of in the target (post tree-shake + conflict resolution). */
emittedPaths: string[];
}

export async function integrateGeneratedFiles(opts: {
files: GeneratedFile[];
language: string;
targetDir: string;
header: string;
}): Promise<WriteResult> {
}): Promise<IntegrateResult> {
const mapped = mapFilesForTargetIntegration(opts.files, opts.language);
const shaken = await treeShakeFiles(mapped, opts.language);

Expand All @@ -234,5 +240,5 @@ export async function integrateGeneratedFiles(opts: {
}
}

return result;
return { ...result, emittedPaths: resolved.map((f) => f.path) };
}
167 changes: 167 additions & 0 deletions src/engine/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';

/**
* Prune-manifest support for `oagen generate`.
*
* The manifest records every auto-generated file emitted on the previous run,
* relative to the directory it lives in (either the `--output` standalone dir
* or the `--target` integration dir). On the next run we diff the previous
* list against the current emission set and delete anything that's no longer
* produced — preventing stale-file accumulation across regens.
*
* Design notes:
* - The manifest is versioned so future format changes can be handled safely.
* - Paths are stored sorted for deterministic diffs in git.
* - Deletion is gated on a header guard when one is available: we only remove
* files whose contents start with the auto-generated header. This protects
* a hand-maintained file that happens to share a name with a previously
* generated one (extremely rare, but worth catching).
* - On first adoption (no previous manifest), pruning is skipped and a
* one-line notice is printed — no surprise deletions.
*/

export const MANIFEST_FILENAME = '.oagen-manifest.json';
const MANIFEST_VERSION = 1;

export interface Manifest {
/** Schema version. Bump when the format changes incompatibly. */
version: number;
/** Emitter language (e.g. "python"). Used only as a consistency hint. */
language: string;
/** ISO-8601 timestamp of the run that produced this manifest. */
generatedAt: string;
/** Sorted list of paths, relative to the manifest's containing directory. */
files: string[];
}

export interface PruneResult {
/** Paths actually deleted. */
pruned: string[];
/** Paths skipped because the header guard didn't match (preserved). */
preserved: string[];
/** Paths already absent on disk (nothing to do). */
missing: string[];
}

/** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
export async function readManifest(dir: string): Promise<Manifest | null> {
const manifestPath = path.join(dir, MANIFEST_FILENAME);
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf-8');
} catch {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<Manifest>;
if (
typeof parsed.version !== 'number' ||
typeof parsed.language !== 'string' ||
typeof parsed.generatedAt !== 'string' ||
!Array.isArray(parsed.files) ||
!parsed.files.every((p): p is string => typeof p === 'string')
) {
return null;
}
if (parsed.version > MANIFEST_VERSION) {
console.warn(
`[oagen] ${MANIFEST_FILENAME} schema version ${parsed.version} is newer than supported (${MANIFEST_VERSION}); ignoring for pruning.`,
);
return null;
}
return parsed as Manifest;
} catch {
return null;
}
}

/** Write `.oagen-manifest.json` to a directory with sorted paths. */
export async function writeManifest(dir: string, opts: { language: string; files: Iterable<string> }): Promise<void> {
const manifest: Manifest = {
version: MANIFEST_VERSION,
language: opts.language,
generatedAt: new Date().toISOString(),
files: [...new Set(opts.files)].sort(),
};
const manifestPath = path.join(dir, MANIFEST_FILENAME);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
}

/** Return the set of previous paths no longer present in the current emission. */
export function computeStalePaths(prev: Manifest, currentPaths: Iterable<string>): string[] {
const current = new Set(currentPaths);
return prev.files.filter((p) => !current.has(p)).sort();
}

/**
* Delete stale files from `dir`.
*
* When `header` is provided, a file is only deleted if its contents start with
* that header — so hand-maintained files that somehow collide with a previously
* generated path can't be clobbered. When `header` is omitted, the guard is
* skipped (useful for non-source artifacts like fixture JSON).
*
* Returns lists for reporting. Empty parent directories are removed after
* deletion, up to (but not including) `dir` itself.
*/
export async function pruneStaleFiles(
dir: string,
paths: string[],
opts: { header?: string } = {},
): Promise<PruneResult> {
const pruned: string[] = [];
const preserved: string[] = [];
const missing: string[] = [];

for (const relPath of paths) {
const fullPath = path.join(dir, relPath);
let content: string;
try {
content = await fs.readFile(fullPath, 'utf-8');
} catch {
missing.push(relPath);
continue;
}
if (opts.header && !content.startsWith(opts.header)) {
preserved.push(relPath);
continue;
}
try {
await fs.unlink(fullPath);
pruned.push(relPath);
} catch {
// Racing deletion or permission issue — treat as missing rather than fatal.
missing.push(relPath);
continue;
}
await removeEmptyParents(path.dirname(fullPath), dir);
}

return { pruned, preserved, missing };
}

/**
* Walk up from `startDir` removing empty directories until we hit `stopDir`
* (exclusive) or a non-empty directory. Silently ignores errors.
*/
async function removeEmptyParents(startDir: string, stopDir: string): Promise<void> {
let current = path.resolve(startDir);
const stop = path.resolve(stopDir);
while (current.startsWith(stop + path.sep) && current !== stop) {
let entries: string[];
try {
entries = await fs.readdir(current);
} catch {
return;
}
if (entries.length > 0) return;
try {
await fs.rmdir(current);
} catch {
return;
}
current = path.dirname(current);
}
}
Loading
Loading