author-migration

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.

Install
npx skills add 'https://github.com/nrwl/nx/tree/master/.claude/skills/author-migration'
Download bundle ↓
master · 646e806Scanned 2026-09-17

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗
View on GitHub
← Back to SKILL.md

migrations.json runtime contract

How nx migrate actually consumes each key. Source of truth: packages/nx/src/command-line/migrate/migrate.ts (the Migrator class and runMigrations), packages/nx/src/command-line/migrate/prompt-files.ts, and the types in packages/nx/src/config/misc-interfaces.ts (MigrationsJsonEntry, MigrationReturnObject, PackageJsonUpdates). Verify against those files when in doubt; line references rot, symbol names do not.

Migration entries (generators section)

New entries always go under generators. Entries under schematics run through the Angular Devkit adapter: at run time the installed package's migrations.json is re-read unmerged and the section holding the entry selects the runner. (The fetch phase folds both sections into one map, but that only feeds gating, not runner selection.)

KeyRuntime behavior
versionGate: entry collected when gt(version, installed) && lte(version, target) after normalizeVersion. Prerelease ordering applies (beta.N < rc.N < stable). Strict gt on the installed side: a user already at that exact prerelease never runs it.
descriptionShown in run listings and the docs page; rendered inside the <migration> block of the agentic prompt.
implementation / factoryEquivalent aliases; implementation wins when both are set and is the one to author. Resolved with require.resolve relative to the installed package's migrations.json directory, so the path must match the PUBLISHED layout (dist-prefixed). #symbol selects a named export, otherwise the default export. Called as await fn(tree, {}). Beware: nothing ties the path to the entry. A wrong-but-existing path resolves and RUNS at run time (getImplementationPath just calls require.resolve), passes assertValidMigrationPaths (it requires the source file directly), and passes @nx/nx-plugin-checks (whose resolveImplementation even guesses source layouts).
requiresMap of package name to semver range, evaluated with includePrerelease: true against the version the package will land on in THIS run (pending packageJsonUpdates first, installed version as fallback). Package absent from both = gate fails. An entry skipped this way does not re-run in the default flow; the catch-up path is --from + --exclude-applied-migrations. Evaluated once at collection and never re-checked at execution: the generated migrations file carries the full entry, but the run path never re-evaluates requires.
promptRelative path to a colocated .md, validated to stay inside the migrations directory. At generate time the content is extracted to tools/ai-migrations/<package>/<targetVersion>/<basename>.md and the field is rewritten to that workspace path. At run time, prompt-only entries execute only under the agentic flow (an agent CLI is spawned and handed the extracted workspace path in <instructions_file>, never inlined content); otherwise they are surfaced as next steps. Hybrid entries (implementation + prompt) always run their generator half, and skip the prompt half entirely when that half returns skipAgentic: true (see Return values). Prompts are deduped by path across entries. At least one of implementation/factory/prompt is required; validated at fetch time.
documentationRelative path to a colocated .md, resolved like implementation. At migrate time, --run-migrations reads it only during agentic runs and hands it to the agent running the prompt or the validation pass (<migration_documentation>, marked reference-not-instructions); --run-migration also prints it in the prompt block a plain run shows the user. The content is never inlined into the prompt. A stale path logs a warning and is skipped. The docs site renders the same key's .md on the plugin's migrations page; nothing is inferred from the implementation's basename.

Do not author: cli (dead since the runner-by-section change; schema marks it "No longer used"), schema (documented in the JSON schema but never read by the runtime), x-repair-skip unless the migration is an nx-core migration that must not re-run under nx repair (repair re-runs ALL nx-core migrations regardless of version).

Collection scope: installed versions resolve by node resolution from the workspace root (createInstalledPackageVersionsResolver -> readModulePackageJson), not from package.json entries. A package-group member that is node-resolvable from the root (for example a hoisted transitive dependency) still has its migrations collected and gated; one that does not resolve is skipped even when workspace code imports it (pnpm-style isolated layouts keep transitive packages on disk but not root-resolvable).

Return values

Migration = (tree) => void | string[] | MigrationReturnObject | Promise<...>.

  • string[] is shorthand for nextSteps.
  • nextSteps: shown in the end-of-run summary and failure recaps; persisted by Nx Console; never included in agent prompts. The channel for anything a human must do.
  • agentContext: injected into the agent prompt as <advisory_context> during agentic runs, including the validation step after generator-only migrations (execution model below); when nx migrate itself runs inside an outer agent it is instead printed to stdout in <agent_context> blocks for that agent. Dropped in plain human runs, so human-relevant content must be duplicated into nextSteps.
  • skipAgentic: opt-in true telling the runner the deterministic run left nothing for an AI step, so it skips the one it would otherwise run: a hybrid's prompt phase, or the validation step after a generator-only migration. It also stops the user being told to run a prompt nobody owes: under --run-migrations the skipped hybrid prompt produces no deferred/next-steps entry, and the --run-migration worker prints no prompt block for it. The end-of-run recap counts it as N AI step(s) not needed, but only under --run-migrations; the worker prints no recap, so there the waiver surfaces through the skip line rather than a tally. Read strictly (=== true), so a truthy non-boolean does not opt out. Returning it together with agentContext contradicts itself; where the waiver takes effect the runner drops agentContext and notes it only under --verbose. For a hybrid it is recorded next to the acknowledgement that marks the prompt phase complete, and the migrate UI reads it for the label it shows there, AI step not needed.
  • Anything else (including a GeneratorCallback) is silently discarded. The runner installs by diffing package.json: once after the whole run in the default flow, per migration under --create-commits and agentic runs. Never return install tasks and never call installPackagesTask.

Execution model

  • Tree changes flush to disk only after the migration function returns. A child process spawned inside a migration sees pre-migration disk state.
  • The first throwing migration aborts the whole --run-migrations run; there is no resume state. Fail open.
  • nx repair re-runs every nx-core migration regardless of version (minus x-repair-skip), so nx-core migrations must be idempotent.
  • Agentic runs validate generator output: unless the user passes --no-validate or the migration returns skipAgentic: true, a generator-only migration that produced changes gets an agent validation step. The agent receives the entry description, the documentation path, the captured generator output (devkit logger and console, <generator_output>), the changed files, and any returned agentContext; it verifies the result and may apply minor in-scope fixes. A failed validation leaves the changes uncommitted.
  • Entries in the schematics section run through the Angular Devkit adapter, which discards their return value entirely: no nextSteps, no agentContext.

packageJsonUpdates

Shape: { "<key>": { version, packages, requires?, incompatibleWith?, "x-prompt"? } } with per-package { version, alwaysAddToPackageJson?, addToPackageJson?, ifPackageInstalled?, ignorePackageGroup?, ignoreMigrations? }.

  • A group applies when installed <= group.version <= target (inclusive lower bound, unlike migration entries).
  • Only packages already in dependencies/devDependencies are touched unless addToPackageJson/alwaysAddToPackageJson is set (true = dependencies, string = that section; alwaysAddToPackageJson wins). Across groups the highest version per package wins; downgrades are filtered at write time.
  • Groups are evaluated in key order, and each accepted group writes into the pending update set that later gate checks read. A group held by requires/incompatibleWith is not discarded: after the initial pass, held groups are re-evaluated until no further group applies, so a gate satisfied by a group evaluated later — including another plugin's — still lands. Each group applies at most once. Order ladder groups oldest source major first so multi-major chains work.
  • incompatibleWith inverts requires: the group is skipped when any listed package's landing version satisfies the range.
  • ifPackageInstalled gates a single package's update on another package being installed; no first-party group uses it (gate with requires instead).
  • x-prompt fires only under --interactive outside CI and is deprecated for removal in Nx v24; do not add it.
  • ignorePackageGroup: true + ignoreMigrations: true on a per-package update bumps that package without pulling in its own package group or migrations (used for @angular/cli).
  • <version>--PackageGroup keys are synthesized at runtime from the plugin's packageGroup; never author one.
  • The group key is user-visible (docs anchor in the interactive prompt footer): X.Y.Z or X.Y.Z-<topic> for separately gated third-party bumps.

package.json migrate config

readNxMigrateConfig reads, in increasing precedence: ng-update, nx-migrations, bare top-level fields. First-party plugins declare "nx-migrations": { "migrations": "./migrations.json", "supportsOptionalMigrations": true }; ng-update survives only for Angular CLI interop (ng update reads it). packageGroup membership (authored under nx-migrations in packages/nx/package.json and under ng-update in packages/workspace/package.json) determines both the synthetic group bump and the required side of the --include required/optional partition; there is no per-entry optionality marker.

Referenced from SKILL.md
SKILL.mdView in source ↗
Source excerpt starting at line 22.
- [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.
SKILL.mdView in source ↗
Source excerpt starting at line 56.
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)).
SKILL.mdView in source ↗
Source excerpt starting at line 80.
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).
SKILL.mdView in source ↗
Source excerpt starting at line 91.
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)).
SKILL.mdView in source ↗
Source excerpt starting at line 119.
- Never return a `GeneratorCallback` or install task; the return value contract is `void | string[] | { nextSteps, agentContext, skipAgentic }` 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`.- `skipAgentic: true` is the opposite signal: the deterministic run covered everything, so `nx migrate` skips the AI step it would otherwise run (a hybrid's prompt phase, or the validation step after a generator-only migration). Return it only when the migration can prove there is nothing left, typically its own no-op guard: the workspace does not use the feature, or it was already migrated. Never pair it with `agentContext`: that context exists to feed the AI step you just waived, so where the waiver takes effect the runner drops it. A hybrid's prompt is owed in every mode, so waiving it also drops the stdout hand-off to an outer agent noted above. Omitting it keeps today's behavior, so an existing migration is unaffected until it opts in.