---
name: author-migration
description: >-
  Author or scope a first-party Nx migration. Use whenever code removes, renames,
  or deprecates an option/flag/executor/generator-schema field, changes a default,
  or bumps a dependency, and someone asks whether existing workspaces need a
  migration so they don't break on `nx migrate`/upgrade. Covers writing the
  colocated update-VER/NAME.{ts,spec.ts,md} set, the migrations.json entry
  (version, requires, implementation, prompt, documentation) or packageJsonUpdates
  group, and the AI-agent prompt/runbook .md for prompt-only or hybrid
  (generator + prompt) migrations. Also covers porting an upstream framework's own
  migrations into Nx. Invoke BEFORE writing, fixing, or editing any migration,
  migration prompt/runbook, or packageJsonUpdates group, and before concluding a
  breaking change needs no migration at all.
allowed-tools: Bash, Read, Write, Edit, Glob, Grep
---

# Author a first-party Nx migration

Companion files in this directory:

- [runtime-contract.md](runtime-contract.md): how `nx migrate` consumes every migrations.json key. Read it before wiring an entry; most authoring mistakes are wrong assumptions about this contract.
- [deprecated-patterns.md](deprecated-patterns.md): patterns to never reproduce, with recognition signatures. Read it before copying from an existing migration or from git history.
- [templates/](templates/): entry shapes, spec skeleton, and the two .md genres.

## 1. Decompose the change into migration needs

Enumerate every breaking or behavior-changing item in the change (upstream changelog, upstream migration guide, upstream repo's own migrations directory, or the Nx-internal change itself). Classify each item with exactly one treatment:

| Treatment                 | When                                                                                                                                                                 | Shape                                                                                                                                                                                                                                                           |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Nothing                   | Additive change, a plugin-absorbed break, or a shape no Nx surface produces (prose below)                                                                            | No entry                                                                                                                                                                                                                                                        |
| Version admission         | Nx starts supporting a new upstream major, even when the previous major stays supported                                                                              | `packageJsonUpdates` group gated on the source-major window; `nx migrate` moves willing workspaces onto the latest supported major (see `packages/storybook/migrations.json` key `22.1.0`, shipped while v8 stayed supported, and `packages/vite` key `21.5.0`) |
| Plain bump                | Dependency versions change, nothing else                                                                                                                             | Declarative `packageJsonUpdates`. Never write a .ts implementation for an unconditional bump                                                                                                                                                                    |
| Conditional dep change    | Add/remove/swap a dependency based on workspace state                                                                                                                | .ts implementation using `addDependenciesToPackageJson` / `removeDependenciesFromPackageJson` (add side: see `packages/angular/src/migrations/update-23-1-0/add-angular-build.ts`; copy its dependency handling, not its `utils/versions` import)               |
| Source transform          | Deterministic, statically detectable source or config change (removed option, key rename, default flip)                                                              | `implementation` + spec + `documentation` .md                                                                                                                                                                                                                   |
| Ported upstream migration | Upstream ships its own migrations for the major (wins over Source transform for those items; a judgment-based upstream migration takes the Prompt-only/Hybrid shape) | One generator-only migration per upstream migration, description ending "matching the <upstream> `X` migration", `requires` on the new major (see `packages/angular/migrations.json` `update-23-1-0-add-trust-proxy-headers`)                                   |
| Run upstream codemod      | Upstream publishes an npx-runnable codemod                                                                                                                           | Prompt migration instructing the agent to run it; do not port it (see `packages/react/src/migrations/update-23-1-0/ai-instructions-for-react-19.md`)                                                                                                            |
| Prompt-only               | Change requires judgment an AST transform cannot make                                                                                                                | `prompt` .md plus `documentation` .md, no implementation                                                                                                                                                                                                        |
| Hybrid                    | Mechanical pre-pass plus judgment                                                                                                                                    | ONE entry with both `implementation` and `prompt` (see eslint `update-23-1-0-convert-to-flat-config`; do not copy its shared .md basename)                                                                                                                      |

Every treatment that produces a `generators` entry also ships the section-6 `documentation` .md, set on the entry's `documentation` key; `packageJsonUpdates` groups have no documentation key.

Never author: executor-to-inferred conversions (that is the user-invoked `convert-to-inferred` generator, not a migration), or migrations for unreleased/speculative upstream behavior.

A break qualifies for an entry only when it survives plugin-side compat and reaches users' own files. If the plugin absorbs it for every shape it emits or manages, classify it Nothing and name the absorbing code in the coverage mapping (below). If it reaches configuration users wrote in an Nx-scaffolded or Nx-documented surface that the plugin passes through, author the migration (the rspack v2 migration generator rewrites user-file options like `libraryTarget`, a plugin-managed default users override explicitly). If it is only expressible in layouts no Nx surface produces (a construct the generated config shape turns into a no-op), classify it Nothing: the upstream migration guide owns it, and a speculative prompt migration for such shapes is over-production, not coverage. A shape between these (user-written and passed through by the plugin, but in no Nx-scaffolded or Nx-documented surface) defaults to authoring; record the call in the coverage mapping. This decides whether an entry exists at all; once one does, the implementation still covers every hand-written shape of the construct it edits (section 4).

When a change could be handled by a migration generator (the deterministic implementation; what general usage calls a codemod) or a prompt, the generator wins: deterministic transforms are faster, produce the same output for the same input, and are the only part guaranteed to run for every user (prompts execute only under the agentic flow; in plain runs they surface as next steps that may never happen). Enumerate the scenarios and edge cases the change can hit, then partition: everything transformable with guaranteed correctness goes in the implementation; only the remainder goes to a prompt. Prompt-only is a last resort, for changes with no safely-automatable subset at all; if any subset is mechanical, ship a hybrid whose .ts does the safe part and hands off the rest per the contract in section 4. The justification for a prompt-only classification in the `## Migration coverage` mapping below must say why a generator cannot guarantee correctness, not why it is harder to write.

Record the mapping in the PR description under a `## Migration coverage` heading: one line per upstream item, its treatment, and a one-clause justification for anything classified Nothing or prompt-only.

## 2. Version and gating

### Entry version

The version field is a gate, not a label: an entry runs when `installed < version <= target` with semver prerelease ordering (see [runtime-contract.md](runtime-contract.md)).

- The target train is the developer's decision, made once per authoring task and covering every entry and `packageJsonUpdates` group in the change: the work may target a train other than the active one. When the task does not state a target, ask the developer, phrased as "Which version should the migration target?" (release-train framing belongs in the option descriptions, not the question), presenting the options printed by `node .claude/skills/author-migration/scripts/compute-target-versions.mjs` (anchored on `npm view nx dist-tags`; needs the repo's node_modules installed for `semver`). What it computes:
  - `next` is a prerelease above `latest` (an active prerelease train): that train's exact next prerelease (`next` `23.1.0-beta.8` -> `23.1.0-beta.9`). Recommended default.
  - Otherwise (the train rolled over, no new prerelease cut yet): the next minor at beta.0 (`latest` `23.2.0` -> `23.3.0-beta.0`).
  - In either case, when `next` is not a major bump over `latest` (e.g. latest `22.4.1`, next `22.5.0-beta.3`): additionally the next major at beta.0 (`23.0.0-beta.0`) for breaking work aimed at the upcoming major; the option must say that the branch, not the version field, chooses the ship vehicle, so this waits for the train switch.
  - Free-text entry covers trains none of the computed options match.
- Non-interactive runs have no one to ask: use the script's recommended default and flag the choice in the PR notes, naming which computed option you took when more than one applied. Treat a run as non-interactive only when there is no channel to ask at all; if you can ask, ask, even when the version need only surfaces at the end of the work.
- Never a bare final version (prerelease users would skip it) and never a backdated prerelease (users past that prerelease silently skip it). All entries in the change use the chosen train's exact next prerelease, even when batching related migrations.
- The version field does not choose which release ships the code; the branch does. A breaking migration must wait for the train switch before merging (the SVGR removal was fully reverted for landing on the wrong train).
- Fixing a shipped migration: amend the implementation in place AND bump the entry version to the current next prerelease so workspaces that already ran the broken version re-run it. The re-stamped version is still the train choice: when the task did not state the train, ask, using the computed options above; the current next prerelease is not a self-sufficient default just because it can be computed.

### requires

- `requires` evaluates against the version the package will LAND on in this run (pending packageJsonUpdates first, installed as fallback), with `includePrerelease`. A package absent from both fails the gate.
- Migration for upstream major N: gate `{"<pkg>": ">=N.0.0"}`. It fires when the same run bumps into N.
- Migration entries gate on the destination, lower bound only (`>=N`) in the common case. Gates evaluate once, at collection time, against landing versions (above) and are never re-checked at execution; package updates are applied and installed before migrations run. An upper bound that encodes the source window ("migrate from 9": `>=9 <10`) therefore fails whenever the same run bumps past the cap, and the migration never runs (a shipped storybook bug, fixed by dropping the bound). An upper bound is right only when the migration is inapplicable at or above it even for workspaces landing there (`next >=15.0.0 <16.0.0` on the next-15 instructions entry in `packages/next/migrations.json`: a workspace landing on next 16 has no use for next-15 guidance).
- `requires` is AND across packages. Mutually exclusive conditions need separate entries; a condition `requires` cannot express (an OR of packages) gets a code-level gate via `getDeclaredPackageVersion` + semver inside the migration function. A dependency installable under alternative names is an OR condition (umbrella vs scoped: `typescript-eslint` vs `@typescript-eslint/eslint-plugin`); gating on one name silently skips workspaces that declare only the other. This shipped as a real bug in eslint, fixed by dropping the gate and checking both names inside the migration (see `hasTypescriptEslintV8` in `packages/eslint/src/migrations/update-23-1-0/remove-removed-typescript-eslint-extension-rules.ts`). In `packageJsonUpdates`, express the OR as one group per name (the paired `21.2.0-typescript-eslint` / `21.2.0-@typescript-eslint` groups in `packages/eslint/migrations.json`).
- `packageJsonUpdates` groups gate on the SOURCE major window (`">=N.0.0 <N+1.0.0"`), one group per supported source major, ordered oldest source major first: groups are processed in key order in a single pass and each accepted group feeds the next group's `requires`, which is what lets a workspace chain major steps. Unlike migration entries, a group's `version` gate is inclusive on the installed side (`installed <= version <= target`). Groups always carry both bounds: a group translates a source range into a target ("within this range -> move to Y"), and the ladder depends on each window being closed. Never infer whether a group needs `requires` from a sibling group, gated or not: re-derive it per admission. `packages/rspack/migrations.json` `21.4.0` shipped the http-proxy-middleware v2 -> v3 bump ungated, while `packages/react/migrations.json` `21.4.0`, the identical bump from the same commit, was later gated with `{"http-proxy-middleware": ">=2.0.0 <3.0.0"}`; a group moving workspaces across a major while the old major stays supported always gates `requires` on the source-major window (see `packages/nest/migrations.json` `21.2.0-beta.2`). A rung backfilled at a later nx version cannot sit before the existing upper rungs: group keys pin to the ship version, so its cohort lands on the intermediate major and the inclusive-installed gate leaves the upper groups permanently behind them. Decide the outcome explicitly: re-offer the upper rungs at the new version, keyed after the new rung and with any landing-gated companion entries re-stamped, or hold the cohort deliberately when a companion depends on landing on the intermediate major (the next 14->15 group at `packages/next/migrations.json` `23.1.0` holds workspaces at 15 so the landing-gated 15-instructions entry fires; the onward 15->16 hop then still needs shipping at a later version or the cohort strands).
- Nx-version-only migrations carry no `requires`, and neither do changes internal to the plugin itself (a dependency restructuring, a moved entry point): those affect every workspace taking the plugin bump. Gate on a package only when the migration's behavior depends on that package's version; never copy a sibling entry's `requires` without re-deriving it, since an unfit gate silently skips workspaces the change applies to (`packages/angular/migrations.json` `update-23-1-0-add-optional-webpack-packages` backfills newly-optional peers and ships ungated beside the `>=22.0.0`-gated entries in the same version window).
- The rules here cover the gates a single new migration carries. Auditing a plugin's whole support window (packageJsonUpdates coverage per source major, version floors, peer alignment) is the [multi-version-compliance](../multi-version-compliance/SKILL.md) skill's job.

## 3. Scaffold

Home the migration in the package whose change it accompanies (`update-devkit-deep-imports` lives in `packages/devkit` for devkit's own break). Re-homing in `packages/nx` to widen reach trades that precision for guaranteed collection, runs for every workspace taking the nx-version bump rather than just those with the affected package installed, and subjects the migration to `nx repair` re-runs; do that only for workspace-level concerns, and never on an unverified claim about what `nx migrate` collects (see [runtime-contract.md](runtime-contract.md) on collection scope).

Layout, always:

```
packages/<plugin>/src/migrations/update-<major>-<minor>-<patch>/<name>.ts
packages/<plugin>/src/migrations/update-<major>-<minor>-<patch>/<name>.spec.ts    (only when there is an implementation)
packages/<plugin>/src/migrations/update-<major>-<minor>-<patch>/<name>.md         (documentation: same basename as the .ts)
packages/<plugin>/src/migrations/update-<major>-<minor>-<patch>/<other-name>.md   (prompt: basename must differ from any .ts)
```

The layout above is the source tree; the entry's `implementation`/`prompt`/`documentation` values use the published shape instead, dist-prefixed per the package's build layout (`./dist/src/migrations/...` for `rootDir: "."`; mapping in [templates/migrations-json.md](templates/migrations-json.md), resolution in [runtime-contract.md](runtime-contract.md)).

Entries go under the top-level `generators` section (`schematics` is the legacy Angular Devkit adapter section). Entry key: kebab-case, unique within the file (`@nx/nx-plugin-checks` flags duplicates, which JSON parsers otherwise resolve by silently keeping only the last occurrence), with a slug naming the action. The full `update-<major>-<minor>-<patch>-<slug>` form is a soft convention that namespaces the slug per release, not a requirement: packages/nx uses `<ver>-<slug>` (e.g. `23-0-0-add-migrate-runs-to-git-ignore`) or plain slugs; jest and much of packages/angular use plain slugs or a reversed `<slug>-<ver>` form (`update-module-resolution-22-2-0`). Follow the file's dominant form; use the full form in a new migrations.json or where no form dominates. A version part in the key is a coarse release-level hint, not required to match the entry's `version` field (usually a prerelease, e.g. `23.0.0-beta.18`); docs group by the `version` field, not the key. The key is user-visible: it becomes the `--create-commits` commit subject, the docs heading, and the run listing line.

Do not rely on `@nx/plugin:migration` generator output alone: it scaffolds empty stubs, defaults the key to the bare filename, and never writes `requires`, `.md` files, prompt entries, or per-package `packageJsonUpdates` details. Hand-author from [templates/migrations-json.md](templates/migrations-json.md).

First migration in a plugin? Also check:

- `package.json` has `"nx-migrations": { "migrations": "./migrations.json", "supportsOptionalMigrations": true }` (new plugins use `nx-migrations`; `ng-update` is legacy Angular CLI interop, do not add it).
- `migrations.json` starts with `"$schema": "../../node_modules/nx/schemas/migrations-schema.json"` (new files only; do not backfill others).
- `assets.json` copies `src/migrations/**/*.md` into dist so each .md lands next to its built implementation. For `rootDir: "src"` packages the equivalent is `{ "glob": "migrations/**/*.md", "input": "packages/<plugin>/src" }` (see `packages/maven/assets.json`). A missing glob drops the .md from the published package, which breaks `prompt`/`documentation` resolution and the docs site; the `migration-markdown-assets` conformance rule fails on any referenced .md the assets config does not produce.
- A root `migrations.spec.ts` calling `assertValidMigrationPaths` from `@nx/devkit/internal-testing-utils` exists.
- The plugin's eslint config applies `@nx/nx-plugin-checks` with `./migrations.json` in the rule's `files` array.
- A brand-new `@nx/*` plugin must be added to `packages/nx/package.json` `nx-migrations.packageGroup`, or `nx migrate` will never bump it (enforced by the `nx-package-group` conformance rule).

## 4. Implement

### Common canon (every migration)

- `export default async function update(tree: Tree)` and nothing else. Migrations take no options; the runner calls them with `(tree, {})`.
- Import helpers from `@nx/devkit` and, for the semi-private ones (`forEachExecutorOptions`, target-default helpers), from `@nx/devkit/internal`. Exception: migrations inside `packages/nx` itself use relative imports and `formatChangedFilesWithPrettierIfAvailable`.
- Tree APIs only, never `fs`. All existence/content checks via `tree.exists` / `tree.read` / `readJson`.
- Build tree paths with `joinPathFragments` or `node:path`'s `posix` helpers, matching what the Tree returns (always slash-separated). Plain `join`/`dirname` emit backslashes on Windows; the Tree normalizes them on API calls, but any string-level use of such a path (comparison with a tree-provided path, a visited-set key, log output) silently mismatches.
- End with `await formatFiles(tree)` (skip only when the migration touches no JS/TS/JSON surface).
- User-facing warnings via devkit `logger.warn`, listing the affected files, reserved for cases the user must finish manually. Never `console.*`. Agentic runs capture the generator's logger output and feed it to the validating agent (`<generator_output>`), so a good warning names the file and the exact remainder.
- Freeze version strings as local consts in the migration file, set to the value the plugin's generators install at authoring time. Never import them from the plugin's `utils/versions`: those constants float with every release, and users run the compiled migration shipped with whichever version they migrate to, so an imported constant installs that release's value instead of the one this migration intended (see the inline-with-rationale const in `packages/angular/src/migrations/update-23-1-0/add-istanbul-instrumenter.ts`). Exception: `nxVersion` when adding a sibling `@nx/*` package, which must float to match the version the workspace lands on. Never inline version literals at call sites, and do not derive the version from what the workspace already has installed unless matching the installed version is the point. An export added to `utils/versions` for a migration's sake also leaks into generator surfaces (`@nx/angular`'s `backward-compatible-versions.ts` type-requires an entry for every export, dead weight for a migration-only version).
- Fail open, never throw: a throwing migration aborts the user's whole `nx migrate --run-migrations` run with no resume. Distinguish the two skip reasons: a file the migration does not recognize is a normal no-match, skip it silently; a file it cannot parse is unfinished work, skip it and record the path in the returned `agentContext` (mirror it in `nextSteps` when the user must finish by hand). The source-transform prefilter sharpens the stakes: a file only reaches the parser when it contains the trigger token, so a swallowed parse error hides a file that needed migrating.
- Idempotent by construction: the rewrite consumes its own trigger, or the write is gated on `updated !== original`, or there is an explicit already-migrated guard. `nx repair` re-runs nx-core migrations unconditionally (minus `x-repair-skip`), and users re-run failed sessions.
- Never return a `GeneratorCallback` or install task; the return value contract is `void | string[] | { nextSteps, agentContext }` and callbacks are silently discarded. The runner handles installs by diffing package.json.
- A migration that skips a shape it cannot handle or leaves residual work returns `{ nextSteps, agentContext }`; this is not hybrid-only. Agentic runs hand `agentContext` to the agent that validates the generator's output and can finish minor in-scope remainders; `nextSteps` never reaches the agent, and `agentContext` is dropped in plain human runs (an outer agent driving `nx migrate` still receives it on stdout; see [runtime-contract.md](runtime-contract.md)). Put everything an agent needs to finish or verify the work (skipped files, why, the exact remaining edit) in `agentContext`, and mirror the human-actionable part in `nextSteps`.

### Source transforms

Exemplars: `packages/vite/src/migrations/update-23-0-0/migrate-to-vitest-4.ts` (copy its discovery and splicing, not its silent parse-failure skips; the common canon above requires reporting those in `agentContext`), `packages/angular/src/migrations/update-21-2-0/replace-provide-server-routing.ts`.

- Discovery, two shapes. File sets that derive from project or executor configuration: scope by the project graph (`forEachExecutorOptions`, dependency filtering), then visit each project root. User-owned config files matched by name (`tsconfig*.json`, tool rc files): scan the whole tree with `visitNotIgnoredFiles` from the root instead; name-matched files exist outside target references (a `tsconfig.editor.json` nothing points at), and the scan works even where project-graph construction fails. Either way, prefilter before parsing: check the filename or extension, then bail unless the content `.includes()` the trigger token.
- A migration generator's input is whatever users hand-write, not the shape our tooling emits. Enumerate the authoring shapes the edited construct can take (the generated form, the same options inlined by hand, wrapped/spread/aliased forms) and handle or negative-test each; classify files by their resolved import bindings, not by whole-content substring matches, which both over- and under-match (see `packages/cypress/src/migrations/update-23-1-0/disable-webpack-ct-just-in-time-compile.ts`: preset call and hand-written inline `devServer.framework`, binding-resolved).
- Parse with tsquery (`ast` + `query`) for selector-style lookups or the raw TypeScript API for structural checks. Use the AST only to LOCATE positions, then splice replacement text into the original content: devkit `applyChangesToString` (sorts and offsets the edits internally; see `packages/angular/src/migrations/update-23-0-0/rewrite-internal-subpath-imports.ts`) or a local splice helper like the exemplars above use. Never reprint a whole file through `ts.createPrinter`; it destroys the user's formatting.
- Property keys in user configs come single-quoted, double-quoted, or backtick-quoted: match `ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)` and preserve the original quotes when splicing a rename (see `packages/eslint/src/migrations/update-23-1-0/remove-removed-typescript-eslint-extension-rules.ts`).
- Load TypeScript lazily: `import type * as ts from 'typescript'` at the top plus `ensureTypescript()` (from `@nx/js/internal`) or `ensurePackage<typeof import('typescript')>('typescript', '*')` at first use. No static value import.
- For `.js` config files parse with `ts.createSourceFile(..., ScriptKind.JS)`.

### Config edits

- Project targets: `getProjects(tree)`, mutate, `updateProjectConfiguration` guarded by a changed flag. Never raw `updateJson` on `project.json`: it silently skips package.json-based projects.
- `getProjects` does not merge `targetDefaults` or inferred targets. A migration about an executor's options must also scan `nx.json` `targetDefaults` and, when the tool is also served by an inferred plugin, the plugin registration.
- nx.json: `readNxJson(tree)` / `updateNxJson(tree, nxJson)`, write only when changed. `targetDefaults` keys may be target names or executors and values may be objects or arrays; guard with `Array.isArray` and match array entries on both `entry.target` and `entry.executor` (plain `Object.entries` over the array appears to work by index keys while silently skipping `target`-keyed entries). Generator defaults come in two shapes (flat `"@nx/x:gen"` keys and nested `"@nx/x": { gen: {} }`); handle both.
- Plugin registrations are `string | ExpandedPluginConfiguration`; match with `typeof p === 'string' ? p === name : p.plugin === name`, preserve array order and per-entry include/exclude scopes, and gate registration on actual usage (glob the tool's config files first).
- Dual-world rule: a migration touching a tool's configuration handles executor-based targets, inferred-plugin registrations, and `targetDefaults` independently in one pass (exemplar: `packages/vite/src/migrations/update-23-0-0/ensure-vitest-package-migration.ts`; copy its scan, not its `GeneratorCallback` return type, which the canon above forbids).
- Before writing any detection or rewrite scan, enumerate the full surface that expresses the feature and cover or negative-test each part: the direct executors; the `@nx/*` wrapper executors that delegate to them (check the plugin's `executors.json`; a ported builder migration that stops at the upstream builder ids misses the Nx wrappers the port exists for); targets that reach the tool only through a referenced target (`dev-server`-style `buildTarget`/`browserTarget`, resolved through `targetDefaults` because raw project config does not merge them); and config-file presence where a project uses the tool with no matching executor (a `module-federation.config.*` remote whose host lives in another workspace). Exemplars: `packages/angular/src/migrations/update-23-1-0/add-optional-webpack-packages.ts` (indirection + config files; copy the scan, not its `utils/versions` import), `packages/angular/src/migrations/update-23-1-0/add-istanbul-instrumenter.ts` (wrapper executors).
- User-owned jsonc files (`tsconfig*.json` and other configs that may carry comments): `readJson`/`updateJson` strips comments and reformats the whole file. Locate and edit nodes with `jsonc-parser` (`modify` + `applyEdits`) so only the targeted span changes (exemplar: `packages/angular/src/migrations/update-23-1-0/remove-conflicting-extended-diagnostics.ts`). A migration that newly uses such a package must add it to the plugin's `package.json` dependencies (and to `allowedNonPeerDependencies` in `ng-package.json` for ng-packaged plugins).
- When the trigger is a resolved (inherited) setting, edit only the file that locally declares the offending block. Never mutate a shared or ancestor config based on one consumer's resolution: sibling projects extending the same base may resolve differently. The exemplar above removes `extendedDiagnostics` only where declared and leaves shared bases alone.
- When re-implementing a host tool's config resolution (tsconfig `extends` chains, ESLint config cascades), handle every input form the real resolver accepts, not just the common one: for `extends`, string and array forms (later entries win), package-specifier bases, and circular references. A resolver that only handles the string form silently mis-resolves the rest.
- Mirroring another plugin's canonical set (config file names, rule names, package lists) when the dependency direction forbids importing it: copy the owning plugin's list verbatim and cite the source in a comment; never reconstruct it from memory, which drops the rare members (see `packages/remix/src/migrations/update-23-1-0/remove-remix-eslint-config.ts`, a frozen copy of `@nx/eslint`'s config-file list with the source named in its comment).
- Ignore files: `addEntryToGitIgnore` (`packages/nx/src/utils/ignore.ts`, parses with the `ignore` package instead of substring matching); keep the `if (tree.exists('lerna.json') && !tree.exists('nx.json')) return` guard used by this family.
- Calling an Nx generator (scaffolding, not the migration itself) from a migration is sanctioned only for same-package generators via relative import, passing `keepExistingVersions: true` and `skipFormat: true` when the generator's schema declares them, so `packageJsonUpdates` keeps ownership of version bumps.
- A migration that materializes a removed option's effect writes it where the tool actually reads it: the tool's own config file, or a schema-declared executor option. Never an undeclared key in target options: most first-party executor schemas omit `additionalProperties`, so validation never rejects the key, and some executors read options absent from their schema and forward them into the tool's invocation in a way that overwrites rather than merges its config value (jest's `getExtraArgs` pushes each onto `process.argv`), so the written value and the tool's real config silently drift apart.

### Dependency updates

Declarative `packageJsonUpdates` first; a .ts implementation only for conditional logic. In groups: explicit `"alwaysAddToPackageJson": false` on bump-only packages; `requires` for gating; do not use `x-prompt` (deprecated) or `ifPackageInstalled` (a live runtime gate no first-party entry uses; gate with `requires`). To bump a package that ships its own migrations without triggering them (the `@angular/cli` pattern), set `ignorePackageGroup: true` and `ignoreMigrations: true` on that package's update.

When the bump targets a package this repo itself depends on (root `package.json` or a `pnpm-workspace.yaml` catalog entry), update the repo's own pin in the same change and run the install so the lockfile follows: the migration only fixes user workspaces.

A version-constant change is also a generator-output change: generator specs pin the value being written (`packages/next/src/generators/application/application.spec.ts` asserts the exact `eslint-config-next` range). Grep the old version string across the plugin's spec and snapshot files and run the owning package's suite in the same change.

### Prompt-only and hybrid

- The prompt .md is colocated in the `update-<ver>/` directory. Its filename must differ from any implementation basename: the `documentation` .md owns that name, and a prompt is the wrong genre for the documentation slot (the eslint flat-config runbook shipped as public docs exactly this way, under the docs site's former basename-guess rendering). Pattern to copy: jest's `set-ts-jest-isolated-modules` pairs `documentation: set-ts-jest-isolated-modules.md` with `prompt: verify-typecheck.md`.
- Naming: `ai-instructions-for-<framework>-<major>.md` for whole-framework upgrade runbooks; task-named files (`migrate-ban-types-rule.md`) for scoped tasks.
- Write the runbook per [templates/prompt-runbook.md](templates/prompt-runbook.md). Every scoped-task prompt opens with a no-op guard: confirm the preconditions, otherwise change nothing and stop (exemplar: `packages/eslint/src/migrations/update-23-1-0/migrate-ban-types-rule.md`).
- A prompt migrating a rule or option must also spell out the bare form (the rule enabled with no options): default-configuration users are the most common case and the easiest to leave unhandled.
- Do not put must-happen changes in a prompt: prompt-only migrations execute only under the agentic flow, and in plain runs they surface as next steps.
- Hybrid = one entry with both `implementation` and `prompt`. The .ts does only mechanically safe edits, accumulates human-readable descriptions of every shape it could not handle, and returns `{ nextSteps, agentContext }` per the common-canon channel split above. The .md tells the agent to verify (not redo) the pre-pass output and treat each advisory-context item as pending work. Exemplar: eslint `convert-to-flat-config` (copy its return contract, not its shared implementation/prompt basename, which predates the naming rule above).
- Re-delivering an existing prompt at a later version: add a new entry pointing at the same .md; the runner dedupes by path.

## 5. Test and validate

Spec canon (skeleton in [templates/spec-skeleton.md](templates/spec-skeleton.md)); a prompt-only entry gets no spec file, since there is no implementation to run and no harness exercises prompt .md content (its checks are the root `migrations.spec.ts` path validation, the conformance rules, and the real-repo run below):

- `createTreeWithEmptyWorkspace()` + `tree.write` / `addProjectConfiguration` to arrange; run the imported default export; assert with explicit reads (`readJson`, `tree.read(..., 'utf-8')`) using `toBe`/`toEqual`/`toContain` or `toMatchInlineSnapshot`. Never `toMatchSnapshot` (external snapshot files); no migration spec uses it.
- Mandatory negative test: capture the content, run the migration on a workspace it should not touch, assert the content is unchanged.
- Mandatory idempotency test when the trigger can survive: run the migration twice, assert the second run changes nothing.
- Mandatory malformed-input test when the migration parses files: feed an unparseable file and assert the migration skips it without throwing and reports the skipped path in the returned `agentContext`.
- Mandatory multi-edit test when the migration can rewrite several spots in one file: one fixture with all rewrite shapes in the same block, asserting adjacent edits do not corrupt each other's offsets.
- Mandatory precedence test when the migration resolves an inherited setting: one fixture where a local declaration differs from the inherited value, asserting the nearest declaration wins.
- Mandatory list-sanity test when the migration freezes a copy of another module's canonical set: per-member cases (the `symbol set sanity` block in `packages/devkit/src/migrations/update-23-0-0/update-deep-imports.spec.ts`; copy the spec pattern, not the migration's own `utils/versions` import) prove listed members are handled, not that the list is complete. Add a drift check: read the owning module's source with `fs.readFileSync` at a path relative to the spec file (via `__dirname`, not `process.cwd()`; not a live import; the frozen copy exists to survive that module changing later, same-package or not), extract export names with tsquery over `ExportDeclaration`/`ExportSpecifier` rather than a regex (a multi-line `export { a, b } from '...'` block is exactly what a naive scan misses), and diff them against the frozen list.
- Mandatory reproduced-behavior test when the migration statically replicates behavior the same change deletes from a runtime path (a merge, a default, a path expansion): diff the replacement against the deleted code case by case and cover each case it exercised; a helper reused from another context usually differs at the edges (resolution roots, `rootDir` handling, option precedence). Treat an incidental gap in the deleted code (a mode it silently skipped) as a decision to make: keep it out of the replacement only with a stated reason, a code comment or a returned next-step, not silently by omission.
- Any migration returning `{ nextSteps, agentContext }` (hybrid or not): `const result = await migration(tree)` and assert on both channels.

Run the repo validators; they must pass:

- `npx nx run-many -t test,lint -p <plugin>`: the root `migrations.spec.ts` (`assertValidMigrationPaths`) resolves every entry's implementation/prompt/documentation path against the source tree and flags orphaned entry-point .ts files and orphaned .md files; lint runs `@nx/nx-plugin-checks`, which validates manifest shape and flags duplicate keys.
- `npx nx build workspace-plugin && pnpm nx-cloud conformance:check`: the `migration-markdown-assets` rule checks the published shape (each referenced .md is actually produced into the built output, each implementation path maps back through the build's `rootDir`/`outDir` to a real source file); `migration-groups` keeps `packageJsonUpdates` package families complete within a group (all `@typescript-eslint/*` bumped together); `nx-package-group` checks packageGroup membership for new plugins.

What no validator checks: whether a path names the RIGHT file (a wrong-but-existing implementation path passes everything and runs at run time; this shipped as a real bug in `packages/nx`), version and train semantics, `requires` fit, spec coverage, and .md claim accuracy. The pre-PR checklist below covers exactly that judgment residue.

Before release, validate against a real repository:

- Local registry: `pnpm local-registry` in one shell; in another, `npm adduser --registry http://localhost:4873` (real credentials are not required, e.g. test/test/test@test.io; publishing just needs a login), then `pnpm nx-release <next-prerelease> --local` to build and publish; then in the target repo run `NX_SKIP_PROVENANCE_CHECK=true npx nx migrate <version>` (locally published packages have no provenance attestations; without the variable migrate fails).
- Registry-free alternative: build and install the plugin tarball in the target repo, write a migrations file `{ "migrations": [{ "package", "name", "version" }] }`, and run `npx nx migrate --run-migrations=<file>`. No version-window or provenance checks on this path.

## 6. Docs and description

- `description` feeds the agentic prompt and the public docs page. State the concrete action ("Removes the deprecated X option from Y executor options"). For prompt migrations, also state why it is AI-driven ("...whose options do not map 1:1, so it is driven by an AI prompt rather than a deterministic generator").
- Every new entry gets a colocated `documentation` .md, set on the entry's `documentation` key, per [templates/documentation-md.md](templates/documentation-md.md): before/after samples for generator-based migrations, what-the-upgrade-involves for prompt migrations (`upgrade-to-<framework>-<major>.md`); h4/h5 headings only, sentence case, and prose per `astro-docs/STYLE_GUIDE.md` (the content renders on nx.dev; vale does not lint these files today, so self-check). The key feeds both consumers: the agentic flow hands the agent its path, and the docs site renders its content on the plugin's migrations page (nothing is inferred from the implementation's basename). A prompt .md never doubles as documentation: it is agent-voiced, wrong audience (see `packages/react/migrations.json` `update-23-1-0-create-ai-instructions-for-react-19`, which pairs both).
- Before finishing, re-read every claim in the .md files against the implementation as written: version selection, trigger conditions, file coverage, and option lists must describe what the code actually does, not an earlier draft's design. Doc text written before a design change is the easiest artifact to leave stale. Scope claims drift most: a "handles X" sentence written while the code handles one shape of X. Back every handles/covers claim with the spec case that exercises it; if none exists, narrow the claim or add the test.
- Verify tool-behavior claims (deprecation timelines, option semantics, error codes) against the tool's source or changelog in `node_modules` before putting them in a .md. These files ship to users, and a wrong version claim reads as authoritative long after review. Compatibility claims (which versions of X work with Y) come from the published package's machine-readable metadata (`peerDependencies`, `engines`), never from upstream prose; guides state the recommended pairing, the metadata states the supported range (Next 15's guide reads as requiring React 19 while `next@15` peers `react: ^18.2.0 || ^19.0.0`).

## 7. Pre-PR checklist

The section-5 validators gate the mechanical layer (paths resolve, no orphans, no duplicate keys, published shape, packageGroup). This list is the judgment residue no validator covers:

- [ ] Validators green: `npx nx run-many -t test,lint -p <plugin>` and the conformance check (section 5).
- [ ] Entry key slug-bearing, following the file's dominant key form (full `update-<ver>-<slug>` in a new file); any version part in the key is a release-level hint only, not required to match the `version` field (often a prerelease).
- [ ] `implementation` points at THIS migration's file: open the file and confirm. Validators check that referenced paths exist, never that they name the right migration.
- [ ] `implementation` used, not `factory`; no `cli`, no `schema` (legacy keys the validators accept).
- [ ] Version is the exact next prerelease of the target train the developer chose (asked once when the task did not state it); `requires` reviewed against landing versions (no upper bound that encodes the source window; one is valid only when the migration is inapplicable at or above it, per section 2), against alternative package names (umbrella vs scoped: no single-name gate), and for fit (gate present only when the migration's behavior depends on that package's version); a fix to an already-shipped migration re-stamps the version to that train's next prerelease so workspaces that ran the broken version re-run it.
- [ ] Spec covers every applicable mandatory case from section 5 (negative always; idempotency, malformed-input, multi-edit, precedence, list-sanity, reproduced-behavior when their triggers apply); specs assert the return object when the migration returns one; prompt-only entries have no spec; `formatFiles` called.
- [ ] Detection covers the full expression surface (section 4): `@nx/*` wrapper executors, referenced-target indirection, config-file signals, both `targetDefaults` shapes.
- [ ] Version-constant changes: old value grepped out of every spec/snapshot; owning package's suite run. No `utils/versions` imports in migration files (`nxVersion` for sibling `@nx/*` adds excepted).
- [ ] .md files colocated; the prompt filename differs from the implementation basename; every new entry sets `documentation`.
- [ ] .md claims (version selection, triggers, coverage) re-checked against the final implementation; each coverage claim backed by a spec case.
- [ ] First migration in a plugin: the section-3 wiring list done (`nx-migrations` in package.json, `$schema`, `assets.json` .md glob, root `migrations.spec.ts`, `@nx/nx-plugin-checks` on migrations.json, `packageGroup` for a brand-new plugin).
- [ ] PR description carries the `## Migration coverage` mapping.
- [ ] Real-repo validation done or explicitly handed off.
