├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── CONTRIBUTING.md ├── .gitignore ├── jsr.json ├── pnpm-workspace.yaml ├── eslint.config.js ├── src ├── define.ts ├── index.ts ├── parse.ts ├── concat.ts ├── merge.ts ├── types.ts ├── extend.ts ├── hijack.ts ├── rename.ts └── composer.ts ├── tsconfig.json ├── test ├── parse.test.ts ├── extend.test.ts ├── disable-fixes.test.ts └── composer.test.ts ├── LICENSE ├── .vscode └── settings.json ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/antfu/contribute 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@antfu/eslint-flat-config-utils", 3 | "version": "2.1.4", 4 | "exports": "./src/index.ts", 5 | "include": [ 6 | "src" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - docs 4 | - packages/* 5 | - examples/* 6 | onlyBuiltDependencies: 7 | - esbuild 8 | - simple-git-hooks 9 | - unrs-resolver 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import antfu from '@antfu/eslint-config' 3 | 4 | export default antfu( 5 | { 6 | rules: { 7 | 'ts/explicit-function-return-type': 'error', 8 | }, 9 | }, 10 | ) 11 | -------------------------------------------------------------------------------- /src/define.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | 3 | /** 4 | * A function that returns the config as-is, useful for providing type hints. 5 | */ 6 | export function defineFlatConfig(config: T): T { 7 | return config 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './composer' 2 | export * from './concat' 3 | export * from './define' 4 | export * from './extend' 5 | export * from './hijack' 6 | export * from './merge' 7 | export * from './parse' 8 | export * from './rename' 9 | export * from './types' 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | uses: sxzz/workflows/.github/workflows/release.yml@v1 11 | with: 12 | publish: true 13 | permissions: 14 | contents: write 15 | id-token: write 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext"], 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "esModuleInterop": true, 11 | "skipDefaultLibCheck": true, 12 | "skipLibCheck": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/parse.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { parseRuleId } from '../src/parse' 3 | 4 | it('parse', () => { 5 | expect(parseRuleId('indent')) 6 | .toEqual({ plugin: null, rule: 'indent' }) 7 | 8 | expect(parseRuleId('ts/indent')) 9 | .toEqual({ plugin: 'ts', rule: 'indent' }) 10 | 11 | expect(parseRuleId('@typescript-eslint/indent')) 12 | .toEqual({ plugin: '@typescript-eslint', rule: 'indent' }) 13 | 14 | expect(parseRuleId('foo/ts/indent')) 15 | .toEqual({ plugin: 'foo', rule: 'ts/indent' }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/extend.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from 'vitest' 2 | import { extend } from '../src' 3 | import { composer } from '../src/composer' 4 | 5 | it('empty', async () => { 6 | const p = composer( 7 | await extend( 8 | [ 9 | { 10 | files: ['*.js'], 11 | }, 12 | { 13 | ignores: ['**/dist/**'], 14 | }, 15 | ], 16 | './foo', 17 | ), 18 | ) 19 | expect(await p) 20 | .toMatchInlineSnapshot(` 21 | [ 22 | { 23 | "files": [ 24 | "foo/*.js", 25 | ], 26 | }, 27 | { 28 | "ignores": [ 29 | "foo/**/dist/**", 30 | ], 31 | }, 32 | ] 33 | `) 34 | }) 35 | -------------------------------------------------------------------------------- /src/parse.ts: -------------------------------------------------------------------------------- 1 | // Ported from https://github.com/eslint/eslint/blob/e39d3f22ff793db42e1f1fc3808cbb12fc513118/lib/config/flat-config-helpers.js#L35-L58 2 | export function parseRuleId(ruleId: string): { 3 | plugin: string | null 4 | rule: string 5 | } { 6 | let plugin: string | null 7 | let rule = ruleId 8 | 9 | // distinguish between core rules and plugin rules 10 | if (ruleId.includes('/')) { 11 | // mimic scoped npm packages 12 | if (ruleId.startsWith('@')) { 13 | plugin = ruleId.slice(0, ruleId.lastIndexOf('/')) 14 | } 15 | else { 16 | plugin = ruleId.slice(0, ruleId.indexOf('/')) 17 | } 18 | 19 | rule = ruleId.slice(plugin.length + 1) 20 | } 21 | else { 22 | plugin = null 23 | rule = ruleId 24 | } 25 | 26 | return { 27 | plugin, 28 | rule, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/concat.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | import type { Awaitable } from './types' 3 | 4 | /** 5 | * Concat multiple flat configs into a single flat config array. 6 | * 7 | * It also resolves promises and flattens the result. 8 | * 9 | * @example 10 | * 11 | * ```ts 12 | * import { concat } from 'eslint-flat-config-utils' 13 | * import eslint from '@eslint/js' 14 | * import stylistic from '@stylistic/eslint-plugin' 15 | * 16 | * export default concat( 17 | * eslint, 18 | * stylistic.configs.customize(), 19 | * { rules: { 'no-console': 'off' } }, 20 | * // ... 21 | * ) 22 | * ``` 23 | */ 24 | export async function concat(...configs: Awaitable[]): Promise { 25 | const resolved = await Promise.all(configs) 26 | return resolved.flat() as T[] 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Anthony Fu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the ESlint flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | 5 | // Disable the default formatter, use eslint instead 6 | "prettier.enable": false, 7 | "editor.formatOnSave": false, 8 | 9 | // Auto fix 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll": "explicit", 12 | "source.organizeImports": "never" 13 | }, 14 | 15 | // Silent the stylistic rules in you IDE, but still auto fix them 16 | "eslint.rules.customizations": [ 17 | { "rule": "style/*", "severity": "off" }, 18 | { "rule": "*-indent", "severity": "off" }, 19 | { "rule": "*-spacing", "severity": "off" }, 20 | { "rule": "*-spaces", "severity": "off" }, 21 | { "rule": "*-order", "severity": "off" }, 22 | { "rule": "*-dangle", "severity": "off" }, 23 | { "rule": "*-newline", "severity": "off" }, 24 | { "rule": "*quotes", "severity": "off" }, 25 | { "rule": "*semi", "severity": "off" } 26 | ], 27 | 28 | // Enable eslint for all supported languages 29 | "eslint.validate": [ 30 | "javascript", 31 | "javascriptreact", 32 | "typescript", 33 | "typescriptreact", 34 | "vue", 35 | "html", 36 | "markdown", 37 | "json", 38 | "jsonc", 39 | "yaml" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/merge.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | 3 | /** 4 | * Merge multiple flat configs into a single flat config. 5 | * 6 | * Note there is no guarantee that the result works the same as the original configs. 7 | */ 8 | export function mergeConfigs(...configs: T[]): T { 9 | const keys = new Set(configs.flatMap(i => Object.keys(i))) 10 | const merged = configs.reduce((acc, cur) => { 11 | return { 12 | ...acc, 13 | ...cur, 14 | files: [ 15 | ...(acc.files || []), 16 | ...(cur.files || []), 17 | ], 18 | ignores: [ 19 | ...(acc.ignores || []), 20 | ...(cur.ignores || []), 21 | ], 22 | plugins: { 23 | ...acc.plugins, 24 | ...cur.plugins, 25 | }, 26 | rules: { 27 | ...acc.rules, 28 | ...cur.rules, 29 | }, 30 | languageOptions: { 31 | ...acc.languageOptions, 32 | ...cur.languageOptions, 33 | }, 34 | linterOptions: { 35 | ...acc.linterOptions, 36 | ...cur.linterOptions, 37 | }, 38 | } 39 | }, {} as T) 40 | 41 | // Remove unused keys 42 | for (const key of Object.keys(merged)) { 43 | if (!keys.has(key)) 44 | delete (merged as any)[key] 45 | } 46 | 47 | return merged as T 48 | } 49 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | 3 | /** 4 | * Alias to `Linter.Config` 5 | * 6 | * @deprecated 7 | */ 8 | export interface FlatConfigItem extends Linter.Config {} 9 | 10 | /** 11 | * A type that can be awaited. Promise or T. 12 | */ 13 | export type Awaitable = T | Promise 14 | 15 | /** 16 | * A type that can be an array or a single item. 17 | */ 18 | export type Arrayable = T | T[] 19 | 20 | /** 21 | * Default config names map. Used for type augmentation. 22 | * 23 | * @example 24 | * ```ts 25 | * declare module 'eslint-flat-config-utils' { 26 | * interface DefaultConfigNamesMap { 27 | * 'my-custom-config': true 28 | * } 29 | * } 30 | * ``` 31 | */ 32 | export interface DefaultConfigNamesMap {} 33 | 34 | interface Nothing { } 35 | 36 | /** 37 | * type StringLiteralUnion<'foo'> = 'foo' | string 38 | * This has auto completion whereas `'foo' | string` doesn't 39 | * Adapted from https://github.com/microsoft/TypeScript/issues/29729 40 | */ 41 | export type StringLiteralUnion = T | (U & Nothing) 42 | 43 | export type FilterType = T extends F ? T : never 44 | 45 | export type NullableObject = { 46 | [K in keyof T]?: T[K] | null | undefined 47 | } 48 | 49 | export type GetRuleRecordFromConfig = T extends { rules?: infer R } ? R : Linter.RulesRecord 50 | -------------------------------------------------------------------------------- /src/extend.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | import type { Awaitable } from './types' 3 | 4 | /** 5 | * Extend another flat configs and rename globs paths. 6 | * 7 | * @example 8 | * ```ts 9 | * import { extend } from 'eslint-flat-config-utils' 10 | * 11 | * export default [ 12 | * ...await extend( 13 | * // configs to extend 14 | * import('./other-configs/eslint.config.js').then(m => m.default), 15 | * // relative directory path 16 | * 'other-configs/', 17 | * ), 18 | * ] 19 | * ``` 20 | */ 21 | export async function extend( 22 | configs: Awaitable, 23 | relativePath: string, 24 | ): Promise { 25 | const { join } = await import('pathe') 26 | const resolved = await configs 27 | 28 | // same directory, no need to rename, return as is 29 | if (relativePath === '') 30 | return resolved 31 | 32 | function renameGlobs(i: string): string { 33 | if (typeof i !== 'string') 34 | return i 35 | if (i.startsWith('!')) 36 | return `!${join(relativePath, i.slice(1))}` 37 | return join(relativePath, i) 38 | } 39 | 40 | return resolved.map((i) => { 41 | if (!i || (!i.files && !i.ignores)) 42 | return i 43 | const clone = { ...i } 44 | if (clone.files) { 45 | clone.files = clone.files.map( 46 | f => Array.isArray(f) 47 | ? f.map(t => renameGlobs(t)) 48 | : renameGlobs(f), 49 | ) 50 | } 51 | if (clone.ignores) { 52 | clone.ignores = clone.ignores.map( 53 | f => renameGlobs(f), 54 | ) 55 | } 56 | return clone 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/hijack.ts: -------------------------------------------------------------------------------- 1 | import type { ESLint, Rule } from 'eslint' 2 | 3 | /** 4 | * Replace a rule in a plugin with given factory. 5 | */ 6 | export function hijackPluginRule( 7 | plugin: ESLint.Plugin, 8 | name: string, 9 | factory: (rule: Rule.RuleModule) => Rule.RuleModule, 10 | ): ESLint.Plugin { 11 | const original = plugin.rules?.[name] 12 | if (!original) { 13 | throw new Error(`Rule "${name}" not found in plugin "${plugin.meta?.name || plugin.name}"`) 14 | } 15 | const patched = factory(original) 16 | if (patched !== plugin.rules![name]) 17 | plugin.rules![name] = patched 18 | return plugin 19 | } 20 | 21 | const disabledRuleFixes = new WeakSet() 22 | 23 | /** 24 | * Hijack into a rule's `context.report` to disable fixes. 25 | */ 26 | export function disableRuleFixes(rule: Rule.RuleModule): Rule.RuleModule { 27 | if (disabledRuleFixes.has(rule)) { 28 | return rule 29 | } 30 | const originalCreate = rule.create.bind(rule) 31 | rule.create = (context): any => { 32 | const clonedContext = { ...context } 33 | const proxiedContext = new Proxy(clonedContext, { 34 | get(target, prop, receiver): any { 35 | if (prop === 'report') { 36 | return function (report: any) { 37 | if (report.fix) { 38 | delete report.fix 39 | } 40 | return (Reflect.get(context, prop, receiver) as any)({ 41 | ...report, 42 | fix: undefined, 43 | }) 44 | } 45 | } 46 | return Reflect.get(context, prop, receiver) 47 | }, 48 | set(target, prop, value, receiver): any { 49 | return Reflect.set(context, prop, value, receiver) 50 | }, 51 | }) 52 | const proxy = originalCreate(proxiedContext) 53 | return proxy 54 | } 55 | disabledRuleFixes.add(rule) 56 | return rule 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2 20 | 21 | - name: Set node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: lts/* 25 | 26 | - name: Setup 27 | run: npm i -g @antfu/ni 28 | 29 | - name: Install 30 | run: nci 31 | 32 | - name: Lint 33 | run: nr lint 34 | 35 | typecheck: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - name: Install pnpm 41 | uses: pnpm/action-setup@v2 42 | 43 | - name: Set node 44 | uses: actions/setup-node@v3 45 | with: 46 | node-version: lts/* 47 | 48 | - name: Setup 49 | run: npm i -g @antfu/ni 50 | 51 | - name: Install 52 | run: nci 53 | 54 | - name: Typecheck 55 | run: nr typecheck 56 | 57 | test: 58 | runs-on: ${{ matrix.os }} 59 | 60 | strategy: 61 | matrix: 62 | node: [lts/*] 63 | os: [ubuntu-latest, windows-latest, macos-latest] 64 | fail-fast: false 65 | 66 | steps: 67 | - uses: actions/checkout@v3 68 | 69 | - name: Install pnpm 70 | uses: pnpm/action-setup@v2 71 | 72 | - name: Set node ${{ matrix.node }} 73 | uses: actions/setup-node@v3 74 | with: 75 | node-version: ${{ matrix.node }} 76 | 77 | - name: Setup 78 | run: npm i -g @antfu/ni 79 | 80 | - name: Install 81 | run: nci 82 | 83 | - name: Build 84 | run: nr build 85 | 86 | - name: Test 87 | run: nr test 88 | -------------------------------------------------------------------------------- /src/rename.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | 3 | /** 4 | * Rename plugin prefixes in a rule object. 5 | * Accepts a map of prefixes to rename. 6 | * 7 | * @example 8 | * ```ts 9 | * import { renamePluginsInRules } from 'eslint-flat-config-utils' 10 | * 11 | * export default [{ 12 | * rules: renamePluginsInRules( 13 | * { 14 | * '@typescript-eslint/indent': 'error' 15 | * }, 16 | * { '@typescript-eslint': 'ts' } 17 | * ) 18 | * }] 19 | * ``` 20 | */ 21 | export function renamePluginsInRules(rules: Record, map: Record): Record { 22 | return Object.fromEntries( 23 | Object.entries(rules) 24 | .map(([key, value]) => { 25 | for (const [from, to] of Object.entries(map)) { 26 | if (key.startsWith(`${from}/`)) 27 | return [to + key.slice(from.length), value] 28 | } 29 | return [key, value] 30 | }), 31 | ) 32 | } 33 | 34 | /** 35 | * Rename plugin names a flat configs array 36 | * 37 | * @example 38 | * ```ts 39 | * import { renamePluginsInConfigs } from 'eslint-flat-config-utils' 40 | * import someConfigs from './some-configs' 41 | * 42 | * export default renamePluginsInConfigs(someConfigs, { 43 | * '@typescript-eslint': 'ts', 44 | * 'import-x': 'import', 45 | * }) 46 | * ``` 47 | */ 48 | export function renamePluginsInConfigs(configs: T[], map: Record): T[] { 49 | return configs.map((i) => { 50 | const clone = { ...i } 51 | if (clone.rules) 52 | clone.rules = renamePluginsInRules(clone.rules, map) 53 | if (clone.plugins) { 54 | clone.plugins = Object.fromEntries( 55 | Object.entries(clone.plugins) 56 | .map(([key, value]) => { 57 | if (key in map) 58 | return [map[key], value] 59 | return [key, value] 60 | }), 61 | ) 62 | } 63 | return clone 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-flat-config-utils", 3 | "type": "module", 4 | "version": "2.1.4", 5 | "packageManager": "pnpm@10.17.0", 6 | "description": "Utils for managing and manipulating ESLint flat config arrays", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu/eslint-flat-config-utils#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu/eslint-flat-config-utils.git" 14 | }, 15 | "bugs": "https://github.com/antfu/eslint-flat-config-utils/issues", 16 | "keywords": [ 17 | "eslint", 18 | "eslint-flat-config" 19 | ], 20 | "sideEffects": false, 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.mts", 24 | "default": "./dist/index.mjs" 25 | } 26 | }, 27 | "main": "./dist/index.mjs", 28 | "module": "./dist/index.mjs", 29 | "types": "./dist/index.d.mts", 30 | "typesVersions": { 31 | "*": { 32 | "*": [ 33 | "./dist/*", 34 | "./dist/index.d.ts" 35 | ] 36 | } 37 | }, 38 | "files": [ 39 | "dist" 40 | ], 41 | "scripts": { 42 | "build": "unbuild", 43 | "dev": "unbuild --stub", 44 | "lint": "eslint .", 45 | "prepublishOnly": "nr build", 46 | "release": "bumpp && npx jsr publish --allow-slow-types", 47 | "start": "tsx src/index.ts", 48 | "test": "vitest", 49 | "typecheck": "tsc --noEmit", 50 | "prepare": "simple-git-hooks" 51 | }, 52 | "dependencies": { 53 | "pathe": "^2.0.3" 54 | }, 55 | "devDependencies": { 56 | "@antfu/eslint-config": "^5.4.1", 57 | "@antfu/ni": "^26.0.1", 58 | "@antfu/utils": "^9.2.1", 59 | "@types/node": "^24.5.2", 60 | "bumpp": "^10.2.3", 61 | "eslint": "^9.36.0", 62 | "eslint-plugin-unused-imports": "^4.2.0", 63 | "jsr": "^0.13.5", 64 | "lint-staged": "^16.1.6", 65 | "pnpm": "^10.17.0", 66 | "rimraf": "^6.0.1", 67 | "simple-git-hooks": "^2.13.1", 68 | "tsx": "^4.20.5", 69 | "typescript": "^5.9.2", 70 | "unbuild": "^3.6.1", 71 | "vite": "^7.1.6", 72 | "vitest": "^3.2.4" 73 | }, 74 | "simple-git-hooks": { 75 | "pre-commit": "pnpm lint-staged" 76 | }, 77 | "lint-staged": { 78 | "*": "eslint --fix" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/disable-fixes.test.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from 'eslint' 2 | import pluginUnusedImports from 'eslint-plugin-unused-imports' 3 | import { expect, it } from 'vitest' 4 | import { composer } from '../src' 5 | 6 | it('for 3rd party plugins', async () => { 7 | const raw: Linter.Config[] = [ 8 | { 9 | name: 'test', 10 | plugins: { 11 | 'unused-imports': pluginUnusedImports as any, 12 | }, 13 | rules: { 14 | 'unused-imports/no-unused-imports': 'error', 15 | }, 16 | }, 17 | ] 18 | 19 | const fixture = 'import { foo, bar } from "bar"\nbar()' 20 | 21 | const linter = new Linter({ 22 | cwd: process.cwd(), 23 | configType: 'flat', 24 | }) 25 | const resultNormal = linter.verifyAndFix(fixture, raw) 26 | expect(resultNormal).toMatchInlineSnapshot(` 27 | { 28 | "fixed": true, 29 | "messages": [], 30 | "output": "import { bar } from "bar" 31 | bar()", 32 | } 33 | `) 34 | 35 | const configs = composer(raw) 36 | 37 | configs.disableRulesFix( 38 | ['unused-imports/no-unused-imports'], 39 | ) 40 | 41 | const resolved = await configs 42 | 43 | const result = linter.verifyAndFix(fixture, resolved) 44 | expect(result) 45 | .toMatchInlineSnapshot(` 46 | { 47 | "fixed": false, 48 | "messages": [ 49 | { 50 | "column": 10, 51 | "endColumn": 13, 52 | "endLine": 1, 53 | "line": 1, 54 | "message": "'foo' is defined but never used.", 55 | "messageId": "unusedVar", 56 | "nodeType": null, 57 | "ruleId": "unused-imports/no-unused-imports", 58 | "severity": 2, 59 | }, 60 | ], 61 | "output": "import { foo, bar } from "bar" 62 | bar()", 63 | } 64 | `) 65 | }) 66 | 67 | it('for builtin plugins', async () => { 68 | const raw: Linter.Config[] = [ 69 | { 70 | name: 'test', 71 | rules: { 72 | 'prefer-const': 'error', 73 | }, 74 | }, 75 | ] 76 | 77 | const fixture = 'let foo = 1' 78 | 79 | const linter = new Linter({ 80 | cwd: process.cwd(), 81 | configType: 'flat', 82 | }) 83 | const resultNormal = linter.verifyAndFix(fixture, raw) 84 | expect(resultNormal).toMatchInlineSnapshot(` 85 | { 86 | "fixed": true, 87 | "messages": [], 88 | "output": "const foo = 1", 89 | } 90 | `) 91 | 92 | await expect(async () => { 93 | const configs = composer(raw) 94 | configs.disableRulesFix(['prefer-const']) 95 | await configs 96 | }) 97 | .rejects 98 | .toThrowErrorMatchingInlineSnapshot(`[Error: Patching core rule "prefer-const" require pass \`{ builtinRules: () => import('eslint/use-at-your-own-risk').then(r => r.builtinRules) }\` in the options]`) 99 | 100 | const configs = composer(raw) 101 | configs.disableRulesFix( 102 | ['prefer-const'], 103 | { 104 | builtinRules: () => import('eslint/use-at-your-own-risk').then(r => r.builtinRules), 105 | }, 106 | ) 107 | const resolved = await configs 108 | 109 | const result = linter.verifyAndFix(fixture, resolved) 110 | expect(result) 111 | .toMatchInlineSnapshot(` 112 | { 113 | "fixed": false, 114 | "messages": [ 115 | { 116 | "column": 5, 117 | "endColumn": 8, 118 | "endLine": 1, 119 | "line": 1, 120 | "message": "'foo' is never reassigned. Use 'const' instead.", 121 | "messageId": "useConst", 122 | "nodeType": "Identifier", 123 | "ruleId": "prefer-const", 124 | "severity": 2, 125 | }, 126 | ], 127 | "output": "let foo = 1", 128 | } 129 | `) 130 | }) 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-flat-config-utils 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![bundle][bundle-src]][bundle-href] 6 | [![JSDocs][jsdocs-src]][jsdocs-href] 7 | [![License][license-src]][license-href] 8 | 9 | Utils for managing and manipulating ESLint flat config arrays 10 | 11 | [Documentation](https://jsr.io/@antfu/eslint-flat-config-utils/doc) 12 | 13 | ## Install 14 | 15 | ```bash 16 | npm i eslint-flat-config-utils 17 | ``` 18 | 19 | ## Utils 20 | 21 | Most of the descriptions are written in JSDoc, you can find more details in the [documentation](https://jsr.io/@antfu/eslint-flat-config-utils/doc) via JSR. 22 | 23 | Here listing a few highlighted ones: 24 | 25 | ### `concat` 26 | 27 | Concatenate multiple ESLint flat configs into one, resolve the promises, and flatten the array. 28 | 29 | ```ts 30 | // eslint.config.mjs 31 | import { concat } from 'eslint-flat-config-utils' 32 | 33 | export default concat( 34 | { 35 | plugins: {}, 36 | rules: {}, 37 | }, 38 | // It can also takes a array of configs: 39 | [ 40 | { 41 | plugins: {}, 42 | rules: {}, 43 | } 44 | // ... 45 | ], 46 | // Or promises: 47 | Promise.resolve({ 48 | files: ['*.ts'], 49 | rules: {}, 50 | }) 51 | // ... 52 | ) 53 | ``` 54 | 55 | ### `composer` 56 | 57 | Create a chainable composer that makes manipulating ESLint flat config easier. 58 | 59 | It extends Promise, so that you can directly await or export it to `eslint.config.mjs` 60 | 61 | ```ts 62 | // eslint.config.mjs 63 | import { composer } from 'eslint-flat-config-utils' 64 | 65 | export default composer( 66 | { 67 | plugins: {}, 68 | rules: {}, 69 | } 70 | // ...some configs, accepts same arguments as `concat` 71 | ) 72 | .append( 73 | // appends more configs at the end, accepts same arguments as `concat` 74 | ) 75 | .prepend( 76 | // prepends more configs at the beginning, accepts same arguments as `concat` 77 | ) 78 | .insertAfter( 79 | 'config-name', // specify the name of the target config, or index 80 | // insert more configs after the target, accepts same arguments as `concat` 81 | ) 82 | .renamePlugins({ 83 | // rename plugins 84 | 'old-name': 'new-name', 85 | // for example, rename `n` from `eslint-plugin-n` to more a explicit prefix `node` 86 | 'n': 'node' 87 | // applies to all plugins and rules in the configs 88 | }) 89 | .override( 90 | 'config-name', // specify the name of the target config, or index 91 | { 92 | // merge with the target config 93 | rules: { 94 | 'no-console': 'off' 95 | }, 96 | } 97 | ) 98 | 99 | // And you can directly return the composer object to `eslint.config.mjs` 100 | ``` 101 | 102 | ##### `composer.renamePlugins` 103 | 104 | This helper renames plugins in all configurations in the composer. It is useful when you want to enforce a plugin to a custom name: 105 | 106 | ```ts 107 | const config = await composer([ 108 | { 109 | plugins: { 110 | n: pluginN, 111 | }, 112 | rules: { 113 | 'n/foo': 'error', 114 | } 115 | } 116 | ]) 117 | .renamePlugins({ 118 | n: 'node' 119 | }) 120 | 121 | // The final config will have `node/foo` rule instead of `n/foo` 122 | ``` 123 | 124 | ##### `composer.removeRules` 125 | 126 | This helper removes specified rules from all configurations in the composer. It is useful when you are certain that these rules are not needed in the final configuration. Unlike overriding with `off`, removed rules are not affected by priority considerations. 127 | 128 | ```ts 129 | const config = await composer([ 130 | { 131 | rules: { 132 | 'foo/bar': 'error', 133 | 'foo/baz': 'warn', 134 | } 135 | }, 136 | { 137 | files: ['*.ts'], 138 | rules: { 139 | 'foo/bar': 'off', 140 | } 141 | } 142 | // ... 143 | ]) 144 | .removeRules( 145 | 'foo/bar', 146 | 'foo/baz', 147 | ) 148 | 149 | // The final config will not have `foo/bar` and `foo/baz` rules at all 150 | ``` 151 | 152 | ##### `composer.disableRulesFix` 153 | 154 | This helper **hijack** plugins to make fixable rules non-fixable, useful when you want to disable auto-fixing for some rules but still keep them enabled. 155 | 156 | For example, if we want the rule to error when we use `let` on a const, but we don't want auto-fix to change it to `const` automatically: 157 | 158 | ```ts 159 | const config = await composer([ 160 | { 161 | plugins: { 162 | 'unused-imports': pluginUnusedImports, 163 | }, 164 | rules: { 165 | 'perfer-const': 'error', 166 | 'unused-imports/no-unused-imports': 'error', 167 | } 168 | } 169 | ]) 170 | .disableRulesFix( 171 | [ 172 | 'prefer-const', 173 | 'unused-imports/no-unused-imports', 174 | ], 175 | { 176 | // this is required only when patching core rules like `prefer-const` (rules without a plugin prefix) 177 | builtinRules: () => import('eslint/use-at-your-own-risk').then(r => r.builtinRules), 178 | }, 179 | ) 180 | ``` 181 | 182 | > [!NOTE] 183 | > This function **mutate** the plugin object which will affect all the references to the plugin object globally. The changes are not reversible in the current runtime. 184 | 185 | ### `extend` 186 | 187 | Extend another flat config from a different root, and rewrite the glob paths accordingly: 188 | 189 | ```ts 190 | import { extend } from 'eslint-flat-config-utils' 191 | 192 | export default [ 193 | ...await extend( 194 | import('./sub-package/eslint.config.mjs'), 195 | './sub-package/' 196 | ) 197 | ] 198 | ``` 199 | 200 | ## Sponsors 201 | 202 |

203 | 204 | 205 | 206 |

207 | 208 | ## License 209 | 210 | [MIT](./LICENSE) License © 2023-PRESENT [Anthony Fu](https://github.com/antfu) 211 | 212 | 213 | 214 | [npm-version-src]: https://img.shields.io/npm/v/eslint-flat-config-utils?style=flat&colorA=080f12&colorB=1fa669 215 | [npm-version-href]: https://npmjs.com/package/eslint-flat-config-utils 216 | [npm-downloads-src]: https://img.shields.io/npm/dm/eslint-flat-config-utils?style=flat&colorA=080f12&colorB=1fa669 217 | [npm-downloads-href]: https://npmjs.com/package/eslint-flat-config-utils 218 | [bundle-src]: https://img.shields.io/bundlephobia/minzip/eslint-flat-config-utils?style=flat&colorA=080f12&colorB=1fa669&label=minzip 219 | [bundle-href]: https://bundlephobia.com/result?p=eslint-flat-config-utils 220 | [license-src]: https://img.shields.io/github/license/antfu/eslint-flat-config-utils.svg?style=flat&colorA=080f12&colorB=1fa669 221 | [license-href]: https://github.com/antfu/eslint-flat-config-utils/blob/main/LICENSE 222 | [jsdocs-src]: https://img.shields.io/badge/jsdocs-reference-080f12?style=flat&colorA=080f12&colorB=1fa669 223 | [jsdocs-href]: https://www.jsdocs.io/package/eslint-flat-config-utils 224 | -------------------------------------------------------------------------------- /test/composer.test.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | import { describe, expect, it } from 'vitest' 3 | import { composer } from '../src/composer' 4 | 5 | it('empty', async () => { 6 | const p = composer() 7 | expect(await p).toEqual([]) 8 | }) 9 | 10 | it('operations', async () => { 11 | const p = composer([{ name: 'init' }]) 12 | .renamePlugins({ 13 | 'import-x': 'x', 14 | }) 15 | .append({ name: 'append' }) 16 | .prepend( 17 | { name: 'prepend' }, 18 | undefined, 19 | Promise.resolve([ 20 | { 21 | name: 'prepend2', 22 | plugins: { 'import-x': {} }, 23 | rules: { 'import-x/import': 'error' }, 24 | }, 25 | false as const, 26 | ]), 27 | ) 28 | .insertAfter('prepend', { name: 'insertAfter' }) 29 | .override('prepend2', { 30 | rules: { 31 | 'import-x/foo': 'error', 32 | }, 33 | }) 34 | expect(await p).toMatchInlineSnapshot(` 35 | [ 36 | { 37 | "name": "prepend", 38 | }, 39 | { 40 | "name": "insertAfter", 41 | }, 42 | { 43 | "name": "prepend2", 44 | "plugins": { 45 | "x": {}, 46 | }, 47 | "rules": { 48 | "x/foo": "error", 49 | "x/import": "error", 50 | }, 51 | }, 52 | { 53 | "name": "init", 54 | }, 55 | { 56 | "name": "append", 57 | }, 58 | ] 59 | `) 60 | }) 61 | 62 | it('onResolved', async () => { 63 | const p = composer([{ name: 'init' }]) 64 | .append({ name: 'append' }) 65 | .onResolved((configs) => { 66 | return [ 67 | ...configs, 68 | ...configs, 69 | ] 70 | }) 71 | expect(await p).toMatchInlineSnapshot(` 72 | [ 73 | { 74 | "name": "init", 75 | }, 76 | { 77 | "name": "append", 78 | }, 79 | { 80 | "name": "init", 81 | }, 82 | { 83 | "name": "append", 84 | }, 85 | ] 86 | `) 87 | }) 88 | 89 | it('clone', async () => { 90 | const p = composer([{ name: 'init' }]) 91 | .append({ name: 'append' }) 92 | .clone() 93 | .append({ name: 'append2' }) 94 | 95 | const clone = p.clone() 96 | clone.append({ name: 'append3' }) 97 | 98 | expect((await p).length).toBe((await clone).length - 1) 99 | }) 100 | 101 | it('config name completion', () => { 102 | type Names = 'foo' | 'bar' 103 | 104 | composer() 105 | .override('foo', { name: 'foo' }) 106 | // ^| here it should suggest 'foo' | 'bar' 107 | }) 108 | 109 | it('override rules', async () => { 110 | let p = composer([ 111 | { 112 | name: 'init', 113 | rules: { 114 | 'no-console': 'error', 115 | 'no-unused-vars': 'error', 116 | }, 117 | }, 118 | ]) 119 | .append({ 120 | name: 'init2', 121 | rules: { 122 | 'no-console': 'off', 123 | }, 124 | }) 125 | 126 | .overrideRules({ 127 | 'no-unused-vars': ['error', { vars: 'all', args: 'after-used' }], 128 | 'no-exists': null, 129 | }) 130 | 131 | expect(await p).toMatchInlineSnapshot(` 132 | [ 133 | { 134 | "name": "init", 135 | "rules": { 136 | "no-console": "error", 137 | "no-unused-vars": [ 138 | "error", 139 | { 140 | "args": "after-used", 141 | "vars": "all", 142 | }, 143 | ], 144 | }, 145 | }, 146 | { 147 | "name": "init2", 148 | "rules": { 149 | "no-console": "off", 150 | }, 151 | }, 152 | ] 153 | `) 154 | 155 | p = p.clone() 156 | .removeRules('no-console') 157 | 158 | expect(await p).toMatchInlineSnapshot(` 159 | [ 160 | { 161 | "name": "init", 162 | "rules": { 163 | "no-unused-vars": [ 164 | "error", 165 | { 166 | "args": "after-used", 167 | "vars": "all", 168 | }, 169 | ], 170 | }, 171 | }, 172 | { 173 | "name": "init2", 174 | "rules": {}, 175 | }, 176 | ] 177 | `) 178 | }) 179 | 180 | it('remove plugins', async () => { 181 | const p = composer([ 182 | { 183 | name: 'init', 184 | plugins: { 185 | node: {}, 186 | }, 187 | rules: { 188 | 'no-console': 'error', 189 | 'no-unused-vars': 'error', 190 | }, 191 | }, 192 | { 193 | rules: { 194 | 'node/no-console': 'error', 195 | 'node/no-unused-vars': 'error', 196 | }, 197 | }, 198 | { 199 | plugins: { 200 | node: {}, 201 | node2: {}, 202 | }, 203 | rules: { 204 | 'node/no-console': 'off', 205 | 'node/no-unused-vars': 'error', 206 | 'node2/no-unused-vars': 'error', 207 | }, 208 | }, 209 | ]) 210 | .removePlugins('node') 211 | 212 | expect(await p).toMatchInlineSnapshot(` 213 | [ 214 | { 215 | "name": "init", 216 | "plugins": {}, 217 | "rules": { 218 | "no-console": "error", 219 | "no-unused-vars": "error", 220 | }, 221 | }, 222 | { 223 | "rules": {}, 224 | }, 225 | { 226 | "plugins": { 227 | "node2": {}, 228 | }, 229 | "rules": { 230 | "node2/no-unused-vars": "error", 231 | }, 232 | }, 233 | ] 234 | `) 235 | }) 236 | 237 | describe('error', () => { 238 | it('error in config', async () => { 239 | const p = composer([{ name: 'init' }]) 240 | .append(Promise.reject(new Error('error in config'))) 241 | 242 | await expect(async () => await p).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: error in config]`) 243 | }) 244 | 245 | it('error in inset', async () => { 246 | const p = composer( 247 | { name: 'init1' }, 248 | { name: 'init2' }, 249 | { name: 'init3' }, 250 | ) 251 | .append( 252 | { name: 'append1' }, 253 | ) 254 | .insertAfter('init4', { name: 'insertAfter1' }) 255 | 256 | await expect(async () => await p) 257 | .rejects 258 | .toThrowErrorMatchingInlineSnapshot(` 259 | [Error: ESLintFlatConfigUtils: Failed to locate config with name "init4" 260 | Available names are: init1, init2, init3, append1] 261 | `) 262 | }) 263 | 264 | it('error in operation', async () => { 265 | const p = composer( 266 | { name: 'init1' }, 267 | { name: 'init2' }, 268 | { }, // unnamed 269 | ) 270 | .append( 271 | { name: 'append1' }, 272 | ) 273 | .override('init4', { name: 'insertAfter1' }) 274 | 275 | await expect(async () => await p) 276 | .rejects 277 | .toThrowErrorMatchingInlineSnapshot(` 278 | [Error: ESLintFlatConfigUtils: Failed to locate config with name "init4" 279 | Available names are: init1, init2, append1 280 | (1 unnamed configs)] 281 | `) 282 | }) 283 | 284 | it('error in conflicts', async () => { 285 | // No error without calling s 286 | await composer( 287 | { name: 'init1', plugins: { 'import-x': {} } }, 288 | { name: 'init2', plugins: { 'import-x': {} } }, 289 | ) 290 | 291 | await expect(async () => { 292 | await composer( 293 | { name: 'init1', plugins: { 'import-x': {} } }, 294 | { name: 'init2', plugins: { 'import-x': {} } }, 295 | ) 296 | .setPluginConflictsError() 297 | }) 298 | .rejects 299 | .toThrowErrorMatchingInlineSnapshot( 300 | `[Error: ESLintFlatConfigUtils: Different instances of plugin "import-x" found in multiple configs: init1, init2. It's likely you misconfigured the merge of these configs.]`, 301 | ) 302 | 303 | await expect(async () => { 304 | await composer( 305 | { name: 'init1', plugins: { 'import-x': {} } }, 306 | { name: 'init2', plugins: { 'import-x': {} } }, 307 | { name: 'init3', plugins: { import: {} } }, 308 | { name: 'init4', plugins: { import: {} } }, 309 | ) 310 | .setPluginConflictsError() 311 | .setPluginConflictsError( 312 | 'import', 313 | plugin => `Plugin "${plugin}" is duplicated in multiple configs`, 314 | ) 315 | }) 316 | .rejects 317 | .toThrowErrorMatchingInlineSnapshot(` 318 | [Error: ESLintFlatConfigUtils: 319 | 1: Different instances of plugin "import-x" found in multiple configs: init1, init2. It's likely you misconfigured the merge of these configs. 320 | 2: Plugin "import" is duplicated in multiple configs] 321 | `) 322 | }) 323 | }) 324 | -------------------------------------------------------------------------------- /src/composer.ts: -------------------------------------------------------------------------------- 1 | import type { Linter, Rule } from 'eslint' 2 | import type { Arrayable, Awaitable, DefaultConfigNamesMap, FilterType, GetRuleRecordFromConfig, NullableObject, StringLiteralUnion } from './types' 3 | import { disableRuleFixes, hijackPluginRule } from './hijack' 4 | import { mergeConfigs } from './merge' 5 | import { parseRuleId } from './parse' 6 | import { renamePluginsInConfigs } from './rename' 7 | 8 | export const DEFAULT_PLUGIN_CONFLICTS_ERROR = 'Different instances of plugin "{{pluginName}}" found in multiple configs: {{configNames}}. It\'s likely you misconfigured the merge of these configs.' 9 | 10 | export interface DisableFixesOptions { 11 | builtinRules?: Map | (() => Awaitable>) 12 | } 13 | 14 | export type PluginConflictsError = ( 15 | pluginName: string, 16 | configs: T[] 17 | ) => string 18 | 19 | /** 20 | * Awaitable array of ESLint flat configs or a composer object. 21 | */ 22 | export type ResolvableFlatConfig 23 | = | Awaitable> 24 | | Awaitable<(Linter.Config | false | undefined | null)[]> 25 | | FlatConfigComposer 26 | 27 | /** 28 | * Create a chainable composer object that makes manipulating ESLint flat config easier. 29 | * 30 | * It extends Promise, so that you can directly await or export it to `eslint.config.mjs` 31 | * 32 | * ```ts 33 | * // eslint.config.mjs 34 | * import { composer } from 'eslint-flat-config-utils' 35 | * 36 | * export default composer( 37 | * { 38 | * plugins: {}, 39 | * rules: {}, 40 | * } 41 | * // ...some configs, accepts same arguments as `concat` 42 | * ) 43 | * .append( 44 | * // appends more configs at the end, accepts same arguments as `concat` 45 | * ) 46 | * .prepend( 47 | * // prepends more configs at the beginning, accepts same arguments as `concat` 48 | * ) 49 | * .insertAfter( 50 | * 'config-name', // specify the name of the target config, or index 51 | * // insert more configs after the target, accepts same arguments as `concat` 52 | * ) 53 | * .renamePlugins({ 54 | * // rename plugins 55 | * 'old-name': 'new-name', 56 | * // for example, rename `n` from `eslint-plugin-n` to more a explicit prefix `node` 57 | * 'n': 'node' 58 | * // applies to all plugins and rules in the configs 59 | * }) 60 | * .override( 61 | * 'config-name', // specify the name of the target config, or index 62 | * { 63 | * // merge with the target config 64 | * rules: { 65 | * 'no-console': 'off' 66 | * }, 67 | * } 68 | * ) 69 | * 70 | * // And you an directly return the composer object to `eslint.config.mjs` 71 | * ``` 72 | */ 73 | export function composer< 74 | T extends Linter.Config = Linter.Config, 75 | ConfigNames extends string = keyof DefaultConfigNamesMap, 76 | >( 77 | ...configs: ResolvableFlatConfig[] 78 | ): FlatConfigComposer { 79 | return new FlatConfigComposer( 80 | ...configs, 81 | ) 82 | } 83 | 84 | /** 85 | * The underlying impolementation of `composer()`. 86 | */ 87 | export class FlatConfigComposer< 88 | T extends object = Linter.Config, 89 | ConfigNames extends string = keyof DefaultConfigNamesMap, 90 | > extends Promise { 91 | private _operations: ((items: T[]) => Promise)[] = [] 92 | private _operationsOverrides: ((items: T[]) => Promise)[] = [] 93 | private _operationsResolved: ((items: T[]) => Awaitable)[] = [] 94 | private _renames: Record = {} 95 | private _pluginsConflictsError = new Map() 96 | 97 | constructor( 98 | ...configs: ResolvableFlatConfig[] 99 | ) { 100 | super(() => {}) 101 | 102 | if (configs.length) 103 | this.append(...configs) 104 | } 105 | 106 | /** 107 | * Set plugin renames, like `n` -> `node`, `import-x` -> `import`, etc. 108 | * 109 | * This will runs after all config items are resolved. Applies to `plugins` and `rules`. 110 | */ 111 | public renamePlugins(renames: Record): this { 112 | Object.assign(this._renames, renames) 113 | return this 114 | } 115 | 116 | /** 117 | * Append configs to the end of the current configs array. 118 | */ 119 | public append(...items: ResolvableFlatConfig[]): this { 120 | const promise = Promise.all(items) 121 | this._operations.push(async (configs) => { 122 | const resolved = (await promise).flat().filter(Boolean) as T[] 123 | return [...configs, ...resolved] 124 | }) 125 | return this 126 | } 127 | 128 | /** 129 | * Prepend configs to the beginning of the current configs array. 130 | */ 131 | public prepend(...items: ResolvableFlatConfig[]): this { 132 | const promise = Promise.all(items) 133 | this._operations.push(async (configs) => { 134 | const resolved = (await promise).flat().filter(Boolean) as T[] 135 | return [...resolved, ...configs] 136 | }) 137 | return this 138 | } 139 | 140 | /** 141 | * Insert configs before a specific config. 142 | */ 143 | public insertBefore( 144 | nameOrIndex: StringLiteralUnion, 145 | ...items: ResolvableFlatConfig[] 146 | ): this { 147 | const promise = Promise.all(items) 148 | this._operations.push(async (configs) => { 149 | const resolved = (await promise).flat().filter(Boolean) as T[] 150 | const index = getConfigIndex(configs, nameOrIndex) 151 | configs.splice(index, 0, ...resolved) 152 | return configs 153 | }) 154 | return this 155 | } 156 | 157 | /** 158 | * Insert configs after a specific config. 159 | */ 160 | public insertAfter( 161 | nameOrIndex: StringLiteralUnion, 162 | ...items: ResolvableFlatConfig[] 163 | ): this { 164 | const promise = Promise.all(items) 165 | this._operations.push(async (configs) => { 166 | const resolved = (await promise).flat().filter(Boolean) as T[] 167 | const index = getConfigIndex(configs, nameOrIndex) 168 | configs.splice(index + 1, 0, ...resolved) 169 | return configs 170 | }) 171 | return this 172 | } 173 | 174 | /** 175 | * Provide overrides to a specific config. 176 | * 177 | * It will be merged with the original config, or provide a custom function to replace the config entirely. 178 | */ 179 | public override( 180 | nameOrIndex: StringLiteralUnion, 181 | config: T | ((config: T) => Awaitable), 182 | ): this { 183 | this._operationsOverrides.push(async (configs) => { 184 | const index = getConfigIndex(configs, nameOrIndex) 185 | const extended = typeof config === 'function' 186 | ? await config(configs[index]) 187 | : mergeConfigs(configs[index], config) as T 188 | configs.splice(index, 1, extended) 189 | return configs 190 | }) 191 | return this 192 | } 193 | 194 | /** 195 | * Provide overrides to multiple configs as an object map. 196 | * 197 | * Same as calling `override` multiple times. 198 | */ 199 | public overrides( 200 | overrides: Partial, T | ((config: T) => Awaitable)>>, 201 | ): this { 202 | for (const [name, config] of Object.entries(overrides)) { 203 | if (config) 204 | this.override(name, config) 205 | } 206 | return this 207 | } 208 | 209 | /** 210 | * Override rules and it's options in **all configs**. 211 | * 212 | * Pass `null` as the value to remove the rule. 213 | * 214 | * @example 215 | * ```ts 216 | * composer 217 | * .overrideRules({ 218 | * 'no-console': 'off', 219 | * 'no-unused-vars': ['error', { vars: 'all', args: 'after-used' }], 220 | * // remove the rule from all configs 221 | * 'no-undef': null, 222 | * }) 223 | * ``` 224 | */ 225 | public overrideRules( 226 | rules: NullableObject>, 227 | ): this { 228 | this._operationsOverrides.push(async (configs) => { 229 | for (const config of configs) { 230 | if (!('rules' in config) || !config.rules) 231 | continue 232 | 233 | const configRules = config.rules as Record 234 | 235 | for (const [key, value] of Object.entries(rules)) { 236 | if (!(key in configRules)) 237 | continue 238 | if (value == null) 239 | delete configRules[key] 240 | else 241 | configRules[key] = value 242 | } 243 | } 244 | return configs 245 | }) 246 | return this 247 | } 248 | 249 | /** 250 | * Remove rules from **all configs**. 251 | * 252 | * @example 253 | * ```ts 254 | * composer 255 | * .removeRules( 256 | * 'no-console', 257 | * 'no-unused-vars' 258 | * ) 259 | * ``` 260 | */ 261 | public removeRules( 262 | ...rules: StringLiteralUnion, string>, string>[] 263 | ): this { 264 | return this.overrideRules(Object.fromEntries( 265 | rules.map(rule => [rule, null]), 266 | ) as any) 267 | } 268 | 269 | /** 270 | * Remove plugins by name and all the rules referenced by them. 271 | * 272 | * @example 273 | * ```ts 274 | * composer 275 | * .removePlugins( 276 | * 'node' 277 | * ) 278 | * ``` 279 | * 280 | * The `plugins: { node }` and `rules: { 'node/xxx': 'error' }` will be removed from all configs. 281 | */ 282 | public removePlugins( 283 | ...names: string[] 284 | ): this { 285 | this._operationsOverrides.push(async (configs) => { 286 | for (const config of configs) { 287 | if ('plugins' in config && typeof config.plugins === 'object' && config.plugins) { 288 | for (const name of names) { 289 | if (name in config.plugins) 290 | delete (config.plugins as any)[name] 291 | } 292 | } 293 | if ('rules' in config && typeof config.rules === 'object' && config.rules) { 294 | for (const key of Object.keys(config.rules)) { 295 | if (names.some(n => key.startsWith(`${n}/`))) 296 | delete (config.rules as any)[key] 297 | } 298 | } 299 | } 300 | return configs 301 | }) 302 | return this 303 | } 304 | 305 | /** 306 | * Remove a specific config by name or index. 307 | */ 308 | public remove(nameOrIndex: ConfigNames | string | number): this { 309 | this._operations.push(async (configs) => { 310 | const index = getConfigIndex(configs, nameOrIndex) 311 | configs.splice(index, 1) 312 | return configs 313 | }) 314 | return this 315 | } 316 | 317 | /** 318 | * Replace a specific config by name or index. 319 | * 320 | * The original config will be removed and replaced with the new one. 321 | */ 322 | public replace( 323 | nameOrIndex: StringLiteralUnion, 324 | ...items: ResolvableFlatConfig[] 325 | ): this { 326 | const promise = Promise.all(items) 327 | this._operations.push(async (configs) => { 328 | const resolved = (await promise).flat().filter(Boolean) as T[] 329 | const index = getConfigIndex(configs, nameOrIndex) 330 | configs.splice(index, 1, ...resolved) 331 | return configs 332 | }) 333 | return this 334 | } 335 | 336 | /** 337 | * Hijack into plugins to disable fixes for specific rules. 338 | * 339 | * Note this mutates the plugin object, use with caution. 340 | * 341 | * @example 342 | * ```ts 343 | * const config = await composer(...) 344 | * .disableRulesFix([ 345 | * 'unused-imports/no-unused-imports', 346 | * 'vitest/no-only-tests' 347 | * ]) 348 | * ``` 349 | */ 350 | public disableRulesFix(ruleIds: string[], options: DisableFixesOptions = {}): this { 351 | this._operations.push(async (configs) => { 352 | for (const name of ruleIds) { 353 | const parsed = parseRuleId(name) 354 | if (!parsed.plugin) { 355 | if (!options.builtinRules) 356 | throw new Error(`Patching core rule "${name}" require pass \`{ builtinRules: () => import('eslint/use-at-your-own-risk').then(r => r.builtinRules) }\` in the options`) 357 | const builtinRules = typeof options.builtinRules === 'function' 358 | ? await options.builtinRules() 359 | : options.builtinRules 360 | const rule = builtinRules.get(name) 361 | if (!rule) 362 | throw new Error(`Rule "${name}" not found in core rules`) 363 | disableRuleFixes(rule) 364 | } 365 | else { 366 | const plugins = new Set((configs as Linter.Config[]).map(c => c.plugins?.[parsed.plugin!]).filter(x => !!x)) 367 | for (const plugin of plugins) { 368 | hijackPluginRule(plugin, parsed.rule, rule => disableRuleFixes(rule)) 369 | } 370 | } 371 | } 372 | return configs 373 | }) 374 | return this 375 | } 376 | 377 | /** 378 | * Set a custom warning message for plugins conflicts. 379 | * 380 | * The error message can be a string or a function that returns a string. 381 | * 382 | * Error message accepts template strings: 383 | * - `{{pluginName}}`: the name of the plugin that has conflicts 384 | * - `{{configName1}}`: the name of the first config that uses the plugin 385 | * - `{{configName2}}`: the name of the second config that uses the plugin 386 | * - `{{configNames}}`: a list of config names that uses the plugin 387 | * 388 | * When only one argument is provided, it will be used as the default error message. 389 | */ 390 | public setPluginConflictsError( 391 | warning?: string | PluginConflictsError 392 | ): this 393 | public setPluginConflictsError( 394 | pluginName: string, 395 | warning: string | PluginConflictsError, 396 | ): this 397 | public setPluginConflictsError( 398 | arg1: string | PluginConflictsError = DEFAULT_PLUGIN_CONFLICTS_ERROR, 399 | arg2?: string | PluginConflictsError, 400 | ): this { 401 | if (arg2 != null) 402 | this._pluginsConflictsError.set(arg1 as string, arg2) 403 | else 404 | this._pluginsConflictsError.set('*', arg1) 405 | return this 406 | } 407 | 408 | private _verifyPluginsConflicts(configs: T[]): void { 409 | if (!this._pluginsConflictsError.size) 410 | return 411 | 412 | const plugins = new Map() 416 | 417 | const names = new Set() 418 | 419 | for (const config of configs as Linter.Config[]) { 420 | if (!config.plugins) 421 | continue 422 | 423 | for (const [name, plugin] of Object.entries(config.plugins)) { 424 | names.add(name) 425 | if (!plugins.has(plugin)) 426 | plugins.set(plugin, { name, configs: [] }) 427 | plugins.get(plugin)!.configs.push(config) 428 | } 429 | } 430 | 431 | function getConfigName(config: Linter.Config): string { 432 | return config.name || `#${configs.indexOf(config as T)}` 433 | } 434 | 435 | const errors: string[] = [] 436 | for (const name of names) { 437 | const instancesOfName = [...plugins.values()].filter(p => p.name === name) 438 | if (instancesOfName.length <= 1) 439 | continue 440 | 441 | const configsOfName = instancesOfName.map(p => p.configs[0]) 442 | 443 | const message = this._pluginsConflictsError.get(name) || this._pluginsConflictsError.get('*') 444 | if (typeof message === 'function') { 445 | errors.push(message(name, configsOfName)) 446 | } 447 | else if (message) { 448 | errors.push( 449 | message 450 | .replace(/\{\{pluginName\}\}/g, name) 451 | .replace(/\{\{configName1\}\}/g, getConfigName(configsOfName[0])) 452 | .replace(/\{\{configName2\}\}/g, getConfigName(configsOfName[1])) 453 | .replace(/\{\{configNames\}\}/g, configsOfName.map(getConfigName).join(', ')), 454 | ) 455 | } 456 | } 457 | 458 | if (errors.length) { 459 | if (errors.length === 1) 460 | throw new Error(`ESLintFlatConfigUtils: ${errors[0]}`) 461 | else 462 | throw new Error(`ESLintFlatConfigUtils:\n${errors.map((e, i) => ` ${i + 1}: ${e}`).join('\n')}`) 463 | } 464 | } 465 | 466 | /** 467 | * Hook when all configs are resolved but before returning the final configs. 468 | * 469 | * You can modify the final configs here. 470 | */ 471 | public onResolved(callback: (configs: T[]) => Awaitable): this { 472 | this._operationsResolved.push(callback) 473 | return this 474 | } 475 | 476 | /** 477 | * Clone the composer object. 478 | */ 479 | public clone(): FlatConfigComposer { 480 | const composer = new FlatConfigComposer() 481 | composer._operations = this._operations.slice() 482 | composer._operationsOverrides = this._operationsOverrides.slice() 483 | composer._operationsResolved = this._operationsResolved.slice() 484 | composer._renames = { ...this._renames } 485 | composer._pluginsConflictsError = new Map(this._pluginsConflictsError) 486 | return composer 487 | } 488 | 489 | /** 490 | * Resolve the pipeline and return the final configs. 491 | * 492 | * This returns a promise. Calling `.then()` has the same effect. 493 | */ 494 | public async toConfigs(): Promise { 495 | let configs: T[] = [] 496 | for (const promise of this._operations) 497 | configs = await promise(configs) 498 | for (const promise of this._operationsOverrides) 499 | configs = await promise(configs) 500 | 501 | configs = renamePluginsInConfigs(configs, this._renames) as T[] 502 | 503 | for (const promise of this._operationsResolved) 504 | configs = await promise(configs) || configs 505 | 506 | this._verifyPluginsConflicts(configs) 507 | 508 | return configs 509 | } 510 | 511 | // eslint-disable-next-line ts/explicit-function-return-type 512 | then(onFulfilled: (value: T[]) => any, onRejected?: (reason: any) => any) { 513 | return this.toConfigs() 514 | .then(onFulfilled, onRejected) 515 | } 516 | 517 | // eslint-disable-next-line ts/explicit-function-return-type 518 | catch(onRejected: (reason: any) => any) { 519 | return this.toConfigs().catch(onRejected) 520 | } 521 | 522 | // eslint-disable-next-line ts/explicit-function-return-type 523 | finally(onFinally: () => any) { 524 | return this.toConfigs().finally(onFinally) 525 | } 526 | } 527 | 528 | function getConfigIndex(configs: Linter.Config[], nameOrIndex: string | number): number { 529 | if (typeof nameOrIndex === 'number') { 530 | if (nameOrIndex < 0 || nameOrIndex >= configs.length) 531 | throw new Error(`ESLintFlatConfigUtils: Failed to locate config at index ${nameOrIndex}\n(${configs.length} configs in total)`) 532 | return nameOrIndex 533 | } 534 | else { 535 | const index = configs.findIndex(config => config.name === nameOrIndex) 536 | if (index === -1) { 537 | const named = configs.map(config => config.name).filter(Boolean) 538 | const countUnnamed = configs.length - named.length 539 | const messages = [ 540 | `Failed to locate config with name "${nameOrIndex}"`, 541 | `Available names are: ${named.join(', ')}`, 542 | countUnnamed ? `(${countUnnamed} unnamed configs)` : '', 543 | ].filter(Boolean).join('\n') 544 | throw new Error(`ESLintFlatConfigUtils: ${messages}`) 545 | } 546 | return index 547 | } 548 | } 549 | 550 | /** 551 | * @deprecated Renamed to `composer`. 552 | */ 553 | export const pipe = composer 554 | 555 | /** 556 | * @deprecated Renamed to `FlatConfigComposer`. 557 | */ 558 | export class FlatConfigPipeline< 559 | T extends object = Linter.Config, 560 | ConfigNames extends string = string, 561 | > extends FlatConfigComposer {} 562 | --------------------------------------------------------------------------------