Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions packages/eslint-config/README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,35 @@ export default pleaseai({
})
```

### 표준 플러그인 이름(Canonical Plugin Names)

Bun의 내장 `bun lint`(canary)는 `eslint.config.*`를 그대로 읽고 일부 플러그인을 네이티브로 실행합니다. 다만 플러그인이 원래 이름과 패키지로 등록되어 있을 때만 그렇습니다. `canonicalPluginNames`를 켜면 이 조건에 맞춥니다:

- typescript-eslint 규칙을 `ts/*` 대신 `@typescript-eslint/*`로 보고합니다.
- `import/*` 규칙을 `eslint-plugin-import-lite` 대신 [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import)에서 가져옵니다. 규칙 ID는 `import/*` 그대로입니다.

```js
import pleaseai from '@pleaseai/eslint-config'

export default pleaseai({
canonicalPluginNames: true,
})
```

```bash
bun add -D eslint-plugin-import
```

[oven-sh/bun#45595](https://github.com/oven-sh/bun/pull/45595)의 Bun 빌드(2026년 10월)로 이 저장소를 측정하면, `bun lint --doctor`가 네이티브로 보고하는 규칙이 118개에서 150개로 늘어납니다. 이 수치는 Bun이 네이티브 규칙을 추가할 때마다 바뀝니다. 규칙이 검사하는 내용은 이전과 같습니다.

켜기 전에 확인하세요:

- **`eslint-disable` 주석을 고쳐야 합니다.** `ts/*` 규칙을 지정한 주석은 오류(`Definition for rule 'ts/…' was not found`)로 보고됩니다. 설정 파일에서 `ts/*`로 지정한 규칙 설정은 자동으로 이름이 바뀝니다.
- **`eslint-plugin-import` 2.32.0은 peer dependency에 ESLint 10을 포함하지 않습니다.** 테스트에서는 ESLint 10에서 동작했지만, npm은 `overrides`를 추가하거나 `--legacy-peer-deps`를 쓰지 않으면 설치를 거부합니다(`ERESOLVE`). bun과 pnpm은 경고만 합니다.
- 나머지 이름 변경(`style/*`, `node/*`, `test/*`, `yaml/*`)은 그대로 둡니다. 이 설정이 켜는 `node/*` 규칙은 Bun이 하나도 네이티브로 실행하지 않으므로, 이름을 바꿔도 disable 주석만 깨집니다.

측정 결과는 [이슈 #56](https://github.com/pleaseai/code-style/issues/56)에 있습니다.

## 선택적 설정

### React
Expand Down Expand Up @@ -489,6 +518,8 @@ npx @eslint/config-inspector
| `style/*` | `@stylistic/*` | [@stylistic/eslint-plugin](https://github.com/eslint-stylistic/eslint-stylistic) |
| `test/*` | `vitest/*` | [@vitest/eslint-plugin](https://github.com/vitest-dev/eslint-plugin-vitest) |

[`canonicalPluginNames`](#표준-플러그인-이름canonical-plugin-names)를 켜면 typescript-eslint는 `@typescript-eslint/*`를 유지하고, `import/*`는 `eslint-plugin-import`에서 가져옵니다.

## 버전 정책

`@pleaseai/eslint-config`는 [Semantic Versioning](https://semver.org/)을 따르지만, 여러 가지가 얽힌 스타일 프리셋이기 때문에 **규칙 변경은 breaking change로 취급하지 않습니다**. 아래 표는 `@antfu/eslint-config`에서 상속받은 정책과 동일합니다.
Expand Down
31 changes: 31 additions & 0 deletions packages/eslint-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,35 @@ export default pleaseai({
})
```

### Canonical Plugin Names

Bun's built-in `bun lint` (canary) reads `eslint.config.*` and runs some plugins natively, but only when they are registered under their usual names and packages. `canonicalPluginNames` is an opt-in that matches them:

- typescript-eslint rules are reported as `@typescript-eslint/*` instead of `ts/*`.
- `import/*` rules come from [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import) instead of `eslint-plugin-import-lite`. The rule IDs stay `import/*`.

```js
import pleaseai from '@pleaseai/eslint-config'

export default pleaseai({
canonicalPluginNames: true,
})
```

```bash
bun add -D eslint-plugin-import
```

Measured on this repository with the Bun build from [oven-sh/bun#45595](https://github.com/oven-sh/bun/pull/45595) (October 2026), it raises the rules `bun lint --doctor` reports as native from 118 to 150. The counts change as Bun adds native rules. The rules check the same code as before.

Before you turn it on:

- **Update your `eslint-disable` comments.** A comment that names a `ts/*` rule is reported as an error (`Definition for rule 'ts/…' was not found`). Rule settings in your config that use `ts/*` are renamed for you.
- **`eslint-plugin-import` 2.32.0 does not list ESLint 10 in its peer dependencies.** It runs with ESLint 10 in our tests, but npm refuses to install it (`ERESOLVE`) unless you add an `overrides` entry or use `--legacy-peer-deps`. bun and pnpm only warn.
- The other renames (`style/*`, `node/*`, `test/*`, `yaml/*`) stay as they are. Bun runs none of the `node/*` rules this config enables natively, so renaming them would only break disable comments.

See [issue #56](https://github.com/pleaseai/code-style/issues/56) for the measurements.

## Optional Configs

### React
Expand Down Expand Up @@ -489,6 +518,8 @@ This config inherits `@antfu/eslint-config`'s plugin renaming for a consistent D
| `style/*` | `@stylistic/*` | [@stylistic/eslint-plugin](https://github.com/eslint-stylistic/eslint-stylistic) |
| `test/*` | `vitest/*` | [@vitest/eslint-plugin](https://github.com/vitest-dev/eslint-plugin-vitest) |

With [`canonicalPluginNames`](#canonical-plugin-names), typescript-eslint keeps `@typescript-eslint/*`, and `import/*` comes from `eslint-plugin-import`.

## Versioning Policy

`@pleaseai/eslint-config` follows [Semantic Versioning](https://semver.org/), but because it's an opinionated style preset with many moving parts, **rule changes are not treated as breaking changes**. The tables below match the policy inherited from `@antfu/eslint-config`.
Expand Down
14 changes: 12 additions & 2 deletions packages/eslint-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,27 @@
"dist"
],
"scripts": {
"build": "tsdown"
"build": "tsdown",
"test": "bun test"
},
"peerDependencies": {
"eslint": "^10.4.0"
"eslint": "^10.4.0",
"eslint-plugin-import": "^2.32.0"
},
"peerDependenciesMeta": {
"eslint-plugin-import": {
"optional": true
}
},
"dependencies": {
"@antfu/eslint-config": "^9.5.1",
"eslint-plugin-package-json": "^1.10.1"
},
"devDependencies": {
"@types/bun": "^1.4.3",
"@types/node": "^25.9.9",
"eslint": "^10.12.0",
"eslint-plugin-import": "^2.32.0",
"tsdown": "^0.22.14",
"typescript": "^6.0.3"
}
Expand Down
91 changes: 87 additions & 4 deletions packages/eslint-config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,81 @@
import type { OptionsConfig, TypedFlatConfigItem } from '@antfu/eslint-config'
import antfu from '@antfu/eslint-config'
import type { ESLint, Linter } from 'eslint'
import antfu, { defaultPluginRenaming } from '@antfu/eslint-config'

export interface PleaseaiOptions extends OptionsConfig, Omit<TypedFlatConfigItem, 'files' | 'ignores'> {
/**
* Register plugins under the names and packages that Bun's `bun lint` runs
* natively:
*
* - typescript-eslint rules are reported as `@typescript-eslint/*` instead
* of `ts/*`.
* - `import/*` rules come from `eslint-plugin-import` instead of
* `eslint-plugin-import-lite`. It must be installed separately.
*
* Rule IDs change, so `eslint-disable` comments that name `ts/*` rules must
* be updated. Rule settings in this config that use `ts/*` are renamed for
* you.
*
* @default false
*/
canonicalPluginNames?: boolean
}

const { '@typescript-eslint': _ts, 'import-lite': _importLite, ...keptPluginRenaming } = defaultPluginRenaming
Comment thread
amondnet marked this conversation as resolved.

/** antfu's renaming, with typescript-eslint back under its own name. */
const canonicalPluginRenaming: Record<string, string> = {
...keptPluginRenaming,
ts: '@typescript-eslint',
}

/** `eslint-plugin-import-lite` and `eslint-plugin-import` spell these options differently. */
const typeSpecifierStyles = new Map([
['top-level', 'prefer-top-level'],
['inline', 'prefer-inline'],
])

async function loadEslintPluginImport(): Promise<ESLint.Plugin> {
try {
const plugin = await import('eslint-plugin-import')
return (plugin.default ?? plugin) as ESLint.Plugin
}
catch (error) {
throw new Error(
'`canonicalPluginNames` requires `eslint-plugin-import`. Install it with `bun add -D eslint-plugin-import`.',
{ cause: error },
)
}
}

function toPreferTypeSpecifierStyle(entry: Linter.RuleEntry): Linter.RuleEntry {
if (!Array.isArray(entry) || typeof entry[1] !== 'string') {
return entry
}
const [severity, style, ...rest] = entry
return [severity, typeSpecifierStyles.get(style) ?? style, ...rest]
}

function useEslintPluginImportOptions(configs: TypedFlatConfigItem[]): void {
for (const config of configs) {
// Plugin renaming has already copied every `rules` object, so it is safe
// to update in place. The typed rules only allow import-lite's values.
const rules: Record<string, Linter.RuleEntry | undefined> | undefined = config.rules
const entry = rules?.['import/consistent-type-specifier-style']
if (rules && entry !== undefined) {
rules['import/consistent-type-specifier-style'] = toPreferTypeSpecifierStyle(entry)
}
}
}

export function pleaseai(
options: OptionsConfig = {},
options: PleaseaiOptions = {},
...userConfigs: TypedFlatConfigItem[]
): ReturnType<typeof antfu> {
return antfu(
const { canonicalPluginNames = false, ...antfuOptions } = options
const autoRenamePlugins = antfuOptions.autoRenamePlugins ?? true

const composer = antfu(
{
lessOpinionated: true,
stylistic: {
Expand All @@ -15,14 +85,27 @@ export function pleaseai(
},
typescript: true,
gitignore: true,
...antfuOptions,
rules: {
'antfu/top-level-function': 'error',
'test/prefer-lowercase-title': 'off',
...antfuOptions.rules,
},
...options,
// antfu's renaming maps `@typescript-eslint` to `ts`, which would undo
// the rename below for configs that register the canonical name.
...(canonicalPluginNames ? { autoRenamePlugins: false } : {}),
},
...userConfigs,
)

if (!canonicalPluginNames) {
return composer
}

return composer
.renamePlugins(autoRenamePlugins ? canonicalPluginRenaming : { ts: '@typescript-eslint' })
.replacePlugin('import', () => loadEslintPluginImport())
.onResolved(useEslintPluginImportOptions)
}

export * from '@antfu/eslint-config'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Resolves the config in a process where `eslint-plugin-import` cannot be
// loaded. Pass `imports=false` to turn the import rules off.
import process from 'node:process'
import { plugin } from 'bun'

plugin({
name: 'hide eslint-plugin-import',
setup(build) {
build.onLoad({ filter: /[\\/]eslint-plugin-import[\\/]lib[\\/]index\.js$/ }, () => {
throw new Error('eslint-plugin-import is not installed')
})
},
})

async function main(): Promise<void> {
// Imported after the plugin is registered, so the hook sees its loads.
const { pleaseai } = await import('../../src/index')
await pleaseai({ canonicalPluginNames: true, imports: process.argv[2] !== 'imports=false' })
}

main().catch((error: unknown) => {

Check warning on line 21 in packages/eslint-config/test/fixtures/without-eslint-plugin-import.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer top-level await over using a promise chain.

See more on https://sonarcloud.io/project/issues?id=pleaseai_code-style&issues=AaFJDbCj-S1ooumMZSFQ&open=AaFJDbCj-S1ooumMZSFQ&pullRequest=61
console.error(error)
process.exitCode = 1
})
131 changes: 131 additions & 0 deletions packages/eslint-config/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { TypedFlatConfigItem } from '../src/index'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { describe, expect, test } from 'bun:test'
import { ESLint } from 'eslint'
import pluginImport from 'eslint-plugin-import'
import pleaseai from '../src/index'

function pluginNames(configs: TypedFlatConfigItem[]): Set<string> {
return new Set(configs.flatMap(config => Object.keys(config.plugins ?? {})))
}

function ruleIds(configs: TypedFlatConfigItem[]): string[] {
return configs.flatMap(config => Object.keys(config.rules ?? {}))
}

/** Resolves the canonical config in a child process where `eslint-plugin-import` cannot be loaded. */
function resolveWithoutEslintPluginImport(...args: string[]) {
const fixture = fileURLToPath(new URL('./fixtures/without-eslint-plugin-import.ts', import.meta.url))
return Bun.spawnSync([process.execPath, fixture, ...args], { stdout: 'ignore', stderr: 'pipe' })
}

function ruleEntry(configs: TypedFlatConfigItem[], id: string): unknown {
return configs.filter(config => config.rules?.[id] !== undefined).at(-1)?.rules?.[id]
}

describe('pleaseai', () => {
test('keeps antfu\'s plugin names by default', async () => {
const configs = await pleaseai()

expect(pluginNames(configs)).toContain('ts')
expect(pluginNames(configs)).not.toContain('@typescript-eslint')
expect(configs.find(config => config.name === 'antfu/imports/rules')?.plugins?.import).not.toBe(pluginImport)
})

test('merges rules from options with the PleaseAI defaults', async () => {
const configs = await pleaseai({ rules: { 'no-console': 'error', 'antfu/top-level-function': 'off' } })

expect(ruleEntry(configs, 'no-console')).toBe('error')
expect(ruleEntry(configs, 'antfu/top-level-function')).toBe('off')
expect(ruleEntry(configs, 'test/prefer-lowercase-title')).toBe('off')
})
})

describe('canonicalPluginNames', () => {
test('registers typescript-eslint as @typescript-eslint and keeps the other renames', async () => {
const configs = await pleaseai({ canonicalPluginNames: true })
const plugins = pluginNames(configs)

expect(plugins).toContain('@typescript-eslint')
expect(plugins).not.toContain('ts')
expect(plugins).toContain('style')
expect(plugins).toContain('node')
expect(ruleIds(configs).filter(id => id.startsWith('ts/'))).toEqual([])
})

test('renames ts/* rule settings from options and user configs', async () => {
const configs = await pleaseai(
{ canonicalPluginNames: true, typescript: { overrides: { 'ts/no-explicit-any': 'warn' } } },
{ rules: { 'ts/array-type': 'error' } },
)

expect(ruleEntry(configs, '@typescript-eslint/no-explicit-any')).toBe('warn')
expect(ruleEntry(configs, '@typescript-eslint/array-type')).toBe('error')
})

test('replaces eslint-plugin-import-lite with eslint-plugin-import', async () => {
const configs = await pleaseai({ canonicalPluginNames: true })

expect(configs.find(config => config.name === 'antfu/imports/rules')?.plugins?.import).toBe(pluginImport)
expect(ruleEntry(configs, 'import/consistent-type-specifier-style')).toEqual(['error', 'prefer-top-level'])
})

test('passes through type specifier styles that need no translation', async () => {
const rule = 'import/consistent-type-specifier-style'
const preferred = await pleaseai({ canonicalPluginNames: true, rules: { [rule]: ['error', 'prefer-top-level'] } })
// A prototype key must not resolve to an inherited member such as `Object.prototype.toString`.
const inherited = await pleaseai({ canonicalPluginNames: true, rules: { [rule]: ['error', 'toString' as 'inline'] } })

expect(ruleEntry(preferred, rule)).toEqual(['error', 'prefer-top-level'])
expect(ruleEntry(inherited, rule)).toEqual(['error', 'toString'])
})

test('registers no import plugin when imports are disabled', async () => {
const configs = await pleaseai({ canonicalPluginNames: true, imports: false })

expect(pluginNames(configs)).not.toContain('import')
Comment thread
amondnet marked this conversation as resolved.
})

test('does not load eslint-plugin-import when imports are disabled', () => {
const result = resolveWithoutEslintPluginImport('imports=false')

expect(result.stderr.toString()).toBe('')
expect(result.exitCode).toBe(0)
})

test('explains how to install eslint-plugin-import when it is missing', () => {
const result = resolveWithoutEslintPluginImport()

expect(result.exitCode).not.toBe(0)
expect(result.stderr.toString()).toContain('`canonicalPluginNames` requires `eslint-plugin-import`')
})

test('reports under the canonical rule IDs when linting', async () => {
const eslint = new ESLint({
overrideConfigFile: true,
overrideConfig: await pleaseai({ canonicalPluginNames: true }),
})
const code = [
'import { type A } from \'./a\'',
'',
'const b = 1',
'',
'import { c } from \'./c\'',
'',
'type D = { d: A }',
'',
'export { b, c }',
'export type { D }',
'',
].join('\n')

const [result] = await eslint.lintText(code, { filePath: 'src/example.ts' })
const reported = result!.messages.map(message => message.ruleId)

expect(result!.messages.filter(message => message.fatal)).toEqual([])
expect(reported).toContain('@typescript-eslint/consistent-type-definitions')
expect(reported).toContain('import/consistent-type-specifier-style')
expect(reported).toContain('import/first')
})
})
Loading
Loading