Skip to content

Commit 931e1e1

Browse files
authored
chore: add write-codemod skill for create-plugin codemods (#2782)
1 parent 89cd91c commit 931e1e1

4 files changed

Lines changed: 564 additions & 0 deletions

File tree

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
---
2+
name: write-codemod
3+
description: Expert at writing migrations and additions for the create-plugin codemod system in packages/create-plugin/src/codemods/. Use this skill whenever the user wants to create, modify, or debug a codemod, migration, or addition in the plugin-tools repo — including replicating a create-plugin template change onto existing plugins, or adding CLI flags to an addition. Triggers on: "write a migration", "add a codemod", "create a migration for X", "add an addition", "update the codemod", or any request to automate a change that plugin authors need to apply to their plugin projects.
4+
---
5+
6+
# Write Codemod
7+
8+
Codemods automate changes to plugin projects scaffolded by `@grafana/create-plugin`. There are two kinds — the first decision is which one to write:
9+
10+
| | Migration | Addition |
11+
| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------- |
12+
| Purpose | Bring existing plugins in line with newer create-plugin output | Opt-in feature an author asks for |
13+
| Runs via | `npx create-plugin update` — automatic, version-gated | `npx create-plugin add <name>` — on demand |
14+
| Registry entry | `{ name, version, description, scriptPath }` in `migrations/migrations.ts` | `{ name, description, scriptPath }` in `additions/additions.ts` |
15+
| Naming | `NNN-migration-title` — next number in sequence, ≤3-word kebab-case title | Descriptive kebab-case — the name becomes the CLI subcommand |
16+
17+
If the change should reach every plugin on its next update, write a migration. If it's something an author chooses to apply ("externalize the JSX runtime"), write an addition.
18+
19+
## When in doubt, read the source
20+
21+
- The scripts in `migrations/scripts/` and `additions/scripts/` are the ground truth for how `recast`, `yaml`, `jsonc-parser`, and `valibot` are used in this codebase. Read the closest existing example before writing a new one.
22+
- For library APIs the examples don't cover, fetch the docs (context7 MCP server if available, otherwise the package's official documentation). Don't guess from training data.
23+
24+
Before writing any code, present a short plan — target files, applicability and idempotency guards, and the tests that prove each behaviour — and get it confirmed.
25+
26+
## How codemods execute (and why the rules exist)
27+
28+
Both kinds run through the same pipeline in `runner.ts`:
29+
30+
1. Import the script's default export; validate CLI flags against its exported valibot `schema`, if any
31+
2. Call it with a fresh `Context` rooted in the plugin's working directory
32+
3. Format every changed file with the plugin's local prettier (skipped when prettier isn't installed)
33+
4. Flush the context's staged changes to disk
34+
5. Print a summary of changes to the author
35+
6. Run the package manager's install when `package.json` changed
36+
37+
The pipeline explains the core rules:
38+
39+
- **Use the Context for all file operations, never Node.js `fs`.** The runner owns disk writes; the context is an in-memory staging area, which is also what makes tests hermetic. Always `return context`, even on early returns.
40+
- **Don't chase perfect output formatting.** Prettier reformats every changed file afterwards using the plugin's own config.
41+
- **The dependency helpers are enough.** Changing `package.json` through them triggers a real install after the run.
42+
- **Degrade gracefully — don't throw.** A thrown error aborts the author's entire `update` sequence. When a file is missing, unparseable, or already migrated, log with the debug helper and return the context unchanged.
43+
- **Stay inside the plugin's working directory.** Codemods run inside a user's project; the context base path is the boundary.
44+
- **Idempotency is non-negotiable.** Codemods re-run against already-migrated projects; every script must be safe to run repeatedly.
45+
- **One concern per codemod.** Don't bundle unrelated changes.
46+
47+
Migrations run sequentially in ascending version order. With the `--commit` flag, each migration that changed files gets its own commit with the registry `description` as the body — one more reason descriptions must read well.
48+
49+
### Debug logging
50+
51+
Log every early-return path so a codemod that silently no-ops in the field can be diagnosed with `DEBUG=create-plugin:*`:
52+
53+
```ts
54+
import { migrationsDebug } from '../../utils.js'; // additionsDebug for additions
55+
56+
if (!parsed.success) {
57+
migrationsDebug(`Failed to parse webpack.config.ts: ${parsed.error.message}`);
58+
return context;
59+
}
60+
```
61+
62+
## Architecture
63+
64+
```
65+
packages/create-plugin/src/codemods/
66+
├── AGENTS.md # Condensed rules for agents without this skill — keep in sync
67+
├── context.ts # Context class — ALL file operations go through this
68+
├── runner.ts # Shared pipeline: validate options → run → format → flush → install
69+
├── schema-parser.ts # Valibot validation of CLI flags
70+
├── types.ts # Codemod / CodemodModule types
71+
├── utils.ts # Helpers: package.json, semver, renderTemplate, debug loggers
72+
├── utils.ast.ts # recast helpers for TS/JS codemods
73+
├── test-utils.ts # createDefaultContext() helper for tests
74+
├── migrations/
75+
│ ├── migrations.ts # Migration registry
76+
│ ├── manager.ts # Version gating + sequential execution
77+
│ └── scripts/
78+
│ ├── NNN-migration-title.ts
79+
│ └── NNN-migration-title.test.ts
80+
└── additions/
81+
├── additions.ts # Addition registry
82+
└── scripts/
83+
├── addition-name.ts
84+
└── addition-name.test.ts
85+
```
86+
87+
## Context API
88+
89+
```ts
90+
// Check before touching
91+
context.doesFileExist(filePath: string): boolean // staged view (includes adds/deletes)
92+
context.doesFileExistOnDisk(filePath: string): boolean
93+
94+
// Read
95+
context.getFile(filePath: string): string | undefined // returns undefined if deleted
96+
context.readDir(folderPath: string): string[] // includes context-added files
97+
context.readDirFromDisk(folderPath: string): string[]
98+
99+
// Write
100+
context.addFile(filePath: string, content: string) // throws if already exists
101+
context.updateFile(filePath: string, content: string) // throws if doesn't exist; no-ops if content unchanged
102+
context.deleteFile(filePath: string)
103+
context.renameFile(from: string, to: string) // delete old + add new
104+
105+
// Inspect
106+
context.listChanges(): ContextFile
107+
context.hasChanges(): boolean
108+
```
109+
110+
Always check `doesFileExist` before `getFile` or `updateFile`.
111+
112+
## Utility functions (from `../../utils.js`)
113+
114+
```ts
115+
// package.json manipulation
116+
addDependenciesToPackageJson(context, dependencies, devDependencies?, packageJsonPath?)
117+
removeDependenciesFromPackageJson(context, dependencies, devDependencies?, packageJsonPath?)
118+
readJsonFile<T>(context, path): T // throws if missing or unparseable
119+
120+
// Semver — handles dist-tags ("latest", "next", "*") and standard ranges
121+
isVersionGreater(incomingVersion, existingVersion, orEqualTo?): boolean
122+
123+
// Render a file from the scaffold templates (see "Rendering scaffold templates")
124+
renderTemplate(templatePath, includeWarning?): string
125+
126+
// Debug loggers (namespace create-plugin:*)
127+
migrationsDebug, additionsDebug
128+
```
129+
130+
## Writing a migration
131+
132+
```ts
133+
import type { Context } from '../../context.js';
134+
135+
export default function migrate(context: Context) {
136+
// 1. Guard: check the file/condition that makes this migration applicable
137+
if (!context.doesFileExist('some-file')) {
138+
return context;
139+
}
140+
141+
const content = context.getFile('some-file') || '';
142+
143+
// 2. Guard: skip if already applied (idempotency)
144+
if (content.includes('already-migrated-marker')) {
145+
return context;
146+
}
147+
148+
// 3. Apply the change
149+
context.updateFile('some-file', content.replace('old', 'new'));
150+
151+
// 4. Always return context
152+
return context;
153+
}
154+
```
155+
156+
Async is fine — the runner awaits, so the signature may be `async` and return `Promise<Context>`.
157+
158+
### Registering a migration
159+
160+
Add to the end of the `default` array in `migrations/migrations.ts`:
161+
162+
```ts
163+
{
164+
name: 'NNN-migration-title',
165+
version: 'X.Y.Z+1', // next patch from the current @grafana/create-plugin version — see below
166+
description: 'One sentence explaining WHY this migration is needed (the problem/consequence), not just what it does.',
167+
scriptPath: import.meta.resolve('./scripts/NNN-migration-title.js'),
168+
},
169+
```
170+
171+
### `version` field — always the next patch
172+
173+
A plugin records the create-plugin version it was last updated to in `.config/.cprc.json`. `update` selects every registered migration whose `version` falls inside the range from that recorded version to the current create-plugin version (inclusive), runs them in ascending order, then bumps `.cprc.json`.
174+
175+
Set `version` to the next **patch** of the current version in `packages/create-plugin/package.json` (e.g. `7.3.0``7.3.1`), regardless of the semver bump the change will actually ship in. The registry version only gates which migrations run — it is decoupled from the release version chosen by release-please — and the next patch is the lowest possible next version, so the migration is guaranteed to fire on the next release whatever bump that release turns out to be.
176+
177+
Do not use `LEGACY_UPDATE_CUTOFF_VERSION` for new migrations — that constant is reserved for the original batch written before the "updates as migrations" model.
178+
179+
### `description` field
180+
181+
The `description` is shown to plugin authors during `update` and becomes the commit body with `--commit`. Lead with the problem or consequence (an error code, a deprecation, a breaking change), then briefly note how it's resolved. "Fix X: Y is deprecated causing error Z, replaced with W" beats "Replace Y with W".
182+
183+
## Writing an addition
184+
185+
Additions live in `additions/scripts/` as `addition-name.ts` + `addition-name.test.ts` and register in `additions/additions.ts`:
186+
187+
```ts
188+
{
189+
name: 'externalize-jsx-runtime', // the CLI subcommand: npx create-plugin add externalize-jsx-runtime
190+
description: 'Externalizes the react JSX runtime to help migrate plugins to React 19',
191+
scriptPath: import.meta.resolve('./scripts/externalize-jsx-runtime.js'),
192+
},
193+
```
194+
195+
There is no `version` field — additions aren't gated. They run whenever an author invokes them, so they must handle any plugin state they might meet: fresh scaffold, heavily customised, or already applied.
196+
197+
### CLI flags via valibot schema
198+
199+
A codemod can accept CLI flags by exporting a valibot `schema` alongside the default function. The runner validates the flags (types, defaults, coercion) before your code runs and reports failures as per-flag error messages:
200+
201+
```ts
202+
import * as v from 'valibot';
203+
import type { Context } from '../../context.js';
204+
205+
export const schema = v.object({
206+
featureName: v.pipe(v.string(), v.minLength(3, 'Feature name must be at least 3 characters')),
207+
enabled: v.optional(v.boolean(), true),
208+
});
209+
210+
export default function addFeature(context: Context, options: v.InferOutput<typeof schema>): Context {
211+
// options are validated and defaulted by the runner
212+
return context;
213+
}
214+
```
215+
216+
`additions/scripts/example-addition.ts` is the canonical example. `update` forwards CLI flags to migrations the same way, but migrations should rarely take options — they must work unattended.
217+
218+
### Rendering scaffold templates
219+
220+
When a codemod adds a file that also exists in the scaffold templates, render the real template instead of duplicating its content — one source of truth:
221+
222+
```ts
223+
import { fileURLToPath } from 'node:url';
224+
import { renderTemplate } from '../../utils.js';
225+
226+
const templatePath = fileURLToPath(
227+
new URL('../../../../templates/common/.config/bundler/externals.ts', import.meta.url)
228+
);
229+
context.addFile('.config/bundler/externals.ts', renderTemplate(templatePath, true));
230+
```
231+
232+
## Working from a template change
233+
234+
A common trigger for a migration: the scaffold templates in `packages/create-plugin/templates/` have already been updated, and existing plugins need a codemod that applies the same change. The `update` command only runs migrations — a template change on its own never reaches already-scaffolded plugins.
235+
236+
1. **Treat the template diff as the specification.** Run `git diff main -- packages/create-plugin/templates/` (or read the relevant PR/commits) and enumerate every changed template file before planning the codemod.
237+
2. **Triage each changed file.** Some template changes only matter for new scaffolds (README wording, sample data, docs). List which files need a codemod and which don't, and confirm the split with the user.
238+
3. **Map template paths to scaffolded paths.** The type directory prefix is not part of the scaffolded output path, the `.hbs` extension is stripped, and some filenames are rewritten at scaffold time (see `configFileNamesMap` in `src/utils/utils.files.ts`): `_package.json``package.json`, `gitignore``.gitignore`, `npmrc``.npmrc`, `_eslintrc``.eslintrc`, `playwright.config``playwright.config.ts`. Templates under `common/` are scaffolded for every plugin type; those under `app/`, `panel/`, `datasource/`, `scenes-app/`, `backend/`, and `backend-app/` only for that type — a change under a type-specific directory needs a matching applicability guard.
239+
4. **Work with rendered output, not template source.** Templates are Handlebars — `{{ pluginId }}` expressions, `{{#if}}` blocks, and partials from `templates/_partials/` never appear in a scaffolded plugin. The codemod must read and write the rendered form, and a change inside a Handlebars conditional needs the equivalent guard in the codemod. When the change adds a whole new file, prefer `renderTemplate` (above) over inlining the content.
240+
5. **Target the intent, not the template text.** An existing plugin's file has usually diverged from the pristine scaffold (renamed variables, extra config, reformatting). Anchor the codemod on the structure the change touches — the AST node, JSON key, or YAML path — with guards for when it's missing, rather than string-replacing the template's old text.
241+
6. **Derive test fixtures from the templates.** Use the old template's rendered output as the happy-path input and assert the result matches what the new template renders. Add cases for divergent user files: the structure moved, the value customized, the file absent.
242+
243+
## Editing files by type
244+
245+
Structured files need parser-based edits — string replacement breaks on formatting differences and is never idempotent. Read the matching reference before writing the transformation:
246+
247+
| Target file type | Read | Approach |
248+
| --------------------------------------------- | ------------------------------ | ----------------------------------------------------------- |
249+
| JSON / JSONC (`package.json`, tsconfig, etc.) | `references/json.md` | `JSON.parse`/`stringify`, helpers, `jsonc-parser` for JSONC |
250+
| TypeScript / JavaScript | `references/typescript-ast.md` | recast AST via the `utils.ast.ts` helpers |
251+
| YAML (workflows, docker-compose) | `references/yaml.md` | `yaml` package document API |
252+
| Plain text / markdown | — | string operations with an idempotency guard |
253+
254+
## Testing requirements
255+
256+
Every codemod ships with a colocated `.test.ts`. Cover four behaviours:
257+
258+
1. **Happy path** — the codemod applies the expected change.
259+
2. **Idempotency** — running twice produces the same result, via the custom matcher (defined in `packages/create-plugin/vitest.setup.ts`; it runs the codemod twice and diffs the files staged after the first run):
260+
```ts
261+
await expect(migrate).toBeIdempotent(context);
262+
// codemods that take options need a wrapper:
263+
await expect((ctx) => addFeature(ctx, options)).toBeIdempotent(context);
264+
```
265+
3. **Already-migrated guard** — no-op when the change is already present.
266+
4. **Missing file guard** — no-op when the target file doesn't exist (if applicable).
267+
268+
`test-utils.ts` exports `createDefaultContext()` for a context pre-seeded with a minimal plugin; otherwise seed your own:
269+
270+
```ts
271+
import { describe, expect, it } from 'vitest';
272+
import migrate from './NNN-migration-title.js';
273+
import { Context } from '../../context.js';
274+
275+
describe('NNN-migration-title', () => {
276+
it('should <describe the happy path>', () => {
277+
const context = new Context('/virtual');
278+
context.addFile('target-file', 'original content');
279+
280+
const result = migrate(context);
281+
282+
expect(result.getFile('target-file')).toBe('expected content');
283+
});
284+
285+
it('should be idempotent', async () => {
286+
const context = new Context('/virtual');
287+
context.addFile('target-file', 'original content');
288+
289+
await expect(migrate).toBeIdempotent(context);
290+
});
291+
292+
it('should not modify files if already migrated', () => {
293+
const context = new Context('/virtual');
294+
const content = 'already-migrated content';
295+
context.addFile('target-file', content);
296+
297+
const result = migrate(context);
298+
299+
expect(result.getFile('target-file')).toBe(content);
300+
});
301+
});
302+
```
303+
304+
## Verifying before shipping
305+
306+
- Run the codemod's tests from the repo root:
307+
```bash
308+
npm run test -w @grafana/create-plugin -- --run src/codemods/migrations/scripts/NNN-migration-title.test.ts
309+
```
310+
- For anything non-trivial, run the codemod against a real plugin: follow "How to test a migration locally" in `packages/create-plugin/CONTRIBUTING.md` (link the local build, check `.config/.cprc.json` is below the bumped version, run `npx create-plugin update` in a test plugin) and inspect the resulting diff.

0 commit comments

Comments
 (0)