Skip to content

feat(eslint-config): restore canonical plugin names so Bun's lint runs typescript-eslint and n natively #56

Open

Description

@amondnet

Context

Bun's upcoming bun lint (tested on 1.4.4-canary.1) is an ESLint-compatible linter that reads eslint.config.* directly. With an ESLint config, it runs these rules natively: ESLint core, @typescript-eslint, import, react, react-hooks, 15 rules of n, and prettier/prettier. Every other plugin is loaded and run in JavaScript, which is slower.

The plugins docs call out our base config directly: plugins registered under a different name run in JavaScript, e.g. "@antfu/eslint-config registers @typescript-eslint as ts". @antfu/eslint-config 9.5.1 (our current base) still applies defaultPluginRenaming (@typescript-eslint → ts, n → node, import-lite → import, @stylistic → style, …).

Measurements

Repo: a ~2,250-file TypeScript/Markdown monorepo using @pleaseai/eslint-config 0.0.4 (antfu 8.3.0) plus eslint-plugin-sonarjs and a few local rules. ESLint 9.33, Intel i7-9700K 8 cores.

Result parity: bun lint reported the same errors and warnings as ESLint across all 2,250 files (19,407 errors / 264 warnings). The only difference was the reported column of 4 complexity findings.

Native vs. JS rules (bun lint --doctor --config eslint.config.mjs):

Config Rule prefixes Native / JS
as-is ts/…, node/… 106 / 231
pleaseai({ autoRenamePlugins: false, … }) still ts/…, node/… 106 / 231
pleaseai({…}).renamePlugins({ ts: '@typescript-eslint', node: 'n' }) @typescript-eslint/…, n/… 133 / 204

autoRenamePlugins: false has no effect because antfu's internal config blocks register the plugins as ts/node from the start. Reversing the rename with the composer moves all 27 typescript-eslint rules to the native path. Findings stay identical apart from the rule-ID prefix.

Timing, JS/TS files only (652 files, md/json/yml/toml excluded; 2 alternating runs). The machine was under heavy load (load average 150+), so treat these as indicative only:

Run ESLint 9.33 bun lint as-is bun lint renamed
1 50.2 s 19.8 s 11.8 s
2 31.7 s 20.0 s 17.1 s

Proposal

  1. Add an opt-in option (name TBD, e.g. canonicalPluginNames: true) that applies .renamePlugins({ ts: '@typescript-eslint', node: 'n' }) to the composed config.
  2. Evaluate replacing eslint-plugin-import-lite with eslint-plugin-import. Bun matches native rules by package, so import-lite's 6 rules stay in JS even under the import name. This trades away antfu's lighter dependency.
  3. Later: consider moving @stylistic formatting (65 rules, all in JS) to bun fmt, after checking that its Markdown output matches Prettier's.

Caveats

  • Renaming changes rule IDs. Consumers' eslint-disable … ts/… comments and rules: { 'ts/…': … } overrides break, so this should start as opt-in. Making it the default is a breaking change.
  • regexp (60), jsdoc (17), and unicorn (15) still run in JS. Bun ships unicorn natively, but only exposes it on the oxlint config path.
  • Markdown/JSON/YAML/TOML files are always handed to the installed ESLint, so whole-repo gains are limited for docs-heavy repos.
  • bun lint is not in a stable release yet (the stable 1.4.3 has no lint command), and a package.json lint script shadows the built-in. These numbers should be re-measured on the release.

Activity

  1. self-assigned this
    on Oct 15, 2026
  2. amondnet commented on Oct 15, 2026

    @amondnet
    ContributorAuthor

    Follow-up: Bun's built-in unicorn rules are not reachable from an ESLint config

    Bun ships 138 unicorn/* rules natively (rules list), but the docs don't say how they get enabled. I tested each path on 1.4.4-canary.1:

    Setup Result
    .oxlintrc.json only unicorn runs natively (97 native / 0 JS)
    bunfig.toml [lint.rules] only unicorn runs natively (no-null reported)
    ESLint config + --native-plugin-rules=unicorn error: the flag only accepts @typescript-eslint, react-hooks, import, n, react, prettier
    ESLint config + --rule 'unicorn/no-null: error' runs in JS via eslint-plugin-unicorn (JS unicorn rules 15 → 16)
    ESLint config + bunfig.toml [lint.rules] config error: Could not find plugin "unicorn" in configuration

    The built-in unicorn rules only work with an oxlint config or a bunfig-only setup. Once an ESLint config is present, bunfig.toml rules are read with ESLint semantics: an unregistered plugin is an error, and a registered one runs in JS. For this package, that means the 15 unicorn rules antfu enables stay in JS no matter how the plugins are named. Renaming plugins (this issue's proposal) doesn't change that.

    There are two ways to change it. One is moving the shared config to an oxlint config, which is a large migration. The other is asking Bun to match eslint-plugin-unicorn natively on the ESLint config path, the way it already does for @typescript-eslint and n. The second is much cheaper and would help every antfu-based consumer. I'd treat it as out of scope here and track it upstream.

  3. amondnet commented on Oct 15, 2026

    @amondnet
    ContributorAuthor

    Correction and follow-up: unicorn is excluded by design, and oxlint.config.ts can share configs as a package

    Correction. My previous comment said the docs don't explain how the built-in unicorn rules get enabled. They do: the plugins page states it.

    With an ESLint config, six plugins run natively: @typescript-eslint, react, react-hooks, import, n, and prettier. Everything else runs from node_modules, including unicorn, jsx-a11y, promise, jest, and vitest. Bun's built-in versions of those follow oxlint, so Bun only uses them with bunfig.toml or an oxlint config.

    So keeping unicorn in JS on the ESLint config path is intentional, not a gap. Asking Bun for an eslint-plugin-unicorn-compatible native path would mean asking it to change a documented design, so I no longer think that's the cheap option.

    Why it's excluded. I ran the 15 unicorn rules antfu enables through eslint-plugin-unicorn 64.0.0 and through Bun's built-ins, on a fixture with violations, non-violations, and option edge cases. The two disagree:

    Rule Code eslint-plugin-unicorn 64.0.0 Bun built-in
    prefer-includes arr.some(x => x === 1) (also 1 === x, block body) reported not reported
    prefer-type-error if (!(v instanceof Date)) { throw new Error('bad') } reported not reported
    prefer-node-protocol TS import cp = require('child_process') not reported reported
    escape-case option "uppercase" accepted config error: "This rule does not accept configuration options"
    number-literal-case option { hexadecimalValue: "uppercase" } accepted same config error

    The other 10 rules reported the same lines: error-message, new-for-builtins, no-instanceof-builtins, no-new-array, no-new-buffer, consistent-empty-array-spread, prefer-dom-node-text-content, prefer-number-properties, prefer-string-starts-ends-with, and throw-new-error. On the real codebase (650 JS/TS files), both sides found the same single finding.

    Sharing a native config as a package. bunfig.toml has no extends, so I checked how a config built from Bun's built-in rules could be published and reused. The test used a fake package @acme/lint-config in node_modules, on 1.4.4-canary.1:

    Approach Result
    bunfig.toml [lint] extends = "..." fails: Unknown key "extends" in [lint]
    bunfig.toml [lint] config = "node_modules/@acme/lint-config/oxlintrc.json" works; local [lint.rules] overrides apply
    bunfig.toml [lint] config = "@acme/lint-config/oxlint" (package specifier) fails: resolved as a file path
    .oxlintrc.json "extends": ["./node_modules/@acme/lint-config/oxlintrc.json"] works; local rules overrides apply
    .oxlintrc.json "extends": ["@acme/lint-config/oxlint"] (package specifier) fails
    oxlint.config.ts with import base from '@acme/lint-config', spread and overridden works: 97 native, 0 JS
    eslint.config.mjs importing the same package (unicorn plugin not registered) fails: 1 rule in eslint.config.mjs is not supported: unicorn/no-null

    oxlint.config.ts composes like a flat config: import a package, spread it, override. On that path every built-in rule runs natively, unicorn included. The JSON-based options can only point at a node_modules/... file path.

    What this means for this repo. A Bun-native shared config is possible as a separate package (e.g. @pleaseai/oxlint-config) consumed from oxlint.config.ts. That is a move from ESLint to oxlint, though. Plugins Bun doesn't ship (@stylistic, perfectionist, regexp, jsdoc) would run in JS via jsPlugins, and unicorn would follow oxlint's port, with the differences above. Not yet tested: jsPlugins and overrides inside oxlint.config.ts, and importing a package that ships TS source rather than .mjs.

    The proposal in this issue (restoring canonical plugin names) still stands for the ESLint path. The oxlint-config package is a separate, larger option.

  4. amondnet commented on Oct 16, 2026

    @amondnet
    ContributorAuthor

    Follow-up: migrating this config to an oxlint config with jsPlugins

    I converted a consuming repo's eslint.config.mjs (@pleaseai/eslint-config, defaults) with @oxlint/migrate 1.86.0 and ran it with bun lint on 1.4.4-canary.1. For JS/TS, the result nearly matches ESLint once four things are fixed. Non-JS files don't migrate.

    Raw migrate output

    @oxlint/migrate migrated 387 rules and skipped 20:

    • Nursery (2): no-undef, no-unreachable-loop
    • Not implemented (13): no-restricted-syntax, four node/* rules, seven jsdoc/* rules, vitest/no-only-tests
    • Unsupported (5): dot-notation and others

    It also dropped sort-imports, which it defers to Oxfmt. It warned that the yaml-eslint-parser, toml-eslint-parser, and parser-plain parsers and ignore lists inside overrides are not supported.

    The output also didn't load as-is. Two jsPlugins package names were wrong (eslint-comments, e18e), and the import/consistent-type-specifier-style option "top-level" had to become "prefer-top-level". After those fixes, --doctor reported 115 native rules, 151 JS rules, and 10 jsdoc/* rules that don't run (bun lint has no "jsdoc" rules).

    Four fixes, compared against ESLint

    Same 650 JS/TS files; ESLint with the original config reports 341 problems.

    Step Bun problems Cause and fix
    Migrate output (name/option fixes only) 166 complexity, max-lines*, and sonarjs report nothing; rules that should be off in scripts and tests are on
    1. Rewrite override globs 436 antfu's extglob patterns (**/*.?([cm])ts) don't match in bun lint, so those overrides are silently skipped. Brace globs (**/*.{,[cm]}ts) work. Reported upstream as oven-sh/bun#45470
    2. Add eslint-plugin-n and eslint-plugin-jsdoc as jsPlugins 462 node and jsdoc are reserved built-in names (Plugin name 'node' is reserved), so they need aliases (n-js, jsdoc-js). Migrate had also dropped node/prefer-global/process: off from the test override; without it, 231 extra problems appear
    3. Register @stylistic as style 376 By default the rule IDs become @stylistic/..., so existing eslint-disable style/... comments stop working (86 extra). { "name": "style", "specifier": "@stylistic/eslint-plugin" } fixes it

    After step 3, --doctor reports 145 native rules, 178 JS rules, and 0 that don't run. The JS rules are @stylistic 65, regexp 60, jsdoc 17, e18e 9, n 8, antfu 7, eslint-comments 4, perfectionist 4, unused-imports 2, command 1, and sonarjs 1.

    The remaining differences:

    • vitest/prefer-hooks-in-order (+35): The rules judge hook order the same way, but they check different hooks. @vitest/eslint-plugin 1.6.19 checks hooks imported from vitest or used as globals, and skips hooks imported from bun:test. Bun's built-in rule, an oxlint port, checks hooks from any import source. All 35 problems come from 19 test files that import their hooks from bun:test. In a minimal repro (beforeAll, afterAll, beforeEach, afterEach), both report 2 problems for a vitest import and for globals, while for a bun:test import ESLint reports 0 and Bun reports 2. oxlint 1.86.0 matches Bun.
    • max-lines (18): same count, different reported line.
    • no-use-before-define (1): same finding under a different rule ID (ts/ vs. core).

    Every other rule matched by count and line.

    Limits

    • Non-JS files are lost. Lint targets drop from 2,250 to 689 files (660 .ts, 21 .js, 8 .mjs). The JSON, YAML, TOML, and Markdown overrides carry over as jsPlugins, but no parser reads those files. JSON key sorting, YAML and TOML rules, and eslint-plugin-format Markdown formatting all go away.
    • Aliases only work for JS plugins. Built-in rules use oxlint names (typescript/, node/, vitest/), so a consumer's eslint-disable ts/... or test/... comments won't match.
    • Not yet tested as oxlint.config.ts. I tested the JSON output, not the same config written as oxlint.config.ts.

    Speed

    I timed bun lint and ESLint on the same 650 files, alternating, two runs each, at load average 20–40:

    • bun lint (oxlint config): 12.8–18.6 s
    • ESLint: 37.0–41.7 s

    That is about 2–3× faster. It is similar to the ESLint-config path with canonical plugin names, because 178 rules still run in JS.

    Takeaway

    A shareable oxlint.config.ts (for example, @pleaseai/oxlint-config) could carry the JS/TS rules with near-ESLint results if it includes:

    It can't replace this config on its own. JSON, YAML, TOML, and Markdown would still need ESLint or bun fmt. For the issue as filed, restoring canonical names in this config remains the smaller change.

  5. amondnet commented on Oct 16, 2026

    @amondnet
    ContributorAuthor

    Summary: ESLint-config path vs. oxlint-config path in bun lint

    All numbers come from bun lint 1.4.4-canary.1 run on the same consuming repo as the comments above.

    ESLint config as-is (eslint.config.mjs) oxlint config (.oxlintrc.json / oxlint.config.ts)
    Results vs. ESLint Same counts and locations on 2,250 files (only complexity columns differ) 341 (ESLint) vs. 376 (Bun) on 650 JS/TS files
    Non-JS files (Markdown, JSON, YAML, TOML) Linted as before Dropped, because no parser reads them (2,250 → 689 files)
    Changes to the config None Four fixes: extglob overrides (oven-sh/bun#45470, oxc-project/oxc#21525), reserved plugin names, a style alias, and the off overrides that migrate dropped
    unicorn, vitest, jest, … The installed plugins run from node_modules, so results match ESLint Bun's built-ins (oxlint ports) run instead. vs. eslint-plugin-unicorn 64.0.0: 3 rules differ in results, 2 in option handling. vitest/* also checks hooks imported from bun:test
    Disable comments (ts/..., test/...) Work unchanged Don't match built-in rules, which use oxlint names (typescript/, vitest/)
    Speed on JS/TS vs. ESLint 1.9–4.3× faster once this config restores the canonical plugin names 2–3× faster; 178 rules still run in JS

    Recommendation

    1. Keep this config and run it with bun lint. It is a drop-in replacement with ESLint-identical results, including non-JS files.
    2. For more speed, restore canonical plugin names here (.renamePlugins({ ts: '@typescript-eslint', node: 'n' })), as an option or a new default. This raises the native rule count from 106 to 133. It is a breaking change: consumers must update their disable comments and rule overrides.
    3. Don't move to an oxlint config for compatibility. It loses more than it gains. The one thing it adds is access to Bun's built-in unicorn, vitest, and jest rules. If we want those, it is a separate, larger project: a JS/TS-only oxlint.config.ts package, with ESLint or bun fmt still covering non-JS files.

    Caveat: bun lint is still a canary build. These differences should be re-checked on a stable release before we act on them.

  6. amondnet commented on Oct 17, 2026

    @amondnet
    ContributorAuthor

    Re-measured on this repo: antfu 9.5.1, canary 1.4.4-canary.1+5f5799ed1

    This run used this repo's own eslint.config.ts (@pleaseai/eslint-config on @antfu/eslint-config 9.5.1, astro: true), ESLint 10.12.0, and 158 files. The canary was built from a 2026-10-15 commit, so it includes neither oven-sh/bun#45482 (the #45470 extglob fix) nor oven-sh/bun#45595 (native JSON/Markdown).

    As-is (ts/, node/) .renamePlugins({ ts: '@typescript-eslint', node: 'n' })
    Native / JS rules (--doctor) 103 / 253 129 / 227 (+26, all typescript-eslint)
    Findings vs. ESLint (22-problem fixture) identical rule, line, column identical after the prefix change, except the next row
    Existing // eslint-disable-next-line ts/… works error: Definition for rule 'ts/no-explicit-any' was not found
    Whole repo, 3 runs (ESLint 5.1–5.5 s) 4.7–5.3 s 4.6–6.2 s

    There's no speedup at this size. Fixed costs dominate: loading the JS plugins takes about 1.2 s, and 112 files are handed to the installed ESLint.

    New findings

    1. Renaming node → n gains nothing. Bun runs only part of eslint-plugin-n natively: n/no-process-exit runs natively, while n/no-sync, n/no-path-concat, and n/no-exports-assign stay in JS, even when registered as n. None of the 8 node/* rules antfu enables runs natively. The +27 in the original measurement also came entirely from typescript-eslint. For antfu's defaults, only the ts → @typescript-eslint half of this proposal pays off.
    2. The broken disable comment is an error, not a silent miss. That supports making the rename opt-in, or shipping it in a major release with a migration note.
    3. A package.json lint script shadows the built-in, here eslint . --cache. Only --doctor reaches the built-in linter. The documented bun lint --no-script flag isn't in this canary yet.
    4. --doctor prints You can remove: @pleaseai/eslint-config even though eslint.config.ts imports it. Every consumer of this package would get the same wrong advice. This looks worth reporting upstream.
    5. antfu defines its test plugin inside the config, and --timing reports loaded the whole config file for the plugin "test", so every thread evaluates the whole config.

    Tracking

    I'm keeping a tracking doc (docs/research/bun-lint-compatibility.md) with a watch list and re-check procedure until bun lint ships in a stable Bun. These numbers should be re-run on that release before we act on the proposal.

  7. amondnet commented on Oct 17, 2026

    @amondnet
    ContributorAuthor

    Upstream comparison (antfu, eslint-config-xo) and the oven-sh/bun#45595 PR build

    These follow up the previous measurement. The tracking doc is in #60 (docs/research/bun-lint-compatibility.md).

    Upstream configs under canary 1.4.4-canary.1+5f5799ed1

    Both configs were measured in scratch projects with ESLint 10.13.0, TypeScript 6.0.3, and typescript-eslint 8.71.2. The fixtures were the same violation files plus 21 TS files from packages/cli/src.

    @antfu/eslint-config 9.5.1 eslint-config-xo 6.0.0
    Plugin names renamed (ts, node, import, style, …) canonical (@typescript-eslint, n, import-x, @stylistic, …)
    Native / JS rules (--doctor) 102 / 242; 128 / 216 after renaming back 246 / 638 (93 typescript-eslint + 153 core native)
    typescript-eslint runs natively only after .renamePlugins({ ts: '@typescript-eslint' }) yes, as-is
    n rules native 0 of 8 0 of 18
    import rules native 0 of 6 (import-lite) 0 of 22 (import-x; --native-plugin-rules=import changes nothing)
    Type-aware rules off by default on (projectService: true)
    Findings vs. ESLint, fixtures identical (22) identical (73), and also vs. the xo 5.0.1 CLI
    Findings vs. ESLint, whole project identical 16 missing, with no warning
    Wall time vs. ESLint no gain (2.7–3.3 s for both) ~1.5× faster (3.7 s vs. 5.5 s)
    • JS plugin rules lose type information. The 16 missing problems are 10 unicorn/consistent-boolean-name and 6 regexp/*. These rules read types through parserServices, but Bun gives types only to its native rules. The JS rules report less, and --doctor still says 0 rules aren't run. For unicorn, the missed pattern confirms the cause: const ok = await confirmOverwrite(...) is a boolean only the type checker can see. For regexp, the cause is inferred. Our config isn't type-aware today, but it would hit this once antfu's type-aware mode (typescript: { tsconfigPath }) is enabled.
    • Canonical names aren't enough for n and import. eslint-config-xo shows that with canonical names, typescript-eslint runs natively, but none of the n or import-x rules do. That strengthens the earlier point: the ts → @typescript-eslint half of this proposal pays off, and the node → n half doesn't. Replacing import-lite with eslint-plugin-import (proposal item 2) is still untested.
    • The false "You can remove: @pleaseai/eslint-config" advice is ours alone. It doesn't appear with plain antfu or with eslint-config-xo, so it seems specific to this repo's wrapper loaded through the workspace link.

    Upstream positions:

    oven-sh/bun#45595 (native JSON and Markdown), PR build 1.4.4-canary.1+a1ab4b531

    No canary includes it yet, so I used the PR's CI build (bunx bun-pr 45595). It also doesn't contain the oven-sh/bun#45482 fix.

    Config Native / JS before → after Files to ESLint Findings vs. ESLint
    this repo, as-is 103 / 253 → 118 / 238 112 → 78 identical (0 / 0)
    this repo, canonical names 129 / 227 → 144 / 212 same —
    @antfu/eslint-config 9.5.1 102 / 242 → 117 / 227 2 JSON identical (26)
    eslint-config-xo 6.0.0 246 / 638 → 266 / 638 6 → 3 (CSS, HTML, package.json) same 16 type-related misses
    • Markdown moves. The 15 @eslint/markdown rules antfu enables now run natively. Code blocks inside Markdown give the same results as before.
    • JSON doesn't move for antfu-based configs. antfu lints JSON with eslint-plugin-jsonc on jsonc-eslint-parser, not @eslint/json, so all 17 JSON files here still go to ESLint, along with YAML and TOML. eslint-config-xo uses @eslint/json, so its JSON runs natively, except package.json.
    • --no-script works in this build, so bun lint can reach the built-in linter even with a package.json lint script.
    • Wall time on this repo is about the same: 4.3–5.5 s vs. ESLint's 5.3–6.0 s, at a load average of 5–8.

    Everything here should be re-run on a release canary and then on the stable release before we act on the proposal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions