Back to Nx

ESLint v9 Flat Config Migration Instructions for LLM

packages/eslint/src/migrations/update-23-1-0/convert-to-flat-config.md

23.1.014.3 KB
Original Source

ESLint v9 Flat Config Migration Instructions for LLM

Overview

These instructions guide you through finishing the migration of an Nx workspace to ESLint v9.

ESLint v9 makes flat config (eslint.config.{mjs,cjs,js}) the default config format. The legacy eslintrc format (.eslintrc.*) still works at runtime, but only when ESLINT_USE_FLAT_CONFIG=false is set, so Nx converts workspaces to flat config instead of relying on that escape hatch.

The migration runs in two halves:

  1. A deterministic pre-pass (the @nx/eslint:convert-to-flat-config generator) that already converted the JSON and YAML eslintrc configs.
  2. This prompt: finish the parts that need judgment and leave the workspace lint-passing.

Work systematically through each section below.

<pre_pass_summary note="a deterministic pre-pass already applied these edits; verify the new shape is in place rather than redoing them">

The pre-pass handled, mechanically:

  • Converted the root and per-project JSON/YAML eslintrc files to eslint.config.mjs:
    • eslint:recommended to js.configs.recommended
    • @nx/* presets to their flat-config equivalents
    • env to languageOptions.globals
    • parser / parserOptions to languageOptions
    • plugins to the flat plugins object
    • ignorePatterns and .eslintignore to ignores
    • stale .eslintrc/.eslintignore references in nx.json and project.json inputs
  • Added @eslint/js and @eslint/eslintrc to package.json when the converted config needs them.

The pre-pass does NOT:

  • Convert JavaScript-based eslintrc files (.eslintrc.js, .eslintrc.cjs). It cannot evaluate them safely.
  • Change the output formatter a lint target uses.
  • Decide whether a generated FlatCompat shim should become flat-native config.
  • Make the workspace pass lint after ESLint v9 changed which rules its preset defaults enable.

Everything the pre-pass could not finish is forwarded to you in <advisory_context>.

How to read the wrapper sections above this file:

  • <files_changed> lists files the pre-pass wrote. Verify the new shape is in place; do not re-apply the same edit. It is absent when the pre-pass made no changes (for example a workspace that was already on flat config).
  • <advisory_context> lists detections the pre-pass forwarded because it could not safely complete them. Every entry is pending work. Address each one in the relevant section below.

</pre_pass_summary>

<handoff_guidance> In your handoff summary (1 to 3 sentences per the system prompt), name the sections you applied and explicitly call out any you skipped because they did not apply (for example "no JavaScript-based configs and no removed formatters in this workspace"). </handoff_guidance>

Pre-Migration Checklist

  1. Confirm the ESLint version is v9:

    bash
    npx eslint --version
    
  2. Locate all ESLint config files:

    • Flat configs: eslint.config.{mjs,cjs,js} at the root and in each project.
    • Any remaining eslintrc files: .eslintrc, .eslintrc.json, .eslintrc.yaml, .eslintrc.yml, .eslintrc.js, .eslintrc.cjs.
    • Ignore files: .eslintignore.
  3. Identify all lint targets:

    bash
    nx show projects --with-target lint
    

    Check project.json files for the @nx/eslint:lint executor or eslint run-commands. Workspaces using the inferred plugin (@nx/eslint/plugin) get lint targets from the presence of eslint.config.*; inspect them with nx show project <name> --json.

  4. Identify local ESLint rules or plugins authored inside the workspace. These use the rule API that changed in v9 (see section 6).


Nx-Specific Notes (read first)

  • Flat config is the default in v9. eslintrc only resolves when ESLINT_USE_FLAT_CONFIG=false is set. Nx converts the workspace to flat config so that no environment variable is required.
  • Shared base config pattern: many Nx workspaces have a root eslint.config.mjs that each project imports, for example import baseConfig from '../../eslint.config.mjs'. Convert and verify the base config first, then the per-project configs.
  • Inferred plugin targets: @nx/eslint/plugin infers the lint target from the presence of eslint.config.*. Renaming or moving the config invalidates inference. After config edits, run nx reset && nx show project <name> on a sample project to confirm the target is still present.
  • FlatCompat shim: when the pre-pass could not translate a third-party extends or a complex override natively, it emitted a FlatCompat shim (from the @eslint/eslintrc package). That config works as-is, but section 3 covers replacing it with flat-native config where low-risk.

1. Already on flat config? Verify only

If the workspace already uses eslint.config.* at the root and in every project, with no remaining .eslintrc.* files, do NOT restructure it. The only required work is the passing-state check in section 4: a workspace on ESLint v9 can newly fail because v9 and typescript-eslint v8 changed which rules their recommended sets enable, even when the config was already flat.

2. Convert JavaScript-based ESLint configs the pre-pass skipped

Search pattern: .eslintrc.js and .eslintrc.cjs files (forwarded in <advisory_context>).

What changed: the pre-pass only converts JSON and YAML eslintrc files. JavaScript-based configs run arbitrary code, so they need manual conversion.

js
// BEFORE (.eslintrc.js)
module.exports = {
  extends: ['../../.eslintrc.json'],
  overrides: [
    {
      files: ['*.ts'],
      rules: { '@typescript-eslint/no-explicit-any': 'error' },
    },
  ],
};
js
// AFTER (eslint.config.mjs)
import baseConfig from '../../eslint.config.mjs';

export default [
  ...baseConfig,
  {
    files: ['**/*.ts'],
    rules: { '@typescript-eslint/no-explicit-any': 'error' },
  },
];

Action items:

  • Convert each JavaScript-based config to eslint.config.mjs, mirroring the structure the pre-pass produced for the JSON/YAML configs.
  • Preserve the existing rules, plugins, parser options, and overrides.
  • Delete the original .eslintrc.js / .eslintrc.cjs once the flat config replaces it.
  • Update any project.json / nx.json inputs that referenced the old file name.

3. Convert FlatCompat shims to flat-native config where low-risk

Search pattern: FlatCompat, @eslint/eslintrc, compat.extends(, compat.config( in the generated eslint.config.* files (listed in <files_changed>).

What changed: FlatCompat is a runtime shim that adapts eslintrc-style extends into flat config. Many plugins now ship native flat presets, which are clearer and avoid the shim.

Decision rule: convert a FlatCompat usage to flat-native config when it is low-risk, otherwise keep the shim.

  • Low-risk (prefer flat-native): typescript-eslint configs, and plugins that document a flat preset (for example eslint-plugin-react, eslint-plugin-import).
  • Keep the shim: third-party shared configs that do not document a flat-config entry point.
js
// BEFORE (FlatCompat shim, eslint.config.mjs)
import js from '@eslint/js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { FlatCompat } from '@eslint/eslintrc';

const compat = new FlatCompat({
  baseDirectory: dirname(fileURLToPath(import.meta.url)),
  recommendedConfig: js.configs.recommended,
});

export default [...compat.extends('plugin:@typescript-eslint/recommended')];
js
// AFTER (flat-native, eslint.config.mjs)
import tseslint from 'typescript-eslint';

export default [...tseslint.configs.recommended];

Action items:

  • For each FlatCompat usage, decide flat-native vs keep-the-shim using the rule above.
  • When converting, drop the now-unused @eslint/eslintrc import if no shim remains in that file.
  • Re-run lint after each change to confirm the rule set did not silently shift.

4. Restore the passing baseline (required)

This is the core requirement of the migration: the workspace must lint cleanly when you are done.

ESLint v9 and typescript-eslint v8 changed which rules their recommended sets enable. A rule the user never configured may now report errors. Disable those rules; do not edit source files to satisfy them.

The set of rules the user explicitly configured before the migration is in <advisory_context> (the entry that starts with "Passing-state requirement").

Procedure:

  1. Run lint across the workspace:

    bash
    nx run-many -t lint
    
  2. Tell a rule violation apart from a plugin crash. If a project fails with a thrown error instead of rule findings - a TypeError such as context.getAncestors is not a function, a Could not find "<rule>" in plugin "<name>" / couldn't find the config "<name>" to extend from, or a plugin that fails to load - the plugin predates ESLint v9; this is not a changed preset default. Do NOT disable the rule (that silently drops its coverage); update the plugin instead (section 6), then re-run lint and continue.

  3. For each rule that now reports errors (findings, not a thrown error):

    • If the rule ID is NOT in the user's explicit list, it came from a changed preset default. Disable it in the relevant flat config with a short comment explaining why.
    • If the rule ID IS in the user's explicit list, the user chose it. Leave it as-is and report it in your handoff summary.
js
// Disable a rule that a changed preset default newly enabled (eslint.config.mjs).
export default [
  ...baseConfig,
  {
    files: ['**/*.ts'],
    rules: {
      // Newly enabled by the ESLint v9 recommended set; was not enforced before the upgrade.
      'no-unused-expressions': 'off',
    },
  },
];

Action items:

  • Run lint and collect every newly reported rule.
  • Treat a plugin crash (a thrown error, not rule findings) as a version incompatibility: update the plugin (section 6), never disable its rules to silence it.
  • Disable preset-originated rules that the user did not configure.
  • Never disable or weaken a rule the user explicitly configured.
  • Never edit source files to satisfy a newly enabled rule.

<fail_if note="if you cannot reach a passing state without editing source or disabling a user-configured rule, stop and report"> You cannot make lint pass without either editing source files or disabling a rule the user explicitly configured. Write status: failed and explain which rule and project in your summary. Do not guess. </fail_if>

5. Fix removed output formatters

Search pattern: the format option on lint targets (forwarded in <advisory_context>).

What changed: ESLint v9 removed several built-in output formatters. The built-ins that remain are stylish, html, json, and json-with-metadata. Removed: compact, codeframe, unix, visualstudio, table, checkstyle, jslint-xml, junit, tap.

Fix: switch the target to a built-in that remains, or install the matching community package and reference it by its package name.

bash
# Example: keep junit output by installing the community formatter package.
npm install --save-dev eslint-formatter-junit
jsonc
// project.json (reference the community formatter by package name)
"lint": {
  "executor": "@nx/eslint:lint",
  "options": { "format": "eslint-formatter-junit" }
}

Action items:

  • For each flagged target, switch to a remaining built-in formatter or a community package.
  • When using a community package, add it to devDependencies.

6. Other ESLint v9 runtime breaking changes

Search pattern: lint executor options, run-commands invoking eslint, and local rule/plugin source.

  • Removed CLI flags and executor options: --rulesdir, --ext, and --resolve-plugins-relative-to were removed. The matching @nx/eslint:lint options (rulesdir, resolvePluginsRelativeTo, ignorePath) are not supported for flat config. Move file targeting into the config via the files and ignores keys.
  • No eslintrc auto-merge: flat config does not merge .eslintrc.* files found up the tree. Every setting must live in eslint.config.*.
  • Third-party plugins that predate ESLint v9: an installed ESLint plugin that was not updated for v9 breaks at lint time - its rules call the removed context APIs below, or it only ships an eslintrc config that no longer loads. This surfaces as a thrown error (see section 4), not a new rule violation. List the installed plugins from package.json (dependencies/devDependencies matching eslint-plugin-* or @<scope>/eslint-plugin-*), and for each confirm its version supports ESLint v9 (its changelog, or that peerDependencies.eslint allows >=9). Update any that do not, and prefer the plugin's flat entry point where it ships one (for example eslint-plugin-cypress/flat). Update the plugin rather than disabling its rules.
  • Local rule API moved to SourceCode (only relevant if the workspace authors its own rules):
    • context.getScope() to sourceCode.getScope(node)
    • context.getAncestors() to sourceCode.getAncestors(node)
    • context.getDeclaredVariables() to sourceCode.getDeclaredVariables(node)
    • context.markVariableAsUsed(name) to sourceCode.markVariableAsUsed(name, node)
    • context.getSource() to sourceCode.getText()
    • context.parserServices to sourceCode.parserServices
  • Stricter rule schema: a custom rule that accepts options must declare meta.schema in v9.

Action items:

  • Remove unsupported CLI flags and executor options; move targeting into files / ignores.
  • Update any installed third-party ESLint plugin that does not yet support ESLint v9, preferring its flat entry point; do not disable its rules to work around a load error.
  • Update local rules to the SourceCode API and add meta.schema where required.

Post-Migration Verification

  1. Clear the inference cache so renamed configs are re-detected:

    bash
    nx reset
    
  2. Confirm lint passes across the workspace:

    bash
    nx run-many -t lint
    
  3. Spot-check that a converted project resolves its config:

    bash
    npx eslint --print-config <a-file-in-the-project>
    
  4. Confirm no .eslintrc.* files remain unless one was intentionally kept.

References