← 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,
},
}
}