画像生成の SaaS で、.prettierrc と eslint.config.mjs、それに package.json の依存七つを同じコミットで消しました。ロックファイルは 545 行減りました。翌日、別のプロジェクトで同じことをして、さらに別のプロジェクトでもやりました。五回目にはもう何も検討していません。リポジトリを開いて、ESLint と Prettier を外して、biome.json を書く。それだけです。
これはもう好みではありません。判断です。だとすれば、擁護できるようにしておくべきです。
最初に背中を押したもの
速度ではありませんでした。依存ツリーです。取り除いた設定はこれです。
const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];
flat config に古い設定を理解させるための FlatCompat。Prettier とぶつかる ESLint のルールを黙らせることだけが仕事の eslint-config-prettier。似たようなことを別々の側からやる eslint-plugin-tailwindcss と prettier-plugin-tailwindcss。存在理由が「他のツール同士が踏み合わないこと」であるパッケージが六つ。
Biome はフォーマットと lint を同じバイナリでやります。調整すべきツールが二つある状態がなくなるので、この問題ごと消えます。
壊れるのに誰も教えてくれないこと
移行はファイルを消して先に進むことではありません。直後に来るフォーマットの一斉適用です。78 ファイルが一コミットで書き換わり、中身はクォートとインデントばかりなのでレビューのしようがありません。やって、受け入れるしかありません。
本当の問題は、linter を本気で有効にしたときに来ます。Telegram のリマインダー bot では、推奨ルールでの一回目のパスがウェブ側で 60 件のエラーを出しました。ノイズはほとんどありません。
- 紐付いていない label。 ログインのフィールドに
htmlFor/idがない。Next の設定を使った ESLint は一度も教えてくれませんでした。 - 装飾用の SVG。 このルールは
aria-hiddenの省略形を認識しないので、aria-hidden="true"と明示する必要があります。 - 名前の衝突。 import した
TaskListという型が、同名のコンポーネントとぶつかっていました。問題なくコンパイルできていて、しかも間違っていました。 - インデックスを key に使う。 回転するテキストのコンポーネントで、各文字の key が
mapのインデックスでした。解決策はルールを黙らせることではなく、memo の中で絶対オフセットを先に計算して安定した key を持たせることでした。
面白いのはそこです。60 件のうち大半は、何か月も前からそこにあった本物のバグでした。残りは理由を書いた抑制で片付きました。一度きりしか走らない効果、サイズが固定のスケルトンのループ、react-remove-scroll-bar を上書きする !important、OAuth のアバターの <img>。
コメントのない biome-ignore は負債です。コメントがあれば判断です。
速度が効くのは、lint をどこに置けるかで決まる
比較記事でいちばん繰り返され、いちばん説明が下手な点です。数字自体はどうでもいい。短時間で終わるなら、コマンドをどこに差し込めるかが効きます。
ESLint のときは lint は CI にありました。ローカルのビルドに入れるのは待ち時間の刑だからです。Biome にしたら、スクリプトはこうなりました。
"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."
ビルドの前に lint。赤ければコンパイルしません。この一行が違いのすべてです。エラーが十五分後にランナーで見つかるのをやめて、ビルドが始まる前に見つかるようになります。遅いツールではこの一行は耐えられず、結局は外すことになります。
失うもの
二つあります。名前を挙げておく価値があります。
ひとつめはプラグインのエコシステムです。特定の ESLint プラグインに依存しているなら、それは存在しないし、おそらくこれからも存在しません。eslint-plugin-tailwindcss を外したときに確かめました。クラスの並び順は linter ではなく Tailwind のフォーマッタが解決するので、なくても困りませんでした。ただしこれは運が良かった例であって、一般則ではありません。
ふたつめはもっと間抜けで、もっと面倒です。デフォルト設定がどこでも同じとは限りません。あるプロジェクトは "indentStyle": "tab" で出てきて、別のプロジェクトは "indentStyle": "space" と "indentWidth": 2 でした。数週間後、biome.json のタブを空白に変えるだけのコミットを打つ羽目になりました。全部を揃えるためです。$schema を正確なバージョンで固定して、同じ biome.json をプロジェクト間でコピーしてください。このツールの快適さは設定がほとんどないことにあります。それを活かして、設定はひとつだけ持ちましょう。
いまの品質まわりのスタック
Biome、strict モードの TypeScript、テスト、そして死んだコード用の knip。ほかにはほとんどありません。
knip はいちばん使われていないのに、いちばん驚かせてくれる道具です。Telegram の bot で一回走らせたら、死んだファイルが 19 個と使われていない依存が 21 個消えました。メタパッケージと重複していた @radix-ui/* のサブパッケージ、@react-three/fiber と three の丸ごと、date-fns、そして外した数か月後もインストールされたままだった eslint。もう誰も呼んでいないアクションとユースケースのかたまりも丸ごと出てきます。
ただし knip は意図的に CI から外しています。大きなリポジトリでは初回のパスで既存の指摘が 107 件出ました。ゲートに繋げば初日から赤になり、結局その赤を無視するようになります。手動のツールとして数か月に一度、刈り取りは専用のコミットにして、マージ前に tsc とテストで検証します。
entrypoint の設定は明示的でなければなりません。でないとアプリの半分を消されます。Next を使ったモノレポでは、どのファイルがエントリーポイントかを手で宣言する必要があります。
"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]
shadcn が生成したコンポーネントは ignore に入れます。使う数より常に多いのが普通だからです。sharp と tailwindcss は誰も import せずビルドから使われるので ignoreDependencies に入れます。
遅い linter は、誰も見ない別ジョブに追いやられる linter です。速い linter はビルドに入り、プロジェクトの床になります。
