既存プロジェクトへ段階的にLinter(eslint)を導入する方法を考える

2021.10.12

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

吉川@広島です。

既存のTS/JSプロジェクトにLinterが当たっていない場合、そこからeslintを導入していくのはかなり大変であり、どう実現していくか悩ましい所です。

こういったシチュエーションに何度か遭遇している自分が、既存プロジェクトにeslintを導入する方法は何が良いか、以下のようなことを考えました。

  • 全ファイルに対して eslint --fix してPRを出すやり方はしない
    • 差分が多すぎて怖い
    • 他ブランチとのコンフリクトも多くなりやすい
  • 段階的・漸進的・徐々に導入できるようなやり方が望ましい
  • ルールをすべてOFFにする→一つずつONにしていく方法もあまりやりたくない
    • ルール単位だと、一つだけONにしても複数のファイルで警告が発生することが多いので、差分が多く怖いという問題が残る
    • 「機能開発などで変更したファイルに対してついでにeslint対応もする」といったボーイスカウトルールで進めようとしても、↑の理由で「今触ってないファイルにも警告が発生する」ことが頻発するのでやりづらい
    • 新規作成したファイルに対してはルールOFFにしたくない
  • ファイル単位+今違反しているルールのみ無効化する
    • ファイルごとに無効化を外せるので、ボーイスカウトルール的に改善していくことができる
    • 最初に一括で全ファイルの先頭にeslint無効化コメントを追加することになるが、PRの差分が多くても内容がコメントのみであれば安心してマージしやすい
    • 新規作成ファイルにはすべてのルールが適用される

というわけで「ファイル単位+今違反しているルールのみ無効化する」を実現するスクリプトを書いてみました。

環境

  • node 14.16.1
  • typescript 4.4.3
  • eslint 8.0.0

依存パッケージのインストール

npm i -S eslint lodash.uniq
npm i -D @types/eslint @types/lodash.uniq

TSスクリプト

// index.ts

import { ESLint } from 'eslint'
import uniq from 'lodash.uniq'
import fs from 'fs'

const main = async () => {
  // eslintのターゲットファイルパス
  const filePathPatterns = process.env.FILE_PATH_PATTERNS!
  // .eslintrcのパス
  const configPath = process.env.CONFIG_PATH!

  const esLint = new ESLint({
    fix: false, // `eslint --fix` はしない
  })

  // .eslintrcを読み込む
  esLint.calculateConfigForFile(configPath)

  // Lintする
  const result = await esLint.lintFiles(filePathPatterns.split(','))
  // Lint結果を出力
  fs.writeFileSync('result.json', JSON.stringify(result, null, 2))

  for (const item of result) {
    if (item.messages.length === 0) {
      // Lintエラーがなければ何もしない
      continue
    }

    // Lintエラーが1つ以上ある場合

    // ruleIdのユニークな配列を取得する
    // 各ファイルでコメントの順序が統一されていた方が見栄えがいいので、ソートする
    const ruleIds = [
      ...uniq(
        item.messages
          .map(({ ruleId }) => ruleId)
          .filter((v) => v != null) as string[]
      ),
    ].sort()

    // 先頭に挿入するeslint無効化コメントの文字列
    const eslintDisableComments =
      ruleIds.map((ruleId) => `/* eslint ${ruleId}: 0 */`).join('\n') + '\n\n'

    // 既存のファイル文字列を取得
    const fileContent = fs.readFileSync(item.filePath).toString()

    // 既存のファイル文字列の先頭にeslint無効化コメントを追加して上書きする
    const newFileContent = eslintDisableComments + fileContent
    fs.writeFileSync(item.filePath, newFileContent)
  }
}

main()

eslintのLint結果は以下のような形式で返ってきます。

[
  {
    "filePath": "/path/to/foo.ts",
    "messages": [],
    "errorCount": 0,
    "fatalErrorCount": 0,
    "warningCount": 0,
    "fixableErrorCount": 0,
    "fixableWarningCount": 0,
    "usedDeprecatedRules": []
  },
  {
    "filePath": "/path/to/bar.ts",
    "messages": [],
    "errorCount": 0,
    "fatalErrorCount": 0,
    "warningCount": 0,
    "fixableErrorCount": 0,
    "fixableWarningCount": 0,
    "usedDeprecatedRules": []
  },
  {
    "filePath": "/path/to/baz.ts",
    "messages": [
      {
        "ruleId": "no-unused-vars",
        "severity": 2,
        "message": "'MyClass' is defined but never used.",
        "line": 3,
        "column": 10,
        "nodeType": "Identifier",
        "messageId": "unusedVar",
        "endLine": 3,
        "endColumn": 22
      },
      {
        "ruleId": "no-unused-vars",
        "severity": 2,
        "message": "'MyClass' is defined but never used.",
        "line": 5,
        "column": 10,
        "nodeType": "Identifier",
        "messageId": "unusedVar",
        "endLine": 5,
        "endColumn": 14
      },
      {
        "ruleId": "eqeqeq",
        "severity": 2,
        "message": "Expected '===' and instead saw '=='.",
        "line": 138,
        "column": 55,
        "nodeType": "BinaryExpression",
        "messageId": "unexpected",
        "endLine": 138,
        "endColumn": 57
      },
    ],
    "errorCount": 3,
    "fatalErrorCount": 0,
    "warningCount": 0,
    "fixableErrorCount": 3,
    "fixableWarningCount": 0,
    "source": "...",
    "usedDeprecatedRules": []
  },
]

この例でいうと、foo.tsとbar.tsのmessagesは空配列のため、Lintエラーなしです。

一方、baz.tsはmessagesの配列に3つ要素があるのでエラー有りです。そして、そのうちno-unused-vars違反が2つ発生しています。このように同じルールの違反が複数箇所あるとダブりが出てくるのですが、先頭の無効化コメントで欲しいのは一つずつなので、lodash.uniqを使ってダブりを除去しています。

実行方法

下記のように実行します。

FILE_PATH_PATTERNS=/path/to/**/*.ts \
CONFIG_PATH=/path/to/.eslintrc.yml \
npx ts-node index.ts
  • FILE_PATH_PATTERNS: eslintのターゲットとなるファイルのパスパターン
  • CONFIG_PATH: .eslintrcのパス

をそれぞれ環境変数に埋めて実行します。

結果、次のように「そのファイルで違反しているeslintルールを無効化するコメント」が追加されます。

/* eslint eqeqeq: 0 */
/* eslint no-unused-vars: 0 */

import { MyClass } from './path/to/my-class'

// 以下省略...

このスクリプトによって作成されたPRのChange Filesは非常に多くはなりますが、内容の全てが「コメント追加のみ」なので、マージの心理障壁はかなり低くできるのではないでしょうか?

参考