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.)
| Key | Runtime behavior |
|---|
version | Gate: 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. |
description | Shown in run listings and the docs page; rendered inside the <migration> block of the agentic prompt. |
implementation / factory | Equivalent 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). |
requires | Map 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. |
prompt | Relative 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. |
documentation | Relative 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.