← 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 · TypeScriptimport { 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)
}
}