Skip to content
← Public packages

@kentcdodds/codemod-runner

Run codemods over your own saved packages: scan, dry-run with real publish checks and diffs, apply, and revert.

src/process-package.ts

307 lines · 8.7 KB · TypeScript
import { packages } from 'kody:runtime'
import { type CandidatePackage } from './candidates.ts'
import {
	validateFindings,
	validateTransformResult,
	type CodemodRef,
	type CodemodTransformResult,
} from './contract.ts'
import { dispatchCodemodEvent } from './events.ts'
import { boundDiff, boundError, saveSnapshot, type RunItem } from './ledger.ts'
import { selectScanPaths } from './scan-paths.ts'
import {
	deleteSessionFiles,
	discardSession,
	listSessionFiles,
	openFreshSession,
	publishSession,
	readSessionFiles,
	runSessionChecks,
	searchSessionFiles,
	writeSessionFiles,
} from './sessions.ts'

async function invokeCodemod(input: {
	codemod: CodemodRef
	operation: 'detect' | 'transform'
	files: Record<string, string>
}): Promise<unknown> {
	return await packages.invoke(((__kodyCodemod0007Value: unknown): `kody:@${string}/${string}` => { /* kody-codemod-0007 */ const __kodyCodemod0007Trimmed = typeof __kodyCodemod0007Value === 'string' ? __kodyCodemod0007Value.trim() : ''; return (typeof __kodyCodemod0007Value === 'string' && __kodyCodemod0007Trimmed.startsWith('@') ? `kody:${__kodyCodemod0007Trimmed}` : __kodyCodemod0007Value) as `kody:@${string}/${string}` })((input.codemod.specifier)), {
		exportName: input.codemod.exportName ?? 'codemod',
		params: { operation: input.operation, files: input.files },
	})
}

function baseItem(pkg: CandidatePackage): RunItem {
	return {
		packageId: pkg.packageId,
		kodyId: pkg.kodyId,
		status: 'failed',
		changedPaths: [],
		findings: [],
		failedChecks: [],
		beforeCommit: null,
		afterCommit: null,
		diff: null,
		error: null,
	}
}

/** Runs `detect` against one package's published tree in a throwaway session. */
export async function scanPackage(input: {
	pkg: CandidatePackage
	codemod: CodemodRef
	detectPattern?: string
}): Promise<RunItem> {
	const item = baseItem(input.pkg)
	let sessionId: string | null = null
	try {
		const session = await openFreshSession({
			packageId: input.pkg.packageId,
		})
		sessionId = session.id
		item.beforeCommit = session.published_commit
		if (!session.published_commit) {
			item.status = 'skipped_unpublished'
			return item
		}
		if (session.base_commit !== session.published_commit) {
			item.status = 'skipped_drift'
			return item
		}
		const listed = input.detectPattern
			? await searchSessionFiles(session.id, input.detectPattern)
			: await listSessionFiles(session.id)
		const paths = selectScanPaths(listed)
		if (paths.length === 0) {
			item.status = 'clean'
			return item
		}
		const files = await readSessionFiles(session.id, paths)
		const findings = validateFindings(
			await invokeCodemod({
				codemod: input.codemod,
				operation: 'detect',
				files,
			}),
		)
		item.findings = findings
		item.status = findings.length > 0 ? 'detected' : 'clean'
		return item
	} catch (error) {
		item.status = 'failed'
		item.error = boundError(
			error instanceof Error ? error.message : String(error),
		)
		return item
	} finally {
		if (sessionId) await discardSession(sessionId)
	}
}

/**
 * Runs `transform` for one package in a throwaway session: verifies
 * idempotency, writes the transformed tree, runs the real publish checks,
 * and either discards (dry-run) or publishes (apply). Apply snapshots the
 * original tree into the run ledger first so it can be reverted.
 */
export async function transformPackage(input: {
	mode: 'dry-run' | 'apply'
	runId: string
	pkg: CandidatePackage
	codemod: CodemodRef
}): Promise<RunItem> {
	const item = baseItem(input.pkg)
	let sessionId: string | null = null
	let published = false
	try {
		const session = await openFreshSession({
			packageId: input.pkg.packageId,
		})
		sessionId = session.id
		item.beforeCommit = session.published_commit
		if (!session.published_commit) {
			item.status = 'skipped_unpublished'
			return item
		}
		if (session.base_commit !== session.published_commit) {
			item.status = 'skipped_drift'
			return item
		}
		const paths = await listSessionFiles(session.id)
		const originalFiles = await readSessionFiles(session.id, paths)

		const first: CodemodTransformResult = validateTransformResult(
			await invokeCodemod({
				codemod: input.codemod,
				operation: 'transform',
				files: originalFiles,
			}),
		)
		item.findings = first.needsManual
		if (!first.changed) {
			item.status = first.needsManual.length > 0 ? 'needs_manual' : 'clean'
			return item
		}

		const second = validateTransformResult(
			await invokeCodemod({
				codemod: input.codemod,
				operation: 'transform',
				files: first.files,
			}),
		)
		if (second.changed) {
			item.status = 'failed'
			item.error =
				'Codemod is not idempotent: transforming its own output reported further changes.'
			return item
		}

		const edits: Record<string, string> = {}
		for (const [path, content] of Object.entries(first.files)) {
			if (originalFiles[path] !== content) {
				edits[path] = content
			}
		}
		const deletions = Object.keys(originalFiles).filter(
			(path) => !(path in first.files),
		)
		item.changedPaths = [...Object.keys(edits), ...deletions].sort()

		const written = await writeSessionFiles(session.id, edits)
		await deleteSessionFiles(session.id, deletions)
		item.diff = boundDiff(written.diff)

		const checks = await runSessionChecks(session.id)
		if (!checks.ok) {
			item.status = 'checks_failed'
			item.failedChecks = checks.failed
			return item
		}

		if (input.mode === 'dry-run') {
			item.status = 'dry_run_ok'
			return item
		}

		await saveSnapshot({
			runId: input.runId,
			packageId: input.pkg.packageId,
			beforeCommit: session.published_commit,
			files: originalFiles,
		})
		const publish = await publishSession(session.id)
		if (publish.status !== 'ok') {
			item.status = 'failed'
			item.error = boundError(publish.message)
			return item
		}
		published = true
		item.status = 'applied'
		item.afterCommit = publish.publishedCommit
		await dispatchCodemodEvent({
			kind: 'applied',
			runId: input.runId,
			codemod: {
				kodyId: input.codemod.kodyId,
				specifier: input.codemod.specifier,
				exportName: input.codemod.exportName ?? 'codemod',
			},
			packageId: input.pkg.packageId,
			kodyId: input.pkg.kodyId,
			changedPaths: item.changedPaths,
			beforeCommit: item.beforeCommit,
			afterCommit: item.afterCommit,
		})
		return item
	} catch (error) {
		item.status = 'failed'
		item.error = boundError(
			error instanceof Error ? error.message : String(error),
		)
		return item
	} finally {
		if (sessionId && !published) await discardSession(sessionId)
	}
}

/** Restores one applied item's pre-codemod tree from the ledger snapshot. */
export async function revertItem(input: {
	revertRunId: string
	sourceRunId: string
	sourceCodemod: {
		kodyId: string
		specifier?: string
		exportName: string
	} | null
	item: RunItem
	snapshot: { beforeCommit: string; files: Record<string, string> }
}): Promise<RunItem> {
	const result = baseItem({
		packageId: input.item.packageId,
		kodyId: input.item.kodyId,
	})
	result.beforeCommit = input.item.afterCommit
	let sessionId: string | null = null
	let published = false
	try {
		const session = await openFreshSession({
			packageId: input.item.packageId,
		})
		sessionId = session.id
		if (session.published_commit !== input.item.afterCommit) {
			result.status = 'skipped_drift'
			result.error =
				'Package was republished after the codemod apply; revert would overwrite newer work.'
			return result
		}
		const currentPaths = await listSessionFiles(session.id)
		const written = await writeSessionFiles(session.id, input.snapshot.files)
		const deletions = currentPaths.filter(
			(path) => !(path in input.snapshot.files),
		)
		await deleteSessionFiles(session.id, deletions)
		result.changedPaths = [...Object.keys(input.snapshot.files), ...deletions]
			.filter(
				(path, index, list) =>
					list.indexOf(path) === index &&
					(deletions.includes(path) || written.diff.includes(path)),
			)
			.sort()
		const checks = await runSessionChecks(session.id)
		if (!checks.ok) {
			result.status = 'checks_failed'
			result.failedChecks = checks.failed
			return result
		}
		const publish = await publishSession(session.id)
		if (publish.status !== 'ok') {
			result.status = 'failed'
			result.error = boundError(publish.message)
			return result
		}
		published = true
		result.status = 'reverted'
		result.afterCommit = publish.publishedCommit
		await dispatchCodemodEvent({
			kind: 'reverted',
			runId: input.revertRunId,
			codemod: input.sourceCodemod,
			packageId: input.item.packageId,
			kodyId: input.item.kodyId,
			changedPaths: result.changedPaths,
			beforeCommit: result.beforeCommit,
			afterCommit: result.afterCommit,
		})
		return result
	} catch (error) {
		result.status = 'failed'
		result.error = boundError(
			error instanceof Error ? error.message : String(error),
		)
		return result
	} finally {
		if (sessionId && !published) await discardSession(sessionId)
	}
}