😇

コミット時にtypescript-eslintを使ったlintを高速化する

2024/09/11に公開

コミット時のlintが遅い問題

うちのチームではコミット時にlint-stagedを使ってeslintを使ったlintを実行していますが、コミットに20~30秒程度の時間がかかる課題がありました。
この問題の解決策としてすぐに考えつくのは以下の二つです。
1.そもそもコミット時のlintをやめてエディタの設定でon save時にlintを実行する
2.biomeやoxlintといった高速なlinterを利用する

エディタの設定でon save時にlintを実行する戦略は割とワークするやり方ではありますが、チーム内で使っているエディタが統一されていないことなどから導入には至りませんでした。
biomeやoxlintといった高速なlinterを利用する戦略については、型情報を利用するルールは今のところ対応しておらず、完全な移行ができません。isolatedDeclarationsの登場によって将来的には解決される可能性が高いですが、コードベースに大幅な変更が必要なこととlinter側の対応を待つ必要がある点から当面はtypescript-eslintと併用する必要があるでしょう。

tsconfigを動的に生成してtypescript-eslintを高速化する

eslintは実行時に環境変数としてTIMING=1を渡すことで各lintルールの実行にかかった時間を計測することができます。

TIMING=1 npx eslint --ext=.ts,.tsx .

lintの完了時に以下のようなログが出力されます。

Rule                                        | Time (ms) | Relative
:-------------------------------------------|----------:|--------:
@typescript-eslint/no-misused-promises      |  2805.101 |    26.7%
import/no-duplicates                        |  1730.171 |    16.5%
@typescript-eslint/no-floating-promises     |  1149.343 |    11.0%
import/default                              |   353.234 |     3.4%
@nicolive/react/initialize-state            |   253.013 |     2.4%
react/no-direct-mutation-state              |   251.371 |     2.4%
unicorn/no-thenable                         |   199.874 |     1.9%
@typescript-eslint/no-unnecessary-qualifier |   194.347 |     1.9%
unicorn/prefer-array-flat                   |   180.724 |     1.7%
import/order                                |   164.549 |     1.6%

各lintルールの実行は早いとは言い難いですが、前述の通りコミット時のlintに20~30秒かかっている点から考えるとそこまで支配的ではありません。
ではどこに時間がかかっているかというと、型情報を必要とするlintルールのために内部的にtscを使って型情報を生成している部分です。
typescript-eslintはeslintrcに記載されたparserOptions.projectに指定されたtsconfigを使って型情報を生成します。

"parserOptions": {
  "project": "tsconfig.json"
}

一般的にtsconfig.jsonのincludesにはプロジェクト全体のコードが指定されているため、typescript-eslintは全体の型情報を生成してしまいます。
リポジトリ全体をlint対象としたい場合には問題ありませんが、コミット時のlintにおいては変更されたファイルのみをlint対象とすれば良いため、型情報も変更されたファイルに限って生成すれば良いはずです。変更されたファイルのみの型情報を生成するにはtsconfig.jsonのincludesを動的に変更する必要があるので、以下のスクリプトを使用します。

#!/usr/bin/env node
import { writeFile } from "node:fs/promises";
import { parseArgs } from "node:util";

const {
  values: { baseTsConfig, outputPath },
  positionals,
} = parseArgs({
  args: process.argv.slice(2),
  options: {
    baseTsConfig: {
      type: "string",
      default: "./tsconfig.json",
    },
    outputPath: {
      type: "string",
      default: "./tsconfig.eslint.tmp.json",
    },
  },
  tokens: true,
  strict: false,
});

const tsConfig = {
  extends: baseTsConfig,
  include: positionals.filter(file => file.endsWith(".ts") || file.endsWith(".tsx")),
};

await writeFile(outputPath, JSON.stringify(tsConfig, null, 2));

このスクリプト(extract.js)をlint-staged.jsonに追加してtsconfig.eslint.tmp.jsonを動的に生成し、そのtsconfigを使ってeslintを実行します。

{
  "*.{ts,tsx}": [
    "node extract.js",
    "eslint --fix --parser-options project:./tsconfig.eslint.tmp.json"
  ]
}

これによってコミット時にかかる時間は10秒未満に削減することができました。

まとめ

10秒未満になったとはいえ決して短い時間ではないので、やはりできるのであればon save時にlintを実行する形を目指すのが理想的と言えそうです。しかしさまざまな事情でそのような運用が難しい場合は今回紹介した方法を採用することを検討する価値はあると思われます。
isolatedDeclarationsを利用したlinterの進化に期待しつつ当面は今回紹介した方法で運用を続けていく予定です。

Discussion