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

180 lines · 5.6 KB · TypeScript
/**
 * The codemod contract shared between the runner and codemod packages.
 *
 * A codemod is a saved package that exposes one export (default name:
 * `codemod`) whose default export is a single function:
 *
 *   export default function codemod(input: CodemodInput): CodemodOutput
 *
 * `detect` answers "does this package need the codemod?" without changing
 * anything. `transform` returns the complete new file tree. Transforms must
 * be pure (same input, same output), deterministic, and idempotent: running
 * `transform` on its own output must report `changed: false`. The runner
 * verifies idempotency mechanically and fails the package when it does not
 * hold. When a file cannot be transformed confidently, leave it untouched
 * and report a `needsManual` finding instead of guessing.
 */

export type CodemodFinding = {
	path: string | null
	message: string
}

export type CodemodTransformResult = {
	files: Record<string, string>
	changed: boolean
	changedPaths: Array<string>
	needsManual: Array<CodemodFinding>
}

export type CodemodInput =
	| { operation: 'detect'; files: Record<string, string> }
	| { operation: 'transform'; files: Record<string, string> }

export type CodemodOutput = Array<CodemodFinding> | CodemodTransformResult

export type CodemodRef = {
	/** Bare kody id of the codemod package (for example `hello-codemod`). */
	kodyId: string
	/** Explicit owner-scoped package specifier (for example `kody:@user/hello-codemod`). */
	specifier: string
	/** Export name inside the codemod package. Defaults to `codemod`. */
	exportName?: string
}

/**
 * Shared codemod contract helpers and docs for runner + codemod packages.
 * Import types from this export when authoring a codemod; call the default for limits/docs.
 *
 * @param value - Codemod ref with `kodyId` + `specifier` (`kody:@owner/kodyId`).
 * @returns The validated `CodemodRef` (throws when incomplete or mismatched).
 *
 * @example
 * import contract from 'kody:@kentcdodds/codemod-runner/contract'
 * const docs = contract()
 * // => { exportName: 'codemod', input: '...', limits: {...} }
 */
export function validateCodemodRef(value: CodemodRef | undefined): CodemodRef {
	const specifierMatch =
		typeof value?.specifier === 'string'
			? /^kody:@[^/\s]+\/([^/\s]+)$/.exec(value.specifier)
			: null
	if (
		!value ||
		typeof value.kodyId !== 'string' ||
		!value.kodyId.trim() ||
		specifierMatch?.[1] !== value.kodyId
	) {
		throw new Error(
			'Pass codemod: { kodyId, specifier: "kody:@owner/kodyId", exportName? } pointing at your codemod package export.',
		)
	}
	return value
}

const maxTransformFiles = 400
const maxTransformTotalBytes = 4 * 1024 * 1024
const maxFindings = 50

function isRecordOfStrings(value: unknown): value is Record<string, string> {
	if (typeof value !== 'object' || value === null || Array.isArray(value)) {
		return false
	}
	return Object.values(value).every((entry) => typeof entry === 'string')
}

export function validateFindings(value: unknown): Array<CodemodFinding> {
	if (!Array.isArray(value)) {
		throw new Error(
			'Codemod detect must return an array of { path, message } findings.',
		)
	}
	return value.slice(0, maxFindings).map((entry) => {
		const record = entry as { path?: unknown; message?: unknown }
		if (typeof record?.message !== 'string') {
			throw new Error('Every codemod finding needs a string `message`.')
		}
		return {
			path: typeof record.path === 'string' ? record.path : null,
			message: record.message,
		}
	})
}

export function validateTransformResult(
	value: unknown,
): CodemodTransformResult {
	const record = value as {
		files?: unknown
		changed?: unknown
		changedPaths?: unknown
		needsManual?: unknown
	}
	if (typeof record !== 'object' || record === null) {
		throw new Error('Codemod transform must return an object.')
	}
	if (!isRecordOfStrings(record.files)) {
		throw new Error(
			'Codemod transform must return `files` as a record of string contents.',
		)
	}
	const paths = Object.keys(record.files)
	if (paths.length > maxTransformFiles) {
		throw new Error(
			`Codemod transform returned ${paths.length} files (max ${maxTransformFiles}).`,
		)
	}
	let totalBytes = 0
	for (const content of Object.values(record.files)) {
		totalBytes += content.length
	}
	if (totalBytes > maxTransformTotalBytes) {
		throw new Error(
			`Codemod transform output exceeds ${maxTransformTotalBytes} bytes.`,
		)
	}
	if (typeof record.changed !== 'boolean') {
		throw new Error('Codemod transform must return a boolean `changed`.')
	}
	if (
		!Array.isArray(record.changedPaths) ||
		!record.changedPaths.every((path) => typeof path === 'string')
	) {
		throw new Error(
			'Codemod transform must return `changedPaths` as an array of strings.',
		)
	}
	return {
		files: record.files,
		changed: record.changed,
		changedPaths: record.changedPaths,
		needsManual: validateFindings(record.needsManual ?? []),
	}
}

/**
 * Loads codemod contract documentation. Import the types from
 * `kody:@kentcdodds/codemod-runner/contract` when authoring a codemod.
 *
 * @returns Contract docs: expected export name, input/output shapes, and size limits.
 *
 * @example
 * import contract from 'kody:@kentcdodds/codemod-runner/contract'
 * const docs = contract()
 * // => { exportName: 'codemod', input: '...', limits: {...} }
 */
export default function contract() {
	return {
		exportName: 'codemod',
		input:
			"{ operation: 'detect' | 'transform', files: Record<string, string> }",
		detectReturns: 'Array<{ path: string | null, message: string }>',
		transformReturns:
			'{ files, changed, changedPaths, needsManual } — full file tree out, pure, deterministic, idempotent',
		limits: {
			maxTransformFiles,
			maxTransformTotalBytes,
			maxFindings,
		},
	}
}