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/step.ts

114 lines · 3.0 KB · TypeScript
import { listCandidates, resolveLimit } from './candidates.ts'
import { validateCodemodRef, type CodemodRef } from './contract.ts'
import {
	createRun,
	resumeRun,
	saveRun,
	summarizeItems,
	type ItemStatus,
	type RunItem,
	type RunMode,
} from './ledger.ts'
import { shouldStopBeforeNextPackage } from './time-budget.ts'

export type StepInput = {
	codemod?: CodemodRef
	packageIds?: Array<string>
	runId?: string
	cursor?: string | null
	limit?: number
	/**
	 * Optional JS regex. When set, scan treats zero repo_search hits as
	 * clean and only reads matching files before detect. Use only when
	 * detect findings require a match — absence-based detects must omit it.
	 */
	detectPattern?: string
}

export type StepResult = {
	runId: string
	mode: RunMode
	items: Array<RunItem>
	nextCursor: string | null
	summary: Partial<Record<ItemStatus, number>>
	note: string | null
}

export function requireCodemod(input: StepInput): CodemodRef {
	return validateCodemodRef(input.codemod)
}

export async function runStep(input: {
	mode: 'scan' | 'dry-run' | 'apply'
	step: StepInput
	defaults: { limit: number; max: number }
	processPackage: (
		run: { id: string },
		pkg: { packageId: string; kodyId: string },
	) => Promise<RunItem>
}): Promise<StepResult> {
	const codemod = requireCodemod(input.step)
	if (input.step.cursor != null && input.step.runId == null) {
		throw new Error('cursor requires runId to continue an existing run.')
	}
	const run = input.step.runId
		? await resumeRun({
				runId: input.step.runId,
				mode: input.mode,
				codemod,
			})
		: await createRun({ mode: input.mode, codemod })

	const limit = resolveLimit({
		requested: input.step.limit,
		fallback: input.defaults.limit,
		max: input.defaults.max,
	})
	const { candidates, nextCursor } = await listCandidates({
		codemod,
		packageIds: input.step.packageIds,
		paging: { cursor: input.step.cursor ?? run.cursor, limit },
	})

	const startedAt = Date.now()
	const items: Array<RunItem> = []
	let stoppedEarlyAt: string | null = null
	let lastPackageMs = 0
	for (const pkg of candidates) {
		if (
			shouldStopBeforeNextPackage({
				elapsedMs: Date.now() - startedAt,
				completedCount: items.length,
				lastPackageMs,
			})
		) {
			stoppedEarlyAt = items[items.length - 1]!.packageId
			break
		}
		const packageStarted = Date.now()
		const item = await input.processPackage({ id: run.id }, pkg)
		lastPackageMs = Date.now() - packageStarted
		items.push(item)
		run.items = [...run.items, item]
		run.cursor = pkg.packageId
		run.status = 'running'
		await saveRun(run)
	}

	const resolvedNextCursor = stoppedEarlyAt ?? nextCursor
	run.cursor = resolvedNextCursor
	run.status = resolvedNextCursor == null ? 'completed' : 'running'
	await saveRun(run)

	return {
		runId: run.id,
		mode: input.mode,
		items,
		nextCursor: resolvedNextCursor,
		summary: summarizeItems(items),
		note:
			resolvedNextCursor == null
				? null
				: `Run is not finished. Call again with runId "${run.id}" and cursor "${resolvedNextCursor}".`,
	}
}