# @useinsider/eslint-config

> A collection of ESLint configurations provided by @useinsider, offering support for various environments including Vanilla JS, TypeScript, and framework-specific setups such as Vue 3.

Latest version **4.2.0** (published 2026-11-09) · MIT license · 0 weekly downloads

## Install

```sh
npm install @useinsider/eslint-config
pnpm add @useinsider/eslint-config
yarn add @useinsider/eslint-config
bun add @useinsider/eslint-config
```

Provides the command `eslint-config`.

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 4.2.0 |
| Published | 2026-11-09 |
| First published | 2023-11-30 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=24.19.0 |
| Dependencies | 22 |
| Unpacked size | 1.8 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | Sahin Deniz |
| Maintainers | useinsider-dev |
| Keywords | useinsider, eslint, eslint-config, lint, code style, vanilla, typescript, vue, vue2, vue3, node |

## Links

- npm: https://www.npmjs.com/package/@useinsider/eslint-config
- Repository: https://github.com/useinsider/config
- Homepage: https://github.com/useinsider/config/tree/main/packages/eslint-config#readme
- Issues: https://github.com/useinsider/config/issues
- npm.io page: https://npm.io/package/@useinsider/eslint-config

## Dependencies (22)

- [jiti](https://npm.io/package/jiti.md) 2.7.0
- [eslint](https://npm.io/package/eslint.md) 9.39.5
- [oxlint](https://npm.io/package/oxlint.md) 1.92.0
- [semver](https://npm.io/package/semver.md) 7.8.6
- [globals](https://npm.io/package/globals.md) 17.14.0
- [@eslint/js](https://npm.io/package/@eslint/js.md) 9.39.5
- [typescript](https://npm.io/package/typescript.md) 6.0.3
- [@types/eslint](https://npm.io/package/@types/eslint.md) 9.6.1
- [@clack/prompts](https://npm.io/package/@clack/prompts.md) 1.8.1
- [typescript-eslint](https://npm.io/package/typescript-eslint.md) 8.72.1
- [@antfu/install-pkg](https://npm.io/package/@antfu/install-pkg.md) 2.1.0
- [eslint-plugin-jsdoc](https://npm.io/package/eslint-plugin-jsdoc.md) 65.4.5
- [eslint-plugin-oxlint](https://npm.io/package/eslint-plugin-oxlint.md) 1.92.0
- [@cspell/eslint-plugin](https://npm.io/package/@cspell/eslint-plugin.md) 10.4.1
- [eslint-plugin-sonarjs](https://npm.io/package/eslint-plugin-sonarjs.md) 4.2.2
- [eslint-plugin-import-x](https://npm.io/package/eslint-plugin-import-x.md) 4.18.0
- [@stylistic/eslint-plugin](https://npm.io/package/@stylistic/eslint-plugin.md) 5.10.0
- [@typescript-eslint/types](https://npm.io/package/@typescript-eslint/types.md) 8.72.1
- [@typescript-eslint/parser](https://npm.io/package/@typescript-eslint/parser.md) 8.72.1
- [eslint-config-airbnb-extended](https://npm.io/package/eslint-config-airbnb-extended.md) 3.3.0
- [@typescript-eslint/eslint-plugin](https://npm.io/package/@typescript-eslint/eslint-plugin.md) 8.72.1
- [eslint-import-resolver-typescript](https://npm.io/package/eslint-import-resolver-typescript.md) 4.4.5

## Alternatives

- [eslint-plugin-sonarjs](https://npm.io/package/eslint-plugin-sonarjs.md) — 2.9M weekly downloads
- [eslint-config-expo](https://npm.io/package/eslint-config-expo.md) — 1.5M weekly downloads
- [@matter/protocol](https://npm.io/package/@matter/protocol.md) — 63.5K weekly downloads
- [@eventcatalog/linter](https://npm.io/package/@eventcatalog/linter.md) — 24.8K weekly downloads
- [@pandacss/eslint-plugin](https://npm.io/package/@pandacss/eslint-plugin.md) — 18.7K weekly downloads

## Recent versions

- 4.2.0 (latest) — 2026-11-09
- 4.2.0-beta.1 (beta) — 2026-11-08
- 1.3.0-alpha.6 (alpha) — 2024-05-20
- 4.1.0 — 2026-10-12
- 4.1.0-beta.2 — 2026-10-12
- 4.1.0-beta.1 — 2026-10-12
- 4.0.0 — 2026-09-26
- 4.0.0-beta.6 — 2026-09-26
- 4.0.0-beta.5 — 2026-09-26
- 4.0.0-beta.4 — 2026-09-26
- 4.0.0-beta.3 — 2026-09-26
- 4.0.0-beta.2 — 2026-09-26
- 4.0.0-beta.1 — 2026-09-25
- 3.0.2-beta.1 — 2026-09-20
- 3.0.2 — 2026-09-20
- … 102 more at https://npm.io/package/@useinsider/eslint-config/versions

## README

# @useinsider/eslint-config

Shared lint configuration for Insider projects, split across two linters.
`useInsider()` takes a single scope-keyed config object and returns two
renderers: `useInsiderESLint()` for ESLint 9 flat config and `useInsiderOXC()`
for oxlint. Every rule oxlint implements natively is turned **off** on the
ESLint side and enabled on the oxlint side from the same description, so the
two configs cannot drift. There is no opaque preset list, no freeform `config`
escape hatch, and no expectation that you spread either result into your own
array.

Because ESLint alone would now silently skip every rule oxlint owns, both
linters must run. `useInsider()` refuses to start if the oxlint config is
missing.

## The three-file layout

`useInsider()` is called from exactly one module, and each linter's config is
a thin re-export of its renderer:

```ts
// insider.lint.ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    // scopes go here
});
```

```ts
// eslint.config.ts
import { useInsiderESLint } from './insider.lint.ts';

export default useInsiderESLint();
```

```ts
// oxlint.config.ts
import { useInsiderOXC } from './insider.lint.ts';

export default await useInsiderOXC();
```

Then run both:

```bash
oxlint --disable-nested-config && eslint .
```

Three constraints this layout is not free to break:

- **The `./insider.lint.ts` import keeps its real extension.** Neither
  ESLint's loader nor oxlint's applies TypeScript's `.js` → `.ts` specifier
  rewriting, so `'./insider.lint.js'` does not resolve.
- **The ESLint config needs a TypeScript extension too** — `eslint.config.ts`
  or `eslint.config.mts`. ESLint routes those through jiti, which resolves the
  TypeScript lint module on every supported Node version. A `.js`/`.mjs`/`.cjs`
  config is loaded by Node directly, so importing `./insider.lint.ts` from one
  works only where Node's type stripping is unflagged (Node >= 22.18) and dies
  with `ERR_UNKNOWN_FILE_EXTENSION` on Node 20. Both CLI commands therefore
  rename an existing JavaScript flat config and delete the original.
- **The oxlint config must be `oxlint.config.ts` or `oxlint.config.mts`.**
  oxlint silently ignores `oxlint.config.{cts,js,mjs,cjs}` and falls through
  to the nearest ancestor config. In a package with no `"type": "module"`,
  use `.mts` for both generated files to avoid Node's module-type warning.
- **A TypeScript project needs `allowImportingTsExtensions: true`** in the
  `tsconfig.json` that covers the layout files, or `tsc` rejects the import
  with `TS5097`. Both CLI flows offer to set it for you, following `extends`
  first so a shared base that already carries it is left alone. TypeScript only
  accepts the flag on a project with `noEmit`, `emitDeclarationOnly` or
  `rewriteRelativeImportExtensions` — on an emitting project it fails with
  `TS5096` — so a project that emits is never edited; the CLI explains what to
  turn on instead.
- **`oxlint` runs with `--disable-nested-config`.** The emitted config carries
  options oxlint accepts only in a root config, and a discovered nested config
  carrying them aborts the whole run. Disabling nested discovery also makes
  the config's own `ignorePatterns` authoritative. In a monorepo each package
  runs its own `lint` script, so every config is the root of its own run.

**Do not `await` `useInsiderESLint()`. Do not spread either renderer. Do not
wrap them in an array.** If you find yourself wanting to append a block, that
is a sign your scope coverage is incomplete — describe the extra files inside
`useInsider({...})` instead.

## Table of Contents

- [The three-file layout](#the-three-file-layout)
- [Prerequisites](#prerequisites)
- [Quick start](#quick-start)
- [Manual setup](#manual-setup)
  - [Node + TypeScript](#node--typescript)
  - [Browser + Vue 3 + TypeScript with split tsconfigs](#browser--vue-3--typescript-with-split-tsconfigs)
  - [Vue 2 + JavaScript with jQuery globals](#vue-2--javascript-with-jquery-globals)
  - [Strict and spell-checker knobs](#strict-and-spell-checker-knobs)
- [API reference](#api-reference)
- [Unused disable directives](#unused-disable-directives)
- [Lint scripts](#lint-scripts)
- [Migrating to v4](#migrating-to-v4)
  - [The guided migration offer](#the-guided-migration-offer)
- [Migrating from v1.x](#migrating-from-v1x)
- [First lint after install](#first-lint-after-install)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)

## Prerequisites

- Node.js 24.19+
- ESLint 9+
- oxlint 1.92+
- pnpm, npm, or yarn

## Quick start

```bash
npx @useinsider/eslint-config
```

The CLI walks an interactive prompt (project type, language, framework, test
runner, jQuery globals, tsconfig layout), installs every required linter and
plugin pinned to the versions this package was built against, writes the three
layout files at the project root, wires oxlint into the script that actually
invokes ESLint (see [Lint scripts](#lint-scripts)), and offers to add
`allowImportingTsExtensions: true` to the tsconfig that really governs those
files. In a solution-style project that is the referenced `tsconfig.node.json`,
not the root — and if an applicable config already sets the flag, or no tsconfig
type-checks those files, you are not asked at all. That edit is surgical —
comments, key order and indentation survive — and declining it prints the exact
snippet to paste.

To migrate an existing `eslint.config.{js,mjs,cjs}` — v3's scope-keyed call or
v1's `useInsider({ preset, config })` — or a legacy `.eslintrc.{js,cjs}`:

```bash
npx @useinsider/eslint-config migrate [path]
```

The migrate flow auto-detects the source format, prints a summary plus any
dropped custom rules, asks for confirmation, then splits the config into the
three-file layout: the `useInsider()` call moves into `insider.lint.*` and
both linter configs become thin re-exports.

A **v3** config takes the shorter route. v4 accepts the same options v3 did,
so the call is re-housed rather than re-rendered: the file is copied into
`insider.lint.*` byte-for-byte and only the export statement changes to
`export const { useInsiderESLint, useInsiderOXC } = useInsider({ ... })`. Your
comments, `satisfies` clauses, computed values and formatting survive. An
`await` on the call and an array wrapping it are dropped, since `useInsider()`
is synchronous in v4 and returns the two renderers. Shapes with nowhere to go
in a lint module are refused by name rather than half-migrated: more than one
`useInsider()` call, a default export mixing the call with hand-written
flat-config blocks, a `defineConfig()` wrapper, and a call reached through a
variable. An `.eslintignore` beside a v3 config is left alone — flat config
never read it, and the re-housed call is not rewritten to fold it in.

Legacy `.eslintrc.{js,cjs}` is also handled (custom rules and
non-`@useinsider/eslint-config/*` extends are dropped with a loud warning so
you can review what changed). Legacy `.eslintrc.{json,yml,yaml}` are not
supported in this release — convert them to `.eslintrc.{js,cjs}` first.

Migrate also rewrites your source: an `// eslint-disable-next-line <rule>`
naming a rule oxlint has taken over becomes `// oxlint-disable-next-line
<rule>`. This is not cosmetic — the emitted oxlint config refuses the
`eslint-disable` spelling (see [Unused disable
directives](#unused-disable-directives)), so an un-migrated comment suppresses
nothing on either side. A comment listing rules from both sides is split into
one comment per linter. A `-line` or `-next-line` directive targets one exact
physical line, so both halves stay on a single line as block comments — a
newline between them would point the first half at the second instead of at
the code. Only a whole-file `eslint-disable` is split across two lines. The rewrite reads each
file's syntax tree and touches only real comment nodes, so a directive spelled
out inside a string or template literal is left alone.

Example `insider.lint.ts` for a Node + TypeScript project:

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    typescriptNode: [
        {
            files: ['**/*.{ts,tsx}'],
            tsconfigPaths: ['tsconfig.json'],
        },
    ],
});
```

What the CLI installs for that answer set (versions reflect the publish-time
pins):

- `@useinsider/eslint-config`
- `eslint`
- `oxlint`
- `globals`
- `typescript`
- `typescript-eslint`

Vue, Jest, or Vitest answers add the matching plugin set (e.g. `eslint-plugin-vue`,
`vue-eslint-parser`, `eslint-plugin-vue-scoped-css`, `eslint-plugin-jest`,
or `@vitest/eslint-plugin`).

## Manual setup

Install the package first:

```bash
pnpm add -E -D @useinsider/eslint-config oxlint
```

Then create the [three layout files](#the-three-file-layout) and call
`useInsider(...)` from `insider.lint.ts` with the scopes your project needs.
Each scope holds an array of `{ files, ... }` entries; each entry becomes its
own block in both emitted configs.

Every example below shows the body of `insider.lint.ts`; the two linter
configs never change.

### Node + TypeScript

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    typescriptNode: [
        {
            files: ['**/*.{ts,tsx}'],
            tsconfigPaths: ['tsconfig.json'],
        },
    ],
    vitest: [
        { files: ['**/*.{test,spec}.{ts,tsx}'] },
    ],
});
```

### Browser + Vue 3 + TypeScript with split tsconfigs

A project using `tsconfig.app.json` for source and `tsconfig.test.json` for
tests, with `RouterLink` and `RouterView` as globally-registered components.

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    vue3Typescript: [
        {
            files: ['src/**/*.{ts,tsx,vue}'],
            tsconfigPaths: ['tsconfig.app.json'],
            globalComponents: ['RouterLink', 'RouterView'],
        },
    ],
    vitest: [
        {
            files: ['**/*.{test,spec}.{ts,tsx}'],
        },
    ],
    typescriptNode: [
        {
            files: ['*.config.{ts,mts,cts}'],
            tsconfigPaths: ['tsconfig.node.json'],
        },
    ],
});
```

Multiple `tsconfigPaths` entries are supported per block — pass an array of
paths if a single scope needs more than one project reference.

### Vue 2 + JavaScript with jQuery globals

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    vue2: [
        { files: ['src/**/*.{js,vue}'] },
    ],
    javascriptNode: [
        { files: ['*.{js,cjs,mjs}'] },
    ],
    jquery: [
        { files: ['src/**/*.{js,vue}'] },
    ],
});
```

`jquery` is a top-level scope. Pass an array of `{ files }` entries describing
the globs that should see the jQuery globals (`$`, `jQuery`). The block is
emitted after the scope blocks and before `strict`/`spellChecker`, so the
matching language scope still applies its rule set and jQuery globals are
layered on top of it.

### Strict and spell-checker knobs

`strict` and `spellChecker` are flat cross-cutting options. They sit at the
top level of the config, not inside a scope entry.

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    typescriptNode: [
        {
            files: ['**/*.{ts,tsx}'],
            tsconfigPaths: ['tsconfig.json'],
        },
    ],
    strict: ['src/**/*.ts'],
    spellChecker: {
        ignoredPaths: ['vendor/**', 'dist/**'],
        customWordListFile: './cspell.custom.json',
    },
    silenceRules: ['no-console'],
});
```

- `strict: true` applies strict rules to every file.
- `strict: string[]` applies strict rules only to the matching paths.
- `spellChecker.customWordListFile` re-points `@cspell/spellchecker` to a
  project-local word list.
- `spellChecker.ignoredPaths` turns the spell-checker off for the matching
  globs (the off block is emitted after the custom-list block, so ignore
  wins on overlap).
- `silenceRules` rewrites the severity of the listed rule IDs to `'warn'`
  across every emitted block while preserving each rule's options.

## API reference

`useInsider(config)` accepts the following top-level properties. Each scope
holds an array of entries; each entry produces its own flat-config block.

Every scope entry shape also accepts two common fields:

- `ignores?: string[]` — applies as the block's `ignores` key, scoping the
  rule set away from the listed globs.
- `globals?: Record<string, GlobalConf>` — merged on top of the globals the
  preset already provides; pass `'readonly'`, `'writable'`, or `'off'` per
  ESLint's `Linter.Globals`. Use this to declare project-specific globals
  like `APP_URL`, `axios`, `atatus`.

| Property         | Entry shape                                                                                              | Notes |
| ---------------- | -------------------------------------------------------------------------------------------------------- | ----- |
| `ignores`        | `string[]` (top-level)                                                                                   | Emits a leading `{ ignores }` block applied to every other block. |
| `javascript`     | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Environment-agnostic JS (workers, isomorphic). |
| `javascriptDom`  | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Browser JS. |
| `javascriptNode` | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Node JS. |
| `typescript`     | `{ files: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }`             | Environment-agnostic TS. |
| `typescriptDom`  | `{ files: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }`             | Browser TS. |
| `typescriptNode` | `{ files: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }`             | Node TS. |
| `vue2`           | `{ files: string[]; globalComponents?: string[]; ignores?: string[]; globals?: Linter.Globals }`         | Vue 2 single-file components in JS. |
| `vue3`           | `{ files: string[]; globalComponents?: string[]; ignores?: string[]; globals?: Linter.Globals }`         | Vue 3 single-file components in JS. |
| `vue2Typescript` | `{ files: string[]; globalComponents?: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }` | Vue 2 + TS. |
| `vue3Typescript` | `{ files: string[]; globalComponents?: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }` | Vue 3 + TS. |
| `jest`           | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Jest test files. |
| `vitest`         | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Vitest test files. |
| `jsdocBasedTs`   | `{ files: string[]; tsconfigPaths: string[]; ignores?: string[]; globals?: Linter.Globals }`             | JS files type-checked via JSDoc. |
| `jquery`         | `{ files: string[]; ignores?: string[]; globals?: Linter.Globals }`                                      | Layers jQuery globals (`$`, `jQuery`) on top of the matching globs. Per-entry `globals` merge with the jQuery ones rather than replacing them. |
| `strict`         | `boolean \| string[]`                                                                                    | Applies the strict preset globally (`true`) or scoped to globs. |
| `spellChecker`   | `{ ignoredPaths?: string[]; customWordListFile?: string }`                                               | Cross-cutting cspell overrides. |
| `silenceRules`   | `string[]`                                                                                               | Rule IDs to demote to `'warn'` across every block. |
| `configFiles`    | `false \| string[]`                                                                                      | Emits a dedicated block for root-level configuration files (`*.config.*`, `eslint.config.*`) that disables `import-x/no-extraneous-dependencies` and `import-x/prefer-default-export`. **Defaults are applied automatically** — omit the key (or pass `[]`) to use the built-in defaults only, pass a `string[]` to layer your own globs on top, or pass `false` to opt out entirely. |

A scope entry with per-entry globals — declare project-specific bindings
without spreading the `useInsider` return into your own array:

```ts
import { useInsider } from '@useinsider/eslint-config';

export const { useInsiderESLint, useInsiderOXC } = useInsider({
    ignores: ['dist', 'coverage'],
    javascriptDom: [
        {
            files: ['src/**/*.js'],
            ignores: ['src/vendor/**'],
            globals: {
                APP_URL: 'readonly',
                axios: 'readonly',
                atatus: 'readonly',
            },
        },
    ],
});
```

Block-emission order — the leading `ignores` block, then scope blocks (in the
order their keys were inserted into the config object), then `jquery` blocks,
then the `configFiles` block, then any `strict` blocks, then `spellChecker`
blocks. Later blocks win on overlap, matching ESLint's flat-config precedence
model.

This order is why `configFiles` — not a scope — is the right place to relax
rules for configuration files: its block is emitted *after* every scope block,
so its overrides survive. A scope listing the same globs is emitted *before*
the language scopes and gets overridden by any of them that also match.

> **Removed in v2:** the `config` scope. It only switched
> `import-x/no-extraneous-dependencies` and `import-x/prefer-default-export`
> off, and because it was emitted with the scope blocks it was silently
> overridden for every file a language scope also matched — which, for
> `.js`/`.ts`, was all of them. Passing `config` now throws with a pointer to
> `configFiles`. Move its globs to `configFiles: [...]`; root-level
> `*.config.*` and `eslint.config.*` are already covered by the defaults, so
> most projects can simply drop the key.

`useInsider` is synchronous and returns `{ useInsiderESLint, useInsiderOXC }`.
`useInsiderESLint()` returns `Promise<Linter.Config[]>` — export it without
awaiting, ESLint 9 resolves it at load time. `useInsiderOXC()` also returns a
Promise, and oxlint does *not* await a default export, so `oxlint.config.ts`
must use top-level `await`. The `UseInsiderResult` type alias is exported for
TypeScript callers that wrap the renderers in a helper of their own.

Two file-matching differences between the linters are handled for you, but
worth knowing:

- An unanchored oxlint glob matches at any depth, so `files: ['*.mjs']` would
  also claim `.husky/install.mjs`. The emitted oxlint config anchors every
  glob that does not already start with `**`, restoring ESLint's semantics.
- oxlint discovers a nested `oxlint.config.*` in any subdirectory and lints
  that directory with it, **ignoring the parent config's `ignorePatterns`**.
  The generated lint script passes `--disable-nested-config`, which turns that
  off and makes the root config's `ignorePatterns` authoritative. It is also
  required rather than optional: the emitted config carries root-only
  `options`, and oxlint aborts with a config-parse error the moment it
  discovers those in a nested file — which a monorepo whose packages each ran
  this CLI always has. Run oxlint once per package, from that package.

## Unused disable directives

Both linters report a disable comment that no longer suppresses anything, and
each one judges only its own spelling:

- The emitted oxlint config sets `options.reportUnusedDisableDirectives` to
  `warn`, so a stale `// oxlint-disable-*` is reported.
- The emitted ESLint config pins
  `linterOptions.reportUnusedDisableDirectives` to `warn`, so a stale
  `// eslint-disable-*` is reported.

It also sets `options.respectEslintDisableDirectives: false`, which is what
makes the pair sound. oxlint judges an `eslint-disable` against the rules *it*
ran, so with the default (`true`) a comment naming `@cspell/spellchecker`,
`sonarjs/*`, `@stylistic/*` or `jsdoc/check-tag-names` — every rule the split
deliberately keeps on the ESLint side — is reported as unused. Refusing the
`eslint-disable` spelling removes that false positive entirely, at the cost of
making the ownership split explicit in your source: a rule oxlint owns must be
suppressed with `oxlint-disable`, a rule ESLint owns with `eslint-disable`.
`migrate` performs that rewrite for you; an un-migrated comment is reported by
ESLint as an unused directive, so nothing fails silently.

Neither severity fails the build on its own. Escalate with
`oxlint --deny-warnings` and `eslint . --max-warnings 0` if you want that.

`silenceRules()` (named export) is also available as a global registration
hook. Call it at module top level to register rule IDs to demote *before*
`useInsider` is invoked; the per-call `silenceRules: [...]` option layers
on top.

```ts
import { useInsider, silenceRules } from '@useinsider/eslint-config';

silenceRules(['no-magic-numbers']);

export const { useInsiderESLint, useInsiderOXC } = useInsider({ /* ... */ });
```

## Lint scripts

Both linters have to run, and oxlint has to run *first* — the ESLint side
turns off every rule oxlint owns, so ESLint alone silently drops those checks.
The canonical script is:

```json
{ "scripts": { "lint": "oxlint --disable-nested-config && eslint ." } }
```

The CLI never replaces a script it did not author. It finds the **one** script
that really invokes the `eslint` binary and **prepends** oxlint to it, leaving
the original command verbatim inside the new value. A composed `lint` that fans
out to a type-check and a stylelint pass is therefore untouched:

```diff
  "lint":          "run-p lint:type lint:es lint:style",
  "lint:es":       "npm run lint:es-ci -- --cache",
- "lint:es-ci":    "NODE_OPTIONS=--max-old-space-size=6144 eslint .",
+ "lint:es-ci":    "oxlint --disable-nested-config && NODE_OPTIONS=--max-old-space-size=6144 eslint .",
  "lint:style":    "npm run lint:style-ci -- --cache",
  "lint:type":     "run-p lint:type:app lint:type:test lint:type:node",
```

Prepending rather than appending is deliberate: a `--` passthrough
(`npm run lint:es-ci -- --cache`) lands on the *last* command, so `--cache`
still reaches ESLint. A script whose name ends in a `--fix` invocation gets
`oxlint --disable-nested-config --fix`. Every rewrite is printed with its
previous value.

Exactly one script is rewritten. When several entry points run ESLint —
`lint`, `lint:fix`, `lint:ci` — prepending to all of them would run oxlint once
per entry point over the same tree, so the CLI picks the entry point `lint`
designates (itself, or the leaf it delegates to) and reports the rest with the
prefix to add if you want oxlint on them too.

If no script invokes `eslint` and a `lint` script already exists, nothing is
written — the conflict is reported with the command to add by hand.

## Migrating to v4

v4 requires **Node.js 24.19+** (`engines.node` was `>=20` through v3). Check
your CI images and any repo pinning an older Node before upgrading: npm and
pnpm warn with `EBADENGINE`, and fail outright under `engine-strict`.

v4 splits the lint run across ESLint and oxlint. Run the CLI to do all of
this automatically:

```bash
npx @useinsider/eslint-config migrate
```

### The guided migration offer

You do not have to know that command exists. Running lint against a v3 config
under v4 detects the outdated structure and, in an interactive terminal, offers
to run the migration there and then:

```
@useinsider/eslint-config: eslint.config.js still uses the v3 single-file layout.
  /repo/eslint.config.js calls `useInsider()` itself, and
  insider.lint.ts and oxlint.config.ts are missing.

Starting the guided migration. It shows every change before applying it.
Skip it with INSIDER_NO_PROMPT=true.

◆  eslint.config.js still uses the v3 single-file layout. Migrate it now?
│  ● Yes / ○ No
```

Accepting runs the normal `migrate` flow — same summary, same confirmations —
and then exits non-zero asking you to re-run lint, because this process already
loaded the configuration that was just rewritten.

The offer is suppressed, and the failure message leads with the `migrate`
command instead, whenever prompting would be wrong: under oxlint (whose stdout
is its report), in CI, without a TTY, with `INSIDER_NO_PROMPT=true`, or with
`INSIDER_SKIP_CONFIG_VALIDATION=true`.

The directive rewrite is reported as its own step, with the file count shown
*before* anything is written, so you can take it as a separate commit or
decline it and run `migrate` again later.

What changes:

- **`eslint-disable` no longer suppresses an oxlint-owned rule. This is the
  largest behavior change in v4.** The emitted oxlint config sets
  `options.respectEslintDisableDirectives: false`, so oxlint reads only the
  `oxlint-disable` spelling. A comment left in the v3 form suppresses nothing,
  and because ESLint no longer runs that rule it reports the comment as an
  unused directive rather than failing quietly. `migrate` rewrites the affected
  comments for you (AST-based — it touches only real comment nodes).

  ```diff
  - // eslint-disable-next-line no-console -- CLI banner
  + // oxlint-disable-next-line no-console -- CLI banner
    console.info(banner);
  ```

  Rules the split keeps on the ESLint side — `@cspell/*`, `sonarjs/*`,
  `@stylistic/*`, `jsdoc/check-tag-names`, `no-inner-declarations`, every
  type-aware `@typescript-eslint` rule — keep the `eslint-disable` spelling.
  See [Unused disable directives](#unused-disable-directives) for why refusing
  the eslint spelling is what makes stale-directive reporting sound in both
  directions.

- **`useInsider()` is now synchronous and returns two renderers.** A v3
  `export default useInsider({...})` no longer produces a flat-config array.

  ```diff
  - // eslint.config.mjs
  - import { useInsider } from '@useinsider/eslint-config';
  -
  - export default useInsider({ typescriptNode: [{ files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] }] });
  + // insider.lint.ts
  + import { useInsider } from '@useinsider/eslint-config';
  +
  + export const { useInsiderESLint, useInsiderOXC } = useInsider({
  +     typescriptNode: [{ files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] }],
  + });
  ```

- **Two new sibling files** — `eslint.config.*` and `oxlint.config.*`, each a
  thin re-export. See [the three-file layout](#the-three-file-layout).
- **The lint command runs both linters**: `oxlint --disable-nested-config && eslint .`.
  `--disable-nested-config` is required, not stylistic. Without it oxlint
  discovers any nested `oxlint.config.*` in the tree and lints that subtree
  with it, bypassing the root config's `ignorePatterns`. Worse, the emitted
  config carries `options.*`, which oxlint accepts **only** in a root config —
  a nested config carrying `options` aborts the whole run, even when its
  directory is listed in `ignorePatterns`.
- **Monorepo consumers run oxlint per package.** oxlint has no workspace mode,
  so each package owns its own three files and its own `lint` script, and each
  script carries `--disable-nested-config`. Running one oxlint pass from the
  repo root over sibling packages is not equivalent.
- **Only `oxlint.config.ts` and `oxlint.config.mts` are discovered.** Any other
  extension — `.js`, `.mjs`, `.cjs`, `.cts` — is ignored *without a word*, and
  oxlint falls through to the nearest ancestor config. The config appears live
  while none of its rules run. `ensureOxlintConfig()` fails the ESLint side
  when neither accepted filename sits beside the lint module, so the mistake
  surfaces rather than silently disarming half the rule set.
- **`oxlint` is version-tracked like `eslint` and `globals`.** A consumer
  whose installed `oxlint` is older than this package's pin is upgraded
  automatically on the next lint run, because an older oxlint silently drops
  whatever rules that release did not yet implement. Opt out with
  `silenceDependencyWarning(['oxlint'])`.
- **`tsconfig.json` gains `allowImportingTsExtensions: true`.** `eslint.config.ts`
  and `oxlint.config.ts` must import the lint module by its real extension —
  nothing here applies TypeScript's `.js` → `.ts` rewriting — and `tsc` fails
  with `TS5097` otherwise. The CLI prompts to add it and resolves the full
  `extends` chain first, so a shared base that already sets it is left alone;
  declining prints the snippet to paste. A project that emits is never edited —
  TypeScript rejects the flag there with `TS5096`.
- **`@typescript-eslint` type-aware rules, `sonarjs`, `@stylistic`, `@cspell`,
  `jsdoc/check-tag-names` and `no-inner-declarations` stay with ESLint.** Two
  of those are permanent, for different reasons:
  - `jsdoc/check-tag-names` is driven by `settings.jsdoc.tagNamePreference`
    (the TypeScript scopes remap `@desc`/`@callback` onto `@use`/`@watch`), and
    oxlint has no equivalent of flat-config `settings`.
  - `no-inner-declarations` is implemented by both, but they disagree on the
    default for an omitted option: ESLint 9 defaults `blockScopedFunctions` to
    `allow`, oxlint to `disallow`. Handing oxlint the bare `'error'` would
    reject every block-scoped function declaration ESLint accepts.
- **Stale disable directives warn on both sides, and neither fails the build
  on its own.** Escalate with `oxlint --deny-warnings` and
  `eslint . --max-warnings 0`.

## Migrating from v1.x

This section describes the v1 → v2 step. The scope-keyed config object it
introduces is still the v4 shape; only the wiring around the call changed
again, which [Migrating to v4](#migrating-to-v4) covers. Do both steps if you
are coming from v1.

- **`preset: string[]`** → top-level scope properties. The order in the
  v1 `preset` array no longer matters — the new shape has one property per
  scope and overrides are explicit.

  ```diff
  - await useInsider({
  -     preset: ['typescript-node'],
  -     config: { files: ['src/**/*.ts'], languageOptions: { parserOptions: { project: ['./tsconfig.json'] } } },
  - })
  + useInsider({
  +     typescriptNode: [
  +         { files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] },
  +     ],
  + })
  ```

- **`preset: ['vue3-typescript']` + `config`** → `vue3Typescript` entry
  with a dedicated `globalComponents` field. No more hand-rolled
  `vue/no-undef-components` overrides.

  ```diff
  - await useInsider({
  -     preset: ['vue3-typescript'],
  -     config: {
  -         files: ['src/**/*.{ts,vue}'],
  -         languageOptions: { parserOptions: { project: ['./tsconfig.app.json'] } },
  -         rules: { 'vue/no-undef-components': ['error', { ignorePatterns: ['^RouterLink$', '^RouterView$'] }] },
  -     },
  - })
  + useInsider({
  +     vue3Typescript: [
  +         {
  +             files: ['src/**/*.{ts,vue}'],
  +             tsconfigPaths: ['tsconfig.app.json'],
  +             globalComponents: ['RouterLink', 'RouterView'],
  +         },
  +     ],
  + })
  ```

- **`preset: ['strict']`** → top-level `strict: true | string[]`.

  ```diff
  - await useInsider({ preset: ['typescript-node', 'strict'], config: { files: ['src/**/*.ts'] } })
  + useInsider({
  +     typescriptNode: [{ files: ['src/**/*.ts'], tsconfigPaths: ['tsconfig.json'] }],
  +     strict: true,
  + })
  ```

  Behavior change: in v1 the `strict` preset was always global when listed.
  In v2, `strict: true` keeps that global behavior; `strict: string[]`
  scopes the strict rule set to the matching globs only. If you have
  paths-targeted strict checks, set `strict: [...]` instead of
  `strict: true`.

- **Shape change**: `useInsider(...)` stopped being an awaited, spreadable
  array. In v2 it became `export default useInsider({...})`; in v4 it became
  the renderer destructuring shown in
  [Migrating to v4](#migrating-to-v4). Either way there is no supported
  override path — describe every glob you want linted inside the call,
  including jQuery files via the dedicated `jquery` scope.

- **Removed**: the `preset` array, the `config: Linter.Config` escape
  hatch, and the `config` preset itself. Configuration-file linting is
  now an ordinary `typescriptNode` (or `javascriptNode`) entry pointed at
  the right tsconfig.

- **Kept**: `silenceRules` accepts the same `string[]` shape both as a
  top-level option and via the named `silenceRules()` export.
  `silenceDependencyWarning()` works unchanged. Side-effect lines such as
  `silenceDependencyWarning(true)` continue to sit above the `useInsider(...)`
  call, now in `insider.lint.*`.

## First lint after install

The first time you run lint after installing, `ensureDependencies` may detect
that your project's `eslint` or `globals` version is older than the pin and
auto-install them, exiting with code `1`. Run lint again and it will complete
normally.

## Troubleshooting

### `Missing dependencies detected:`

If you see this error, your project is missing a peer plugin this config
needs. The error message lists the install command. To silence the warning
permanently:

```ts
import { useInsider, silenceDependencyWarning } from '@useinsider/eslint-config';

silenceDependencyWarning(['@cspell/eslint-plugin', '@stylistic/eslint-plugin']);

export const { useInsiderESLint, useInsiderOXC } = useInsider({ /* ... */ });
```

Passing `true` silences every warning; that is not recommended because the
warning is the only signal that your installed plugin versions drifted
from what the config expects.

```js
silenceDependencyWarning(true);
```

## Contributing

See [CONTRIBUTING.md](../../CONTRIBUTING.md) at the repo root.

---
_Source: https://npm.io/package/@useinsider/eslint-config · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
