# Migrating to 14.0.0

> Choosing entrypoints, carrying your ignores across, and adopting the rule changes in stages

This guide is organised by project shape rather than by release note. Find your stack, take the entrypoints and
the ignores that go with it, then adopt the rule changes at whatever pace suits the codebase.

## Check the ESLint floor first

`14.0.0` requires `eslint` 9 or 10. A project on 8.x cannot install it at all, so upgrade ESLint first and
migrate this configuration afterwards. Nothing else in this guide applies until that is done.

## Step 1: choose entrypoints for your stack

| Project shape | Entrypoints |
| --- | --- |
| Nuxt 3 or 4 | `./nuxt` |
| Vue 3 without Nuxt | `./vue3` |
| Vue 2, or Nuxt 2 | `./vue` (deprecated, see below) |
| Vue 2 with standalone `.ts` files | `./vue` and `./typescript` |
| Node package or service, no Vue | the base export |
| Any of the above that lints JSON | add `./json` |
| Any of the above using Tailwind | add `./tailwind`, or `./tailwind-vue` where templates carry classes, and point the plugin at your Tailwind entry point as the README shows - `settings.tailwindcss.cssConfigPath` on plugin v4, `settings.tailwindcss.config` on v3. v4 aborts the whole run when it cannot find it; v3 silently uses Tailwind's defaults |

Four rules decide every row above.

- **`./nuxt` composes `./vue3`**, so it carries Vue 3 rules and is the wrong entrypoint for Nuxt 2. A Nuxt 2
  project is a Vue 2 project and takes `./vue`.
- **`./vue` parses `.vue` and the JavaScript family only.** A Vue 2 project with standalone `.ts` files needs
  `./typescript` beside it, exactly as its `.eslintrc` extended both. `./vue3` and `./nuxt` already cover `.ts`.
- **`./vue3` and `./nuxt` need no `./typescript`.** Adding it beside `./vue3` changes nothing. Beside
  `./nuxt` it must come *first*: `./nuxt` switches `no-undef` off last, for Nuxt's auto-imports, and anything
  spread after it turns that back on.
- **A path into `rules/` is not an entrypoint.** Anything under `rules/` is internal and can move in a minor
  release; use the published exports only.

Deciding between `./vue` and `./vue3` is a question about the code, not about the old configuration. A project
that had bolted `plugin:vue/vue3-recommended` on top of `./vue` was already linting Vue 3, so `./vue3` is the
like-for-like replacement. A project mid-migration between Vue majors keeps `./vue` until the migration lands,
then switches, and gets the Vue 3 deprecation rules as the migration checklist that switch produces.

## Step 2: carry your ignores across

Ignores are the consuming project's responsibility, from the three places below. The one exception this
package keeps is `./json`, which ignores lockfiles and `composer.json` because nothing else can reach them
(ADR-0027).

**Call `gitignore()`.** This package contributes no ignores for generated output. Without that line the first
run reports on build output, and for a Nuxt project on `.nuxt/` and `.output/` as well.

**Convert `.eslintignore`.** ESLint 9 removed support for the file entirely. A project that kept its exclusions
there silently loses every one of them and starts linting its vendored bundles, which is the single largest
source of surprise findings on this migration. Move each line into an `ignores` block.

**Add committed vendor bundles.** Anything minified and committed is excluded by neither `.gitignore` (it is
tracked) nor `gitignore()`. This matters beyond noise: `jsdoc/no-undefined-types` throws on minified sources
rather than reporting, so one such directory stops the whole run with `There were no results for comment
parsing`.

### Ignores by project shape

Take the block for your stack, then add anything your build writes that git tracks.

**Laravel, with or without Inertia:**

```js
{
    ignores: [
        '**/bootstrap/**',
        '**/lang/**',
        '**/nova-components/**',
        '**/public/**',
        '**/storage/**',
        '**/vendor/**',
    ],
}
```

`public/` holds compiled assets and `vendor/` holds Composer packages; both are generated or third-party.
`nova-components/` ships minified bundles, so it is the crash case above. Where a project lints its own code
under `public/`, narrow the pattern to `**/public/build/**` instead of dropping it.

**Nuxt:**

```js
{
    ignores: [
        '**/.data/**',
        '**/.nuxt/**',
        '**/.output/**',
        '**/dist/**',
    ],
}
```

`gitignore()` normally covers these, since the Nuxt template gitignores all four. Name them explicitly only
where the project does not.

**Vue or Vite single-page application:**

```js
{
    ignores: ['**/coverage/**', '**/dist/**'],
}
```

**Any project with generated type definitions:**

```js
{
    ignores: ['**/*.d.ts'],
}
```

Narrow this to the generated files by name where the project also hand-writes declarations. Route and API
definition files emitted by tooling are the usual case.

### Two pattern gotchas

The first silently matches nothing, which reads as an ignore that works until you look at the finding count.

- **A leading slash is not an anchor.** Flat config does not read `ignores` the way `.gitignore` does, so
  `/dist/**` matches no file at all. Write `**/dist/**`, which also covers a monorepo's per-package copies.
- **`dir/*` and `dir/**` both cover the tree**, so neither is the trap. Only the leading slash is.

## Step 3: drop plugins this package now covers

**A Tailwind class-sorting plugin.** `./tailwind` and `./tailwind-vue` carry `tailwindcss/classnames-order`,
which is the same check, reported at `warn` and auto-fixable. One difference decides whether it can replace a
formatter plugin: `./tailwind` claims `.js`, `.jsx`, `.ts` and `.tsx` and `./tailwind-vue` claims `.vue`, so
class strings in `.mjs`, `.cjs`, `.mts` and `.cts` are sorted by neither. One caveat decides whether this is a
straight swap: the plugin resolves the project's own Tailwind entry point, which no shared configuration can
supply (ADR-0025). Step 1 sets it, under the key your plugin major reads.

**A plugin that forbids import paths.** A rule banning one alias in favour of another is a project's own
convention, so it stays with the project rather than moving here. It needs no plugin either: core
`no-restricted-imports` takes patterns with a custom message, and this package never sets that rule, so a
project can use it without merging against anything.

```js
{
    rules: {
        'no-restricted-imports': ['warn', {
            patterns: [{
                group: ['@/some/path/*'],
                message: 'Prefer ~/other/path over @/some/path.',
            }],
        }],
    },
}
```

**A separate package.json sorting step.** `./json` orders `package.json` and sorts JSON keys, so a standalone
sort step in the same repository is now duplicated work. See the [`./json` Preset Migration
Guide](migration-json-preset.md).

**Hand-added `jsonc/*` rules.** `./json` owns JSON linting. Keep only the entries that differ from what it
already sets.

## Step 4: translating an eslintrc

Two semantic differences cause almost every failure when converting a working `.eslintrc`.

**eslintrc `rules` is global; flat config has no global rules.** A rule resolves only where its plugin is
registered. This package registers the JSON plugins for JSON files and everything else for the JavaScript
family, so moving a top-level `rules` block over verbatim asks a JSON file for a JavaScript rule and ESLint
refuses to load the configuration at all, with `could not find plugin`.

Split the block by namespace, and scope each half to the extensions your entrypoint actually registers - which
the [Configuration Guide](02-configuration.md) tabulates per entrypoint. Naming an extension your entrypoint
does not claim is worse than a no-op: it makes those files lintable, so a project that was skipping them starts
aborting on them instead.

For a `/vue3` project, whose row is the base extensions plus `.ts`, `.tsx`, `.mts`, `.cts` and `.vue`:

```js
{
    files: ['**/*.{js,mjs,cjs,ts,tsx,mts,cts,vue}'],
    rules: { 'sort-destructure-keys/sort-destructure-keys': 'error' },
},
{
    files: ['**/*.{json,jsonc,json5}'],
    rules: { 'jsonc/sort-keys': 'error' },
},
```

The JSON half needs `./json` composed alongside, which Step 1 covers: without it the `jsonc` plugin is not
registered and the run aborts on the same `could not find plugin` this section is about.

On the base entrypoint the first glob is `**/*.{js,mjs,cjs}`; add `.jsx` only on `/nuxt`, which is the one row
that claims it.

**Later wins, and specificity does not.** An eslintrc `overrides` entry beat the root block because it was more
specific. In flat config the last matching block wins, so ordering is what expresses intent: entrypoints first,
then the project's own decisions, then anything parked.

While splitting, drop the overrides that were already no-ops. A rule turned off for a file type this package
never registers it for was doing nothing, and carrying it forward only makes the configuration look busier than
it is.

## Step 5: adopt the rule changes in stages

This release widens where the house rule set applies, so a long-lived codebase reports findings that are not
defects: the code was never checked against those rules in those files. Fixing all of it before the upgrade
lands is not required, and not recommended. This release also switches rules off, which leaves the
`eslint-disable` comments written for them suppressing nothing.

Order matters. Steps 3 and 4 come first because this one reads the finished configuration: a comment for a
rule you have not yet translated is not stranded, and a comment for a rule you park in 5b must be kept.

### Step 5a: clear the comments that no longer suppress anything

```bash
npx eslint . --format stylish | grep -E "^\S|Unused eslint-disable directive|Definition for rule"
```

Two kinds of line matter, and both mean the comment can go:

- `Unused eslint-disable directive` - the rule still exists and reports nothing at that line. A warning, so it
  fails any build that runs `--max-warnings 0`.
- `Definition for rule '...' was not found` - the rule's plugin is no longer registered, usually because Step 3
  removed it. This one is an **error**, it fails every build, and `reportUnusedDisableDirectives: false` does
  not silence it.

A rule-specific comment names its rule; a blanket `/* eslint-disable */` names none. An unused blanket is
suppressing nothing, so delete it - `unicorn/no-abusive-eslint-disable` will require you to narrow any that
survive.

A directive written as an HTML comment in a `.vue` template is handled by `vue/comment-directive`, not by
ESLint's own directive machinery, so `linterOptions.reportUnusedDisableDirectives` does not reach it and the
sweep above passes over templates in silence. That rule takes its own option. Add it for the sweep and the
templates are covered too:

```js
{
    files: ['**/*.vue'],
    rules: {
        'vue/comment-directive': ['error', { reportUnusedDisableDirectives: true }],
    },
}
```

It reports both cases the sweep reports elsewhere - a directive suppressing nothing, and one naming a rule that
no longer exists - but not in the two shapes above: every template finding comes from `vue/comment-directive`
at error severity, and both cases carry the `Unused eslint-disable directive` wording. Remove the block once the
sweep is done, or keep it: this package leaves the option unset so that the upgrade does not turn every stale
template comment into a finding on day one.

Working from a list does not scale. This release moves rules across many file types, and your own codebase is
the only place that records which ones you disabled. To see the whole set for a file type yourself, compare
`npx eslint --print-config` output between the two versions.

Reading the output: ESLint groups findings under the file they came from, and the filter keeps every heading -
including files whose only findings were something else, which is why a heading may have nothing under it. The
closing `✖ N problems` line counts everything, not just what survived the filter.

### Step 5b: park what still reports

With the stranded comments gone, run the lint again and read the rules it names:

```bash
npx eslint .
```

Park each one that reports on existing code which is not a defect:

```js
import gitignore from 'eslint-config-flat-gitignore';
import bitfactoryVue3 from '@bitfactory/eslint-config/vue3';
import bitfactoryJson from '@bitfactory/eslint-config/json';

export default [
    gitignore(),
    ...bitfactoryVue3,
    ...bitfactoryJson,

    // Parked for staged adoption. Each of these reports on existing code and none of it is a defect.
    // Delete a line and fix what it reports when the team chooses to adopt that rule.
    {
        rules: {
            'perfectionist/sort-imports': 'off',
            '@stylistic/multiline-ternary': 'off',
        },
    },

    // While rules are parked, their existing eslint-disable comments read as unused.
    {
        linterOptions: {
            reportUnusedDisableDirectives: false,
        },
    },
];
```

The last block matters, and it is why Step 5a comes first. This configuration reports an unused disable
directive, so parking a rule turns every `eslint-disable` comment for it into a finding - and those comments
must be *kept*, since the rule is only parked. Once the block is in place the report is suppressed, which is
also why the stranded comments have to be cleared before it is added.

Two kinds of rule end up in the parked list, and they deserve different treatment.

- **House style**, where this package sets the options: quote style, indentation, spacing, import order. These
  are a formatting decision, so adopt them in one pass per rule with `--fix` and a single commit.
- **Upstream checks**, where a plugin's own recommended set decides: framework deprecations, accessibility,
  unused values. These often mark real work. A Vue 3 deprecation rule reporting on a Vue 2 idiom is the
  migration that has not happened yet, not noise.

`vuejs-accessibility/label-has-for` changes the other way: it now accepts a `for`/`id` pair or a nested control,
where the plugin's default demands both, so it reports less rather than stranding anything.

`no-object-constructor` replaces `no-new-object`, which this release removes, and also covers `Object()` called
without `new`.
