Skip to content

Commit af250cf

Browse files
authored
feat(codemod): scaffold @payloadcms/codemod for v4 migrations (#16385)
# Overview Scaffolds a new `@payloadcms/codemod` package at `packages/codemod/`. It provides a CLI that will host codemods for migrating user projects from Payload v3 to v4. This PR ships infrastructure only; real migration transforms land alongside the PRs that introduce their deprecations. ## Key Changes - **New package: `@payloadcms/codemod`** - Ships a `payload-codemod` bin, built via SWC. Package shape mirrors `create-payload-app`. - Default invocation `npx @payloadcms/codemod [path]` runs every registered transform in order; `--transform <name>`, `--list`, `--dry` (alias `--dry-run`), and `--print` are available as escape hatches. - **ts-morph-based runner and transform contract** - `Transform` exposes `{ name, description, apply(ctx) }`. The runner applies transforms against a shared ts-morph `Project`, isolates errors per transform, and aggregates results. A failing transform sets `process.exitCode = 1` but does not abort the rest of the run. - Print and save operations are gated on a pre-run text snapshot, so `--print` only emits files whose content changed and the default path only writes files that actually differ from the loaded source. - **Testing infrastructure** - `runTransform()` helper drives fixture-pair comparisons against in-memory ts-morph projects. Each transform lives in its own folder with co-located `index.ts`, `index.test.ts`, and `<case>.input.ts` / `<case>.output.ts` siblings. Idempotency is a per-transform test requirement. - **Reference transform: `example-noop`** - Temporary placeholder that exercises the full harness end to end. Will be deleted when the first real v3 to v4 transform ships. - **Build wiring** - Root `build:codemod` script added; the package is picked up by `build:all`. - `packages/eslint-config` gains a single entry allowing the new `bin/cli.js` through typescript-eslint's `projectService`, matching the existing entry for `create-payload-app`. ## Design Decisions - **Engine: ts-morph.** Payload is TS-first and most expected v3 to v4 deprecations are TS-shaped (renamed types, moved exports, `import type` updates, generic signatures). jscodeshift is the industry default but fights TS specifics; ast-grep is too limited for structural transforms. - **Scope: v3 to v4 only.** No version metadata, no `--since` flag, no back-catalog migrations. Transforms are a flat list in the registry, authored as part of the PR that introduces the deprecation. Future major versions are a separate decision, not a feature flag on this package. - **"Run everything" as the primary UX.** Users point the CLI at their project; it applies every registered transform. Transforms are required to be idempotent and safe on non-matching code, so running the full set against a partially migrated project is safe. Per-transform selection exists for debugging, not as the common path. - **Dry-run is purely a CLI concern.** `TransformContext` intentionally does not expose `dry`. Transforms always mutate the in-memory project; the CLI decides whether to persist. This prevents transforms from branching on a flag they cannot meaningfully honor. `--dry` is the canonical flag (matching jscodeshift and the broader codemod ecosystem); `--dry-run` is accepted as an alias for users reaching for the general Unix form. - **Error isolation over atomicity.** One transform throwing does not stop the run; failures surface in the summary and via exit code. Users get maximum-progress-per-invocation, recoverable via idempotent re-runs. ## Overall Flow ```mermaid sequenceDiagram participant User participant CLI as payload-codemod participant Runner participant Registry participant Project as ts-morph Project User->>CLI: payload-codemod [path] [flags] CLI->>Registry: load transforms CLI->>Project: load from tsconfig or glob CLI->>Project: snapshot source texts CLI->>Runner: runTransforms({ project, transforms }) loop per transform Runner->>Project: transform.apply({ project }) Project-->>Runner: mutations applied end Runner-->>CLI: { results, failed } CLI->>Project: diff against snapshot alt --print CLI->>User: print changed files only else --dry CLI->>User: summary only else CLI->>Project: save changed files end CLI->>User: summary + exit code ``` ## References / Links - [ts-morph](https://ts-morph.com/) Resolves https://app.asana.com/1/10497086658021/project/1214231632891339/task/1214259839775107
1 parent 0ceba02 commit af250cf

23 files changed

Lines changed: 553 additions & 0 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"build:app:analyze": "cross-env ANALYZE=true next build",
1818
"build:bundle-for-analysis": "turbo run build:bundle-for-analysis",
1919
"build:clean": "pnpm clean:build",
20+
"build:codemod": "turbo build --filter \"@payloadcms/codemod\"",
2021
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\" --filter \"!@payloadcms/storage-*\" --filter \"!blank\" --filter \"!website\" --filter \"!ecommerce\"",
2122
"build:core:force": "pnpm clean:build && pnpm build:core --no-cache --force",
2223
"build:create-payload-app": "turbo build --filter create-payload-app",

packages/codemod/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dist
2+
*.tsbuildinfo

packages/codemod/.swcrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"$schema": "https://json.schemastore.org/swcrc",
3+
"sourceMaps": true,
4+
"jsc": {
5+
"target": "esnext",
6+
"parser": { "syntax": "typescript", "tsx": false, "dts": true }
7+
},
8+
"module": { "type": "es6" }
9+
}

packages/codemod/CLAUDE.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# @payloadcms/codemod — Claude Guidance
2+
3+
Package-scoped rules. See `README.md` for usage and the mechanical authoring recipe.
4+
5+
## Transform contract
6+
7+
- Transforms receive `{ project }` only. The `TransformContext` does not expose `dry`, `print`, or any other flag. Never branch on options; the CLI controls persistence.
8+
- Mutate only the `Project` you were handed. Do not read or write the filesystem directly, spawn processes, or touch state outside ts-morph.
9+
- Every transform must be idempotent. Running twice produces the same result as running once.
10+
- Every transform must be safe on non-matching code. If the expected AST shape isn't there, return `{ filesChanged: [] }`. Do not throw for "didn't find what I expected".
11+
- `filesChanged` must list exactly the files whose text changed. A transform that mutates a file but forgets to list it will silently under-report in the CLI summary.
12+
- Use `notes` for surfacing information the user should see (e.g., spots that need manual review). Do not log via `console` from within a transform.
13+
14+
## Testing discipline
15+
16+
- Every transform ships with a fixture-pair test: at least one `<case>.input.ts` / `<case>.output.ts` pair covering a real matching case.
17+
- Every transform ships with an idempotency test: running the transform on the fixture's output must produce the output unchanged.
18+
- Every transform ships with at least one non-matching case proving the transform no-ops on unrelated code.
19+
- Fixtures live as siblings of `index.ts`. Do not introduce `__fixtures__` subfolders.
20+
21+
## PR coupling
22+
23+
- A codemod lands in the **same PR** as the deprecation it migrates. Do not defer the codemod to a follow-up PR.
24+
- Update the transform list in `README.md` in the same PR.
25+
26+
## Scope
27+
28+
- This package is v3 → v4 only. Do not add version metadata, `--since` flags, or back-catalog transforms for earlier versions.
29+
- Future major versions (v4 → v5 and beyond) are a separate decision, not an extension to this package.

packages/codemod/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# @payloadcms/codemod
2+
3+
CLI for auto-migrating Payload projects across deprecations. Initial target: v3 -> v4.
4+
5+
## Usage
6+
7+
Run against your project root:
8+
9+
```bash
10+
npx @payloadcms/codemod [path]
11+
```
12+
13+
With no arguments, runs every registered transform against the current directory. Transforms are idempotent and no-op on code that doesn't match their pattern, so running the full set against a partially migrated project is safe.
14+
15+
### Flags
16+
17+
- `--transform <name>` — run a single transform by name.
18+
- `--list` — print registered transforms.
19+
- `--dry` — analyze only; write nothing.
20+
- `--print` — print transformed sources to stdout instead of writing.
21+
22+
## How it works
23+
24+
The tool loads your project via [ts-morph](https://ts-morph.com/), using your `tsconfig.json` when present, otherwise globbing `**/*.{ts,tsx,js,jsx}` (excluding `node_modules`, `dist`, `.next`, `build`). Each registered transform is applied in order against the shared project; changes are saved at the end unless `--dry` or `--print` is passed.
25+
26+
## Transforms
27+
28+
_None yet — transforms land alongside the PRs that introduce their deprecations._
29+
30+
## Contributing
31+
32+
To add a transform:
33+
34+
1. Create `src/transforms/<name>/` with `index.ts` exporting a `Transform`.
35+
2. Add fixtures as `<case>.input.ts` and `<case>.output.ts` siblings of `index.ts`.
36+
3. Add `index.test.ts` verifying both the fixture pair and idempotency (running the transform on the output produces the output unchanged).
37+
4. Register in `src/registry.ts`.
38+
5. Update the transform list in this README.
39+
40+
Ship the transform in the same PR as the deprecation it migrates.

packages/codemod/bin/cli.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env node
2+
3+
import { main } from '../dist/cli.js'
4+
5+
main().catch((err) => {
6+
console.error(err)
7+
process.exit(1)
8+
})

packages/codemod/package.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "@payloadcms/codemod",
3+
"version": "4.0.0-beta.0",
4+
"description": "Codemods for migrating Payload projects across versions.",
5+
"homepage": "https://payloadcms.com",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/payloadcms/payload.git",
9+
"directory": "packages/codemod"
10+
},
11+
"license": "MIT",
12+
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
13+
"maintainers": [
14+
{
15+
"name": "Payload",
16+
"email": "info@payloadcms.com",
17+
"url": "https://payloadcms.com"
18+
}
19+
],
20+
"sideEffects": false,
21+
"type": "module",
22+
"bin": {
23+
"payload-codemod": "bin/cli.js"
24+
},
25+
"files": [
26+
"package.json",
27+
"bin",
28+
"dist",
29+
"README.md"
30+
],
31+
"scripts": {
32+
"build": "pnpm typecheck && pnpm build:swc",
33+
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
34+
"clean": "rimraf -g {dist,*.tsbuildinfo}",
35+
"lint": "eslint .",
36+
"lint:fix": "eslint . --fix",
37+
"test": "vitest --run",
38+
"typecheck": "tsc"
39+
},
40+
"dependencies": {
41+
"ts-morph": "^21.0.1"
42+
},
43+
"devDependencies": {
44+
"@swc/core": "1.15.3",
45+
"@types/node": "22.15.30",
46+
"rimraf": "6.0.1",
47+
"typescript": "5.7.3",
48+
"vitest": "4.1.2"
49+
},
50+
"engines": {
51+
"node": "^18.20.2 || >=20.9.0"
52+
}
53+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { parseFlags } from './cli.parseFlags.js'
4+
5+
describe('parseFlags', () => {
6+
it('defaults path to cwd when no positional arg is given', () => {
7+
expect(parseFlags([])).toEqual({
8+
dry: false,
9+
list: false,
10+
path: process.cwd(),
11+
print: false,
12+
transform: undefined,
13+
})
14+
})
15+
16+
it('reads positional path', () => {
17+
expect(parseFlags(['./src']).path).toBe('./src')
18+
})
19+
20+
it('parses flags', () => {
21+
expect(parseFlags(['./src', '--dry', '--print'])).toMatchObject({
22+
dry: true,
23+
path: './src',
24+
print: true,
25+
})
26+
})
27+
28+
it('parses --transform', () => {
29+
expect(parseFlags(['--transform', 'rename-slate-export'])).toMatchObject({
30+
transform: 'rename-slate-export',
31+
})
32+
})
33+
34+
it('parses --list', () => {
35+
expect(parseFlags(['--list']).list).toBe(true)
36+
})
37+
38+
it('treats --dry-run as an alias for --dry', () => {
39+
expect(parseFlags(['--dry-run']).dry).toBe(true)
40+
})
41+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { parseArgs } from 'node:util'
2+
3+
export type CliFlags = {
4+
dry: boolean
5+
list: boolean
6+
path: string
7+
print: boolean
8+
transform?: string
9+
}
10+
11+
export function parseFlags(argv: string[]): CliFlags {
12+
const { positionals, values } = parseArgs({
13+
allowPositionals: true,
14+
args: argv,
15+
options: {
16+
dry: { type: 'boolean', default: false },
17+
'dry-run': { type: 'boolean', default: false },
18+
list: { type: 'boolean', default: false },
19+
print: { type: 'boolean', default: false },
20+
transform: { type: 'string' },
21+
},
22+
})
23+
24+
return {
25+
dry: Boolean(values.dry) || Boolean(values['dry-run']),
26+
list: Boolean(values.list),
27+
path: positionals[0] ?? process.cwd(),
28+
print: Boolean(values.print),
29+
transform: values.transform,
30+
}
31+
}

packages/codemod/src/cli.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/* eslint-disable no-console */
2+
import { existsSync } from 'node:fs'
3+
import { resolve } from 'node:path'
4+
import { Project } from 'ts-morph'
5+
6+
import type { TransformRunResult } from './runner.js'
7+
import type { Transform } from './types.js'
8+
9+
import { parseFlags } from './cli.parseFlags.js'
10+
import { transforms as registry } from './registry.js'
11+
import { runTransforms } from './runner.js'
12+
13+
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
14+
const flags = parseFlags(argv)
15+
16+
if (flags.list) {
17+
printList()
18+
return
19+
}
20+
21+
const selected = selectTransforms(registry, flags.transform)
22+
if (selected.length === 0) {
23+
console.error(`No transforms matched${flags.transform ? ` "${flags.transform}"` : ''}.`)
24+
process.exitCode = 1
25+
return
26+
}
27+
28+
const project = loadProject(resolve(flags.path))
29+
const snapshot = snapshotProject(project)
30+
31+
const { failed, results } = await runTransforms({
32+
project,
33+
transforms: selected,
34+
})
35+
36+
const changed = project
37+
.getSourceFiles()
38+
.filter((file) => snapshot.get(file.getFilePath()) !== file.getFullText())
39+
40+
if (flags.print) {
41+
if (changed.length === 0) {
42+
console.log('(no files changed)')
43+
} else {
44+
for (const file of changed) {
45+
console.log(`// ${file.getFilePath()}`)
46+
console.log(file.getFullText())
47+
}
48+
}
49+
} else if (!flags.dry) {
50+
await Promise.all(changed.map((file) => file.save()))
51+
}
52+
53+
printSummary(results)
54+
55+
if (failed) {
56+
process.exitCode = 1
57+
}
58+
}
59+
60+
function selectTransforms(available: Transform[], name: string | undefined): Transform[] {
61+
if (!name) {
62+
return available
63+
}
64+
return available.filter((t) => t.name === name)
65+
}
66+
67+
function printList(): void {
68+
if (registry.length === 0) {
69+
console.log('No transforms registered.')
70+
return
71+
}
72+
for (const t of registry) {
73+
console.log(`${t.name} ${t.description}`)
74+
}
75+
}
76+
77+
function loadProject(path: string): Project {
78+
const tsconfigPath = resolve(path, 'tsconfig.json')
79+
if (existsSync(tsconfigPath)) {
80+
return new Project({ tsConfigFilePath: tsconfigPath })
81+
}
82+
const project = new Project()
83+
project.addSourceFilesAtPaths([
84+
`${path}/**/*.{ts,tsx,js,jsx}`,
85+
'!**/node_modules/**',
86+
'!**/dist/**',
87+
'!**/.next/**',
88+
'!**/build/**',
89+
])
90+
return project
91+
}
92+
93+
function snapshotProject(project: Project): Map<string, string> {
94+
return new Map(project.getSourceFiles().map((file) => [file.getFilePath(), file.getFullText()]))
95+
}
96+
97+
function printSummary(results: TransformRunResult[]): void {
98+
console.log('')
99+
console.log('Codemod summary:')
100+
for (const r of results) {
101+
if (r.error) {
102+
console.log(` [FAIL] ${r.name}${r.error.message}`)
103+
continue
104+
}
105+
console.log(` [ok] ${r.name}${r.filesChanged.length} file(s) changed`)
106+
for (const note of r.notes ?? []) {
107+
console.log(` note: ${note}`)
108+
}
109+
}
110+
}

0 commit comments

Comments
 (0)