Rédaction de migration Nx

Rédiger ou définir une migration propriétaire Nx. Utilisé lorsque du code supprime, renomme ou déprécie des options/indicateurs/exécuteurs/champs de schéma, modifie une valeur par défaut ou met à jour une dépendance.

Spar Skills Guide Bot
DeveloppementAvancé
0025/07/2026
Claude CodeCursorWindsurfCopilotCodex
#nx#migration#generator#breaking-change#authoring

Recommandé pour


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: 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: patterns to never reproduce, with recognition signatures. Read it before copying from an existing migration or from git history.
  • 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).

  • 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 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 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, resolution in 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.

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). 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. 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); 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: 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.
Skills similaires