# Migrating to ESLint 9 Flat Config

This guide covers migrating from ESLint 8 (legacy `.eslintrc` format) to ESLint 9 (flat config) for projects using `@commercetools-frontend/eslint-config-mc-app`.

> **AI agents**: This document is structured as step-by-step instructions that can be followed automatically. Read the existing config files before generating new ones.

## Key concept: no more config cascading

In legacy ESLint, `.eslintrc.*` files in subdirectories automatically merged with parent configs. **Flat config does not cascade.** ESLint 9 reads only the root `eslint.config.js`. Any `.eslintrc.*` files in subdirectories are silently ignored.

This means every subdirectory `.eslintrc.*` must be explicitly migrated into the root config (or into files imported by the root config). Missing this is the most common migration mistake — your subdirectory rules will silently stop being enforced.

## Step 1: Discover and analyze existing config

Find **all** ESLint-related files in the project:

```bash
find . -name '.eslintrc*' -not -path '*/node_modules/*'
find . -name '.eslintignore' -not -path '*/node_modules/*'
```

For **each** `.eslintrc.*` file found (root and subdirectories), extract: `extends`, `plugins`, `rules`, `overrides` (with their `files`, `excludedFiles`, `parser`, `parserOptions`, `plugins`, `rules`), `env`, `globals`, `settings`, and any `process.env` assignments before `module.exports`. Also collect `.eslintignore` patterns and note `"type": "module"` fields in `package.json`.

**Legacy cascade behavior**: A child config completely replaces the parent's value for the same rule key (e.g., if root sets `no-restricted-imports` with `paths` and a subdirectory sets it with `patterns`, the subdirectory version wins entirely). Replicate this override behavior in flat config.

## Step 2: Plan subdirectory config strategy

If subdirectory `.eslintrc.*` files were found, **ask the user which approach they prefer**:

### Option A: Subdirectory files imported by root (recommended for monorepos)

Each subdirectory exports a flat config array with **root-relative** `files` patterns. The root imports and spreads them.

```js
// packages/my-app/src/eslint.config.cjs
module.exports = [
  {
    files: ['packages/my-app/src/**/*.{js,jsx,ts,tsx}'],
    rules: { 'my-rule': 'error' },
  },
];
```

```js
// eslint.config.js (root)
const myAppConfig = require('./packages/my-app/src/eslint.config.cjs');
module.exports = [...mcAppConfig, ...myAppConfig];
```

> **Use `.cjs` extension** for subdirectory config files. If any package has `"type": "module"` in its `package.json`, a `.js` file will be treated as ESM and `module.exports` will fail.

### Option B: Inline in root config

All rules defined directly in root `eslint.config.js` with directory-scoped `files` patterns. Simpler for small projects.

## Step 3: Create `eslint.config.js`

### Base structure

```js
// Preserve any process.env assignments from the old config
process.env.ENABLE_NEW_JSX_TRANSFORM = 'true';

// Plugins are now imported as objects, not strings
const somePlugin = require('some-eslint-plugin');
const mcAppConfig = require('@commercetools-frontend/eslint-config-mc-app');

module.exports = [
  // Ignores replace .eslintignore (directory patterns end with /)
  { ignores: ['dist/', 'build/'] },

  // Spread the base config (replaces "extends")
  ...mcAppConfig,

  // Top-level rule overrides become a config object after the spread
  {
    files: ['**/*.{js,jsx,ts,tsx}'],
    rules: { 'no-console': 'warn' },
  },
];
```

### Converting `plugins`

Plugins registered as strings become imported objects:

```js
// Before: plugins: ['@graphql-eslint']
// After:
const graphqlPlugin = require('@graphql-eslint/eslint-plugin');
// plugins: { '@graphql-eslint': graphqlPlugin }
```

### Converting `overrides`

Each `overrides` entry becomes a separate object in the config array:

```js
// Before (legacy):
overrides: [{ files: ['**/*.graphql'], parser: '@graphql-eslint/eslint-plugin',
  parserOptions: { graphQLConfig: { schema: './schema.json' } },
  rules: { '@graphql-eslint/known-type-names': 'error' } }]

// After (flat config):
{
  files: ['**/*.graphql'],
  plugins: { '@graphql-eslint': graphqlPlugin },
  languageOptions: {
    parser: graphqlPlugin,
    parserOptions: { graphQLConfig: { schema: './schema.json' } },
  },
  rules: { '@graphql-eslint/known-type-names': 'error' },
}
```

> **Glob patterns must include `**/`for subdirectory matching.** In legacy config,`files: ['*.foo.js']`matched anywhere. In flat config, it only matches the root directory. Always prefix with`\*\*/`.

### Key property mappings

| Legacy (`.eslintrc`)               | Flat config (`eslint.config.js`)                          |
| ---------------------------------- | --------------------------------------------------------- |
| `parser` (string)                  | `languageOptions.parser` (imported module)                |
| `parserOptions`                    | `languageOptions.parserOptions`                           |
| `env: { browser: true }`           | `languageOptions.globals` (use the `globals` npm package) |
| `globals: { myVar: 'readonly' }`   | `languageOptions.globals: { myVar: 'readonly' }`          |
| `plugins: ['name']` (string array) | `plugins: { name: importedPlugin }` (object)              |
| `excludedFiles`                    | `ignores` (within the same config object)                 |

### Plugin scoping: matching rules to file types

**Critical difference from legacy ESLint.** In flat config, plugins are only registered for files matching the config object's `files` pattern. Setting a rule from an unregistered plugin causes an error. Split rule overrides by plugin scope.

The base `mcAppConfig` registers plugins for:

| Plugin               | Registered for                   |
| -------------------- | -------------------------------- |
| `react`              | `*.js`, `*.jsx`, `*.tsx`         |
| `react-hooks`        | `*.js`, `*.jsx`, `*.ts`, `*.tsx` |
| `@typescript-eslint` | `*.ts`, `*.tsx`                  |
| `jest`               | `*.spec.*`, `*.test.*`           |
| `testing-library`    | `*.spec.*`, `*.test.*`           |

If your override mixes rules from different plugins, split into separate config objects matching each plugin's file types (e.g., `react/` rules in `**/*.{js,jsx,tsx}`, `@typescript-eslint/` rules in `**/*.{ts,tsx}`, core rules in `**/*.{js,jsx,ts,tsx}`).

#### Common mistake: catch-all file patterns with mixed plugin rules

The natural instinct when converting a subdirectory `.eslintrc.cjs` is to put all the rules into a single config object targeting `**/*.{js,jsx,ts,tsx}`. This will fail because not every plugin is registered for every file type.

```js
// WRONG — will error on .ts files because `react` plugin is not registered for them
{
  files: ['packages/my-app/src/**/*.{js,jsx,ts,tsx}'],
  rules: {
    'no-console': 'warn',                                 // core — works on all
    'react/jsx-sort-props': 'error',                       // react — NOT on .ts
    '@typescript-eslint/consistent-type-imports': 'error', // ts — NOT on .js/.jsx
  },
}
```

Split into separate config objects, each matching the plugin's registered file types:

```js
// Core ESLint rules — all source files
{
  files: ['packages/my-app/src/**/*.{js,jsx,ts,tsx}'],
  rules: { 'no-console': 'warn' },
},
// React rules — only file types where the react plugin is registered
{
  files: ['packages/my-app/src/**/*.{js,jsx,tsx}'],
  rules: { 'react/jsx-sort-props': 'error' },
},
// TypeScript rules — only .ts and .tsx
{
  files: ['packages/my-app/src/**/*.{ts,tsx}'],
  rules: { '@typescript-eslint/consistent-type-imports': 'error' },
},
```

> **Tip**: When converting an existing `.eslintrc.cjs`, group its rules by which plugin they belong to, then create one config object per plugin group with the correct `files` pattern. Core ESLint rules (no plugin prefix) can target all file types.

#### Common mistake: `no-unused-vars` duplication on TypeScript files

The base `mcAppConfig` disables core `no-unused-vars` on `.ts`/`.tsx` files and replaces it with `@typescript-eslint/no-unused-vars`. If your subdirectory config re-enables the core `no-unused-vars` for all file types, both rules will fire on TypeScript files, producing duplicate errors.

```js
// WRONG — re-enables core no-unused-vars on .ts/.tsx where @typescript-eslint
// version is already active from the base config
{
  files: ['packages/my-app/src/**/*.{js,jsx,ts,tsx}'],
  rules: { 'no-unused-vars': 'error' },
}
```

Scope core `no-unused-vars` overrides to JavaScript files only:

```js
// CORRECT — only overrides the core rule on files where it applies
{
  files: ['packages/my-app/src/**/*.{js,jsx}'],
  rules: { 'no-unused-vars': 'error' },
}
```

### Self-linting the config file

The root `eslint.config.js` is itself a `.js` file in your project, so ESLint will lint it. Rules like `import/extensions` may error on `.cjs` requires. Add a self-override to avoid this:

```js
{
  files: ['eslint.config.js'],
  rules: { 'import/extensions': 'off' },
},
```

## Step 4: Update `package.json`

```diff
- "eslint": "8.57.1",
+ "eslint": "^9.0.0",

- "@commercetools-frontend/eslint-config-mc-app": "^25.0.0",
+ "@commercetools-frontend/eslint-config-mc-app": "^27.0.0",
```

Remove `@rushstack/eslint-patch` — not needed in flat config.

### Update custom ESLint plugins

If you maintain custom ESLint rule plugins, update deprecated APIs:

| Deprecated (ESLint 8)     | Replacement (ESLint 9)      |
| ------------------------- | --------------------------- |
| `context.getFilename()`   | `context.filename`          |
| `context.getSourceCode()` | `context.sourceCode`        |
| `context.getScope()`      | `sourceCode.getScope()`     |
| `context.getAncestors()`  | `sourceCode.getAncestors()` |
| `context.getCwd()`        | `context.cwd`               |

Also update the plugin's `peerDependencies` to `"eslint": "9.x"`.

### `jest-runner-eslint`

The official starter templates no longer use `jest-runner-eslint`. It has a stale peer dependency declaration (`eslint@^7 || ^8`) that causes `npm install` to fail with an `ERESOLVE` error when used alongside ESLint 9. The code itself is functionally compatible with ESLint 9, but the undeclared peer dep makes npm hard-fail on install.

**Recommended:** replace it with a direct ESLint call.

1. Remove `jest-runner-eslint` and `jest-watch-typeahead` from `package.json`
2. Delete `jest.eslint.config.js` and `jest-runner-eslint.config.js`
3. Update the `lint` script:

```diff
- "lint": "jest --config jest.eslint.config.js"
+ "lint": "eslint ."
```

This produces standard ESLint output instead of Jest-style per-file PASS/FAIL reporting, but works correctly with both npm and pnpm.

If you prefer to keep `jest-runner-eslint`, add an override to suppress the peer dep conflict:

```json
{
  "pnpm": {
    "overrides": {
      "jest-runner-eslint>eslint": "^9.0.0"
    }
  }
}
```

If the formatter path uses a bare directory import (e.g., `node_modules/eslint-formatter-pretty`), ESM module resolution will reject it. Change to an explicit file path:

```diff
- format: 'node_modules/eslint-formatter-pretty',
+ format: 'node_modules/eslint-formatter-pretty/index.js',
```

## Step 5: Clean up

- Delete **all** `.eslintrc.*` files (root and subdirectories)
- Delete `.eslintignore`
- Verify: `find . -name '.eslintrc*' -not -path '*/node_modules/*'`

## Step 6: Verify

Run eslint on at least one file from **each directory that had its own config**. Check for:

- **Configuration errors** — fix the generated `eslint.config.js`
- **New lint violations** from dependency upgrades:
  - `@typescript-eslint` v5→v8 (stricter type checking)
  - `eslint-plugin-jest` v27→v28 (new rules)
  - `eslint-plugin-react-hooks` v4→v5 (improved detection; rules now also apply to `*.ts` — custom hooks in `.ts` files may surface new warnings)
  - `eslint-plugin-testing-library` v5→v7 (new best practices)

## Troubleshooting

**"Definition for rule 'plugin/rule-name' was not found"**: This means a rule is referenced on a file type where its plugin isn't registered. Two common causes:

1. **Config rules targeting wrong file types** — your subdirectory config has a catch-all `files` pattern like `**/*.{js,jsx,ts,tsx}` but includes rules from a plugin that isn't registered for all of those types. See "Plugin scoping" above for how to split by plugin.
2. **Stale inline `eslint-disable` comments** — ESLint 8 silently ignored `eslint-disable` comments referencing rules from unregistered plugins. ESLint 9 treats them as errors. This surfaces pre-existing stale comments that were previously harmless (e.g., `// eslint-disable-next-line testing-library/no-render-in-setup` in a file that isn't matched by the `testing-library` plugin's file pattern, or `@typescript-eslint/...` in a `.js` file). **Fix**: remove the stale disable comment, or if the rule should apply, register the plugin for that file type.

**"ReferenceError: module is not defined in ES module scope"**: Config file uses `module.exports` but nearest `package.json` has `"type": "module"`. **Fix**: rename to `.cjs` extension and update imports.

**Jest globals (`describe`, `it`, `expect`) not defined**: The base config only injects Jest globals for `**/*.{spec,test}.*`. Other files that use Jest APIs need explicit globals. Common patterns that are easy to miss:

- `__mocks__/` directories (at any depth)
- `test-utils/` directories (helper modules used by tests)

Add a config block for these:

```js
{
  files: [
    '**/__mocks__/**/*.{js,jsx,ts,tsx}',
    '**/test-utils/**/*.{js,jsx,ts,tsx}',
  ],
  languageOptions: {
    globals: {
      jest: 'readonly',
      expect: 'readonly',
    },
  },
},
```

> **Note**: Use `**/__mocks__/**` (with leading `**/`), not `__mocks__/**`. The latter only matches a `__mocks__/` directory at the project root. Most projects have `__mocks__/` directories nested inside packages or `src/` directories.

**Duplicate `no-unused-vars` errors on `.ts`/`.tsx` files**: The base config sets `@typescript-eslint/no-unused-vars` for TypeScript files and disables the core `no-unused-vars` there. If your subdirectory config re-enables `no-unused-vars` for `**/*.{js,jsx,ts,tsx}`, both rules fire on TypeScript files. **Fix**: scope `no-unused-vars` overrides to `**/*.{js,jsx}` only. See "Common mistake" above.

**Lint errors in `eslint.config.js` itself**: The config file is a `.js` file in the project root, so ESLint lints it. Rules like `import/extensions` may error on `.cjs` requires. **Fix**: add a self-override: `{ files: ['eslint.config.js'], rules: { 'import/extensions': 'off' } }`.

**"Cannot find module 'some-eslint-plugin'"**: Plugins must be installed as direct dependencies in flat config.

**"context.getScope is not a function"**: Incompatible plugin version. Update to the latest major version supporting ESLint 9.

**Lint violations in test files**: The testing-library plugin v7 is stricter. Common issues: `testing-library/no-node-access` (use `fireEvent.click()` instead of `.click()`) and `testing-library/no-wait-for-side-effects` (only assertions belong in `waitFor()`).

## Additional resources

- [ESLint Flat Config Documentation](https://eslint.org/docs/latest/use/configure/configuration-files)
- [Migration Guide (Official ESLint)](https://eslint.org/docs/latest/use/configure/migration-guide)
- [TypeScript ESLint v8 Release Notes](https://typescript-eslint.io/blog/announcing-typescript-eslint-v8)
