# 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.
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 ``.
How to read the wrapper sections above this file:
- `` 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).
- `` 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.
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").
## 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 --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 ` 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 ``).
**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 ``).
**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 `` (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 "" in plugin ""` / `couldn't find the config "" 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.
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.
## 5. Fix removed output formatters
**Search pattern**: the `format` option on lint targets (forwarded in ``).
**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 `@/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
```
4. Confirm no `.eslintrc.*` files remain unless one was intentionally kept.
## References
- ESLint configuration files (flat config): https://eslint.org/docs/latest/use/configure/configuration-files
- Migrate to ESLint v9.0.0: https://eslint.org/docs/latest/use/migrate-to-9.0.0
- typescript-eslint configs: https://typescript-eslint.io/users/configs
- Nx ESLint plugin: https://nx.dev/nx-api/eslint