RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

AI 時代の lint / formatter として Oxc に移行した話

はじめに

こんにちは。RevComm でエンジニアをしているです。

MiiTel Phone では長らく ESLint + Prettier をフロントエンドの lint / formatter として使ってきましたが、約 1,300 ファイル規模の React + TypeScript プロジェクトで CI と AI コーディングループのフィードバックがどうしても重くなってきていました。

今回、Rust 製ツールチェインである Oxc ベースの Oxlint / Oxfmt へ完全移行したので、その経緯と方法、得られたメリット・デメリットについて紹介します。

Oxc とは

Oxc (The JavaScript Oxidation Compiler) は、パーサー・リンター (Oxlint)・フォーマッター (Oxfmt)・トランスパイラーといった JS/TS ツールチェインを Rust で書き直す OSS プロジェクトです。今回扱うのは次の 2 つ。

  • Oxlint: ESLint 互換のルールセットを持つ Rust 製リンター。公式ドキュメントでは ESLint 比 50〜100 倍速いとしている。JS Plugins (Alpha) と Type-Aware Linting (Alpha) も順次拡張中
  • Oxfmt: 2026-02 に Beta。Prettier v3.8 の JS/TS conformance 100% を達成し、出力互換を保ったまま高速化できる

開発主体は VoidZero Inc. — Vue.js / Vite の作者 Evan You 氏が 2024 年に設立したスタートアップです。Vite / Vitest / Rolldown / tsdown / Oxc を開発しています。

Biome ではなく Oxc を選んだ理由

似た立ち位置のツールに Biome がありますが、決め手は速度や Prettier 互換ではなく、周辺ツール全体を束ねる存在 (Vite+) でした。

観点 Oxc (Oxlint / Oxfmt) Biome
開発主体 VoidZero (Vite 作者の会社) Biome コミュニティ
周辺ツールとの統合 Vite+ で build / test / lint / format が同じロードマップに乗る 独立型
規則互換性 ESLint ルール名 / Prettier 出力と互換重視 独自ルールセット中心

Vite+ は Vite / Vitest / Oxlint / Oxfmt / Rolldown / tsdown を vp という単一 CLI に束ねた統合ツールチェインです。

今回のプロジェクトも将来的に Vite+ への合流を視野に入れており、その時点で Oxlint / Oxfmt に乗っておいて良かったと言える状態を作りたかったのが Oxc 採用の主な動機です。

Biome に乗ると、将来 Vite+ に揃えたいときに lint / format をもう一度乗り換えるコストが再発します。

移行理由

1. CI / pre-commit / editor の速度がボトルネックになっていた

全件 lint で 13 秒、format チェックで 5 秒というのは、単体で見ればまだ我慢できる範囲です。ただし PR ごとに何度も走り、ローカルの保存時 lint や pre-commit hook でも体感に効いてくるため、合計すると無視できない時間になっていました。

2. AI コーディング時代のフィードバックループ

Claude Code や Codex などの AI エージェントがほとんどのコードを書くようになると、lint は人間による最終レビューの代替手段から、AI がコードを書く途中でその場でミスを弾いて修正する仕組みに役割を変えます。

具体的には、エージェントの編集ごとに lint と formatter を回し、エラーを次のターンに即フィードバックして自己修正させるという使い方です。

ESLint の 1 ファイルあたり 1.72 秒という所要時間ではこのループに乗せづらく、Oxlint の 0.43 秒で初めて現実的になりました。

AGENTS.md / CLAUDE.md の自然言語ガイドは読んだ後に指示が守られるかどうかはお祈りレベルの保証しかなく、絶対に止めたい違反はツール側で機械的に弾いた方が確実です。

lint にその役割を担わせる場合、速度がそのままループの成立可否になります。加えて、一度入れたルールはその後の全セッションに効くので、AI が書くコード量が増えるほど 1 ルールあたりの効果が積み重なっていきます。

3. 設定の簡素化と依存削減

ESLint 設定は eslint.config.js が 220 行、プラグインが 11 個ぶら下がっていました。Oxlint の native plugin で同等のカバレッジが取れるなら、設定もぐっとシンプルになります。@eslint/compat のような互換レイヤーや、フォーマッター衝突回避のための周辺パッケージも整理できます。

4. Prettier 互換性が確保された

Oxfmt が Prettier v3.8 互換 100% を達成したことで、フォーマッターを乗り換えると全ファイルに差分が出るという移行最大の壁が消えました。これがなければ移行コストが見合わなかったので、Beta リリースを待ってから着手する予定でした。

移行方法

Oxc は単一ツールではなく個別のツール群なので、Prettier → Oxfmt と ESLint → Oxlint をそれぞれ独立した PR で進めました。先に Oxfmt 側を済ませてから Oxlint に取りかかる順番です。

Prettier から Oxfmt

1. 差分ゼロ確認

まずローカルで src 配下の全ファイル (約 1,300 ファイル) について、prettier --write の結果と oxfmt の結果が一致するか git diff で確認しました。

Beta とはいえ Prettier conformance テストが通っているので、現行コードに対しては実用上問題なしと判断しています。

2. 設定ファイル変換

Oxfmt は Prettier 設定からの自動変換コマンドを持っているので、これを使います。

npx oxfmt --migrate=prettier

生成された .oxfmtrc.json の例 ↓

{
  "$schema": "./node_modules/oxfmt/configuration_schema.json",
  "semi": true,
  "trailingComma": "all",
  "singleQuote": true,
  "printWidth": 120,
  "tabWidth": 2
}

.prettierrc / .prettierignore は削除します。

3. scripts / hooks / codegen の置き換え

// package.json (抜粋)
{
  "scripts": {
    "lint:oxfmt": "oxfmt --check 'src/**/*.{ts,tsx,mdx}'",
    "fix:oxfmt": "oxfmt 'src/**/*.{ts,tsx,mdx}'"
  }
}

pre-commit hook の Prettier エントリも oxfmt に置き換え、graphql-codegenafterAllFileWrite や独自 codegen スクリプトで prettier --write を呼び出していた箇所も npx oxfmt に差し替えます。CI ワークフローのジョブ名もここで変更しました。

4. eslint-config-prettier はこの段階では据え置き

公式ドキュメントの推奨に従い、この段階では eslint-config-prettier をそのまま残しました。フォーマッタと衝突する ESLint ルール (indent など) を無効化する役割は、Prettier でも Oxfmt でも変わらないためです。

なお ESLint 本体を撤去する後続の Oxlint 移行 PR で、同パッケージも併せて削除しています。

ESLint から Oxlint

Prettier 移行と違ってこちらは挙動の互換性を見るだけではなく、ルールセットの取捨選択が必要です。方針として「native plugin 主軸、必要なものだけ JS Plugins (Alpha) で補う」を取りました。

1. 完全移行か併用か

Oxlint 公式ドキュメントは「小〜中規模は完全置換、大規模は ESLint との併用を推奨」としています。併用したい場合は eslint-plugin-oxlint で Oxlint が拾えるルールを ESLint 側で重複排除し、両者を直列で回すのが定石です。

{ "scripts": { "lint": "oxlint && eslint ." } }

ちなみに今回は 1,300 ファイル規模ながら完全移行を選びました。主な理由は以下です。

  • 速度メリットが消える: 全件 lint で oxlint 0.35s + eslint 12.96s ≈ 13.31s となり、現状とほぼ変わらず、AI ループ用 hook に乗せる速度を取り戻せない
  • 設定の二重管理: eslint-plugin-oxlint で重複排除すると、独自設定 (no-restricted-globals のカスタム設定など) が無効化されたり、既存の inline // eslint-disable-next-line ... が "Unused eslint-disable directive" として警告化されたりした
  • ロードマップとの整合: 将来 Vite+ に揃えたい以上、併用は足がかりにもならない

「完全移行で破綻するほど大規模か」「速度を hook ループに取り戻したいか」が判断の分岐点で、以降のステップは完全移行ルートの内容です。

2. プラグインの仕分け

既存の ESLint プラグインを、native で代替可能 / JS Plugins で残す / 捨てる の 3 つに分類します。

既存の ESLint plugin 対応方針
typescript-eslint native typescript plugin
react / react-hooks native react plugin (react-hooks 同梱)
jest native jest plugin
unused-imports typescript/no-unused-vars で代替
react-compiler eslint-plugin-react-hooks v6+ に統合されたため native でカバー
testing-library / jest-dom oxlint jsPlugins (Alpha) 経由で src/__tests__/** 限定で残す
storybook 撤退。storybook build と Chromatic 視覚回帰で代替
import (import/order) oxfmt の sortImports に寄せる (後述)

JS Plugins は ESLint プラグインをそのまま読み込めて便利ですが、Alpha かつ Oxlint の Rust native で速いという売りを部分的に削るレイヤなので、メインでは使いませんでした。

テスト系の 2 プラグインだけは「テスト信頼性に直接効くので確実に止めたい」「スコープが __tests__ 配下に閉じている」という条件が揃ったので、限定的に採用しています。

3. 自動変換 + 手調整

flat config からの自動変換は oxlint-migrate が使えます。

npx @oxlint/migrate <optional-eslint-flat-config-path>

生成された JSON に対して、native plugin で対応するルール名 (@typescript-eslint/...typescript/... など) の最終調整と、JS Plugins / overrides の追加を手で行います。最終的に出来上がった構成の骨子は次のような形です。

{
  "$schema": "./node_modules/oxlint/configuration_schema.json",
  "plugins": ["typescript", "oxc", "react", "jest"],
  "jsPlugins": ["eslint-plugin-testing-library", "eslint-plugin-jest-dom"],

  "categories": {
    "correctness": "error",
    "suspicious": "error",
    "pedantic": "off",
    "perf": "off",
    "restriction": "off",
    "style": "off",
    "nursery": "off"
  },

  "rules": {
    // ... プロジェクト固有の制約ルール ...
  },

  "overrides": [
    {
      "files": ["src/__tests__/**/*.{ts,tsx}"],
      "rules": {
        "testing-library/no-node-access": "error",
        "jest-dom/prefer-in-document": "error"
        // ... 他のテスト系ルール ...
      }
    }
  ]
}

plugins キーを書くと Oxlint のデフォルト plugin セットを上書きします。typescript / oxc はデフォルトで有効ですが、明示的に書いておかないと意図せず外れる事故が起きやすいので注意が必要です。

もう一つ押さえておきたいのが categoriesrules の関係です。

categories は Oxlint のルールを 7 種類 (correctness / suspicious / pedantic / perf / restriction / style / nursery) で一括 ON/OFF する仕組みで、今回は correctness (明らかなバグ) と suspicious (疑わしいコード) のみ error、それ以外は off にしています。

優先順位は rules > categories なので、categories.correctness: "error" で一括有効化したうえで、違反が多くて即修正できないルールだけを rules: { "no-extra-boolean-cast": "off" } で個別に逃がす、という使い方ができます。

次の「違反ルールは一時 off」はまさにこの優先順位を使った運用です。

4. 違反ルールは一時 off → follow-up で再有効化

Oxlint デフォルトの correctness カテゴリには、ESLint で off にしていたルールも一部入っています。本 PR で既存コードに手を入れない方針を取ったため、違反が出るルールは一度 off にしておき、別 PR で違反修正 + 再有効化する形にしました。

具体的には no-extra-boolean-cast, no-unneeded-ternary, no-useless-rename, no-useless-catch, jest/require-to-throw-message, react/jsx-key などです。1 PR にまとめると差分が膨大になって reviewer の負担になるので、ルールごとに小さく切り出すと進めやすかったです。

再有効化漏れを防ぐため、一時 off にしたルールはトラッキングチケットに「ルール名 / 違反件数 / 担当 / follow-up PR」のチェックリストで一覧化し、各 follow-up PR から逆リンクを張る運用にしています。

一方で oxlint-migrate の出力には Oxlint で未対応のルールも並びます。今回は @typescript-eslint/no-floating-promises のような type-aware 系 (本移行のスコープ外として別途段階導入する想定) と、storybook/* のように代替手段ありで撤退と判断したものが中心でした。

未対応ルールは .oxlintrc.json に書いても効かないため、設定からは削除しています。

5. CI / pre-commit の切り替え

// package.json (抜粋)
{
  "scripts": {
    "lint:oxlint": "oxlint --deny-warnings src",
    "fix:oxlint": "oxlint --fix --deny-warnings src"
  }
}

Prettier から Oxfmt への移行段階で残していた eslint-config-prettier も含め、ESLint 関連 devDependencies はすべて削除しています。

6. import order は Oxfmt の sortImports に寄せる

eslint-plugin-importimport/order ルールに相当する機能は Oxlint native にはありません。Oxfmt の sortImportscustomGroups / groups を指定すれば、pathGroups 相当のグループ分けが再現できるので、こちらに寄せました。

// .oxfmtrc.json (sortImports 部のみ抜粋)
{
  "sortImports": {
    "customGroups": [
      {
        "groupName": "internal-modules",
        "elementNamePattern": ["<your-dir-1>/**", "<your-dir-2>/**"] 
      }
    ],
    "groups": [
      "builtin",
      "external",
      "internal-modules",
      ["parent", "sibling", "index"],
      "style",
      "unknown"
    ],
    "newlinesBetween": true,
    "order": "asc"
  }
}

これにより lint と format の責務がきれいに分かれ、import の並び替えは Oxfmt の --fix で完結するようになりました。なお sortImports を有効化したコミットは全ファイルに差分が出るので、.git-blame-ignore-revs に登録して git blame の汚染を防いでいます。

移行したことによるメリット

効果を数字でまとめると次の通りです。

項目 移行前 (ESLint / Prettier) 移行後 (Oxlint / Oxfmt) 倍率
全件 lint (約 1,300 ファイル) 12.96s 0.35s 約 37 倍
全件 format チェック 5.2s 0.5s 約 10 倍
単一ファイル lint 1.72s 0.43s 約 4 倍
設定行数 (lint) eslint.config.js 約 220 行 + 11 プラグイン .oxlintrc.json 約 80 行

数字には表れない次のような効果もありました。

  1. 単一ファイル lint が hook に乗る速度に収まり、AI エージェントの編集ループ内で常時走らせられるようになった
  2. Claude Code の PostToolUse hook で確実に止められる品質チェックをかけられるようになり、エージェントが見逃すエラーを一段減らせた
  3. ESLint 10 への移行で必要になる @eslint/compat のような互換レイヤが不要になった
  4. devDependencies が ESLint 本体 + 11 プラグイン分減り、npm install 時間と node_modules サイズが改善
  5. import order を Oxfmt 側に寄せたことで、lint と format の責務分離がクリアになった

移行したことによるデメリット

  1. JS Plugins が Alpha なので、testing-library / jest-dom を残している部分は互換性が壊れるリスクを抱えている
  2. type-aware ルール (no-floating-promises など) は速度トレードオフで今回意識的に lint 層から外している。Alpha 卒業待ちというより、現状の type-aware は 1 ファイルあたり数秒オーダーで hook ループのミリ秒単位の速さに載らないという判断。該当する欠陥は tsc と統合テストでカバーし、lint 層の速度を死守する方針
  3. ESLint / Prettier に比べてコミュニティが小さく、トラブル時の情報量が少ない

さいごに

今回の移行は単なる lint / formatter の乗り換えではなく、AI のフィードバックループそのものを速くするための改善でした。lint を hook ループに載せられるか否かが、AI が書くコードの品質に直接効いてくる時代になっています。

次に視野に入れているのは Type-Aware Linting です。たとえば no-floating-promises は AI が書いたコードで起きがちな await 漏れを検出できるルールで、tsc だけでは取りこぼしやすく本来 lint で止めたい類の問題です。ただし現状の type-aware lint は速度的にまだ重く、hook の高速ループには乗せづらいため、当面は tsc とテストでカバーしつつ常時 hook には組み込まない方針でいく予定です。

AI 時代のフロントエンド開発環境として、これからもフィードバックループ全体の最適化を続けていきたいと思います。

参考文献