🐈

【ESLint & Rubocop】 開発チームの問題を解決したカスタムリントルールのススメ

2024/09/30に公開

はじめに

この記事について

株式会社COUNTERWORKS でソフトウェアエンジニアとして働いているchibaです。

この記事では開発チームが抱えていた問題を解決したリントツールへのカスタムルール導入手順についてまとめました!

対象読者

  • リントツールにカスタムルールを導入したい人
  • 日々の開発者体験を改善させたい人
  • 仕組みで問題を解決したい人

この記事に書かれていること

  • 開発チームが抱えていた問題とその解決までの流れ
  • カスタムリントルルールの導入手順
  • ESLint と Rubocop のカスタムルールの作成手順

開発チームが抱えていた問題について

私が所属している開発チームは ToB 向け SaaS である SHOPCOUNTER Enterprise の開発を行っており、複数の企業様に導入していただいています。

技術スタックは FE に Next.js 、BE に Ruby on Rails を使用しています。

SHOPCOUNTER Enterprise では導入企業様ごとに文言やテキストのカスタマイズを行えるのですが、一部の箇所でカスタマイズしたテキスト(以下カスタマイズテキスト)が反映されていないという問題がありました。

課題:導入企業ごとに変えるべきテキストが反映されていない

例で示すと以下のような状況でした。

  • A社の導入環境では、「カートに追加する」と表示する
  • B社の導入環境では、「買い物かごに追加する」と表示する
  • C社の導入環境では、「購入する」と表示する

本来は以下のように環境変数などを用いてカスタマイズテキストを表示するべき箇所で、カスタマイズされていない固定のテキストなどが表示されてしまう可能性がありました。

-  <p>カートに追加する</p>
-  <p>買い物かごに追加する</p>
-  <p>購入する</p>

+  <p>{env.companyBuyText}</p>

原因(なぜ上記のような問題が起きていたか)

  • レビューでの見逃し、指摘漏れ
  • CI が通れば実装に大きな問題はないという意識があった
  • 新規開発参画者が増えたことによるコード実装ルールの周知不足
  • ステージ環境での確認時にカスタマイズテキストが反映されているか気づけない設定

レビューでの指摘、コーディングルールの周知、テストの強化などで対策を行いましたが、同様の問題が再発してしまう状況が続いていました。
我々の開発チームでは根本解決のため 「リントツールによる解決」 を行いました。

そもそもリントツールとは?

リントツールとは、コードの品質を保つためのツールです。静的解析を行い、コードの品質を向上させるためのツールです。リントツールには ESLint(JS,TS)、Rubocop(Ruby) などがあります。近年の開発では導入が一般的であり、複数人での開発ではリントツールを導入していることが多いで
下記の記事がリントツールについて詳しく書かれていますので、参考にしてください。

https://typescriptbook.jp/tutorials/ESLint

これらのリントツールには標準でルールが設定されており、それに従ってコードを書くことでコードの品質を保つことができます。加えて特定のルールに関しては自動修正機能を持っているため、コマンド1つで簡単にコードの修正を行うことができます。
また、カスタムルールを導入することで、自チームの開発ルールに合わせたオリジナルルールを設定することができます。

他の解決策候補
  • レビューのチェック項目にカスタマイズテキストが反映されているかの確認項目を追加する
  • カスタマイズテキストに関するテストコードを記述する
  • ステージ環境でカスタマイズテキストを分かりやすい言葉に変更して、正しく反映されているかの確認を行う

前提

  • CI/CD 環境が整っている
  • テストコードが書かれている
  • リントツールが導入されている
  • レビュー文化が根付いている

カスタムリントルールの導入手順

カスタムリントルールの導入手順は以下の通りです。

  1. 該当コードの洗い出し
  2. 構文解析
  3. 該当ノードに関するカスタムルールの作成
  4. 検証

ESLint

まずは、ESLint のカスタムルールを導入する手順を説明します。

1.構文解析

まずは検出したいテキストを羅列し、対応する AST(抽象構文木) を確認します。
AST はコードを解析し木構造に変換したものです。
(Lint ツールは AST を解析してエラーを検出するため、どのようなノードに対してルールを適用するかを事前に確認する必要があります。)

const text = 'テキスト'
{'テキスト'}
<Form label="テキスト" />
<>テキスト</>
<Form>テキスト</Form>

AST explorer というサイトで構文木を確認することができます。
https://astexplorer.net/
AST Exproler の設定JavaScript, typescript-eslint/parser を選択し、コードを入力することで対応する AST を確認することができます。
上記の例では以下のノードとして定義されていることが分かります。

const text = 'テキスト'

literal

ExpressionStatement の中に Literal ノードがあり文字列が含まれていることが分かります。


<Form label= 'テキスト'>

JSXOpeningElement
JSXOpeningElement の中にも同様に Literal ノードがあることが分かります。

同様の手順で検出したいコードに該当するノードを洗い出したところ LiteralJSXText の2種類のノードを検出することで、コード内のテキストを全て検出することができると判明しました。

次にこれらのノードを検出するためのカスタムルールを作成します。

2.導入手順

はじめにカスタムルールを作る際に必要なライブラリを yarn addで追加します。

yarn add eslint-plugin-local-rules typescript-eslint/utils

またeslint-local-rules.jsをsrcディレクトリに作成し、以下のように記述します。

eslint-local-rules.js
module.exports = {
  'bearer-text': require('./eslint-local-rules/rules/bearer-text').rule,
}

eslintrc.js に下記のような記述を追加します。

eslintrc.js

plugins: [
  'local-rules'
]

rules: [
  // ...
  'local-rules/bearer-text': ERROR,
]

overrides: [
  {
    files: ['src/mocks/*.ts', '**/msw.ts'],
    rules: {
      'local-rules/bearer-text': off,
    },
  },
]

読み込みたいルールを plugins と、 rules に追加します。
(overrides を使用することで、特定のファイルに対してルールを無効にすることができます。)

3.カスタムルール の作成

src/rules/bearer-text.ts
import { ESLintUtils } from '@typescript-eslint/utils';
import { TSESTree } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(() => '');
const hardCodingTexts = ['apple', 'Apple'];

export const rule = createRule({
  create(context) {
    const checkHardCodingText = (str: string, node: TSESTree.Node) => {
      if (hardCodingTexts.some((text) => str.includes(text))) {
        context.report({
          node,
          messageId: 'bearer-text',
        });
      }
    };

    return {
      /*
          const text = 'テキスト'
          {'テキスト'}
          <Form label="テキスト" />
         を検知する
      */
      Literal(node) {
        if (node.value && typeof node.value === 'string') {
          checkHardCodingText(node.value, node);
        }
      },
      /*
        <>テキスト</>
        <Form>テキスト</Form>
        <p>テキスト</p>
       を検知する
      */
      JSXText(node) {
        if (node.value && typeof node.value === 'string') {
          checkHardCodingText(node.value, node);
        }
      },
    };
  },
  meta: {
    type: 'problem',
    docs: {
      description: '「apple,Apple」は環境変数を参照してください。',
    },
    messages: {
      'bearer-text': '「apple,Apple」は環境変数を参照してください。',
    },
    schema: [],
  },
  name: 'bearer-text',
  defaultOptions: [],
});

RuleCreatorを使用してカスタムルールを作成し、checkHardCodingText はテキストを検出する関数として定義しています。
return内で LiteralとJSXText ノードを検出、また context.reportを呼ぶことで、エラーを ESLint に伝えることができmeta内に記述したエラーメッセージが表示されます。

Lint の実行時に使用されるファイルは jsファイルであるため、上記で作成したtsファイルをjsファイルに変換した後に ESLint を実行する必要があります。

$ yarn tsc ファイル名

4.ESLint の実行

ESLint を実行すると、下記のようなエラーが検出されます。

src/sample.jsx
<Label name='apple'>
<p>Apple</p>
$ eslint src

227:19  error  「apple,Apple」は環境変数を参照してください。  local-rules/bearer-text
277:50  error  「apple,Apple」は環境変数を参照してください。  local-rules/bearer-text

実際にエラーが検出できています。

万が一開発中に ESLint を実行し忘れた場合でも CI でエラーが検出されるため、コードの品質を保つことができます。

Rubocop

続けて Rubocop のカスタムルールの導入手順について説明します。
ESLint と同様の流れで検出したいテキストを洗い出し、構文解析を行い、カスタムルールを作成します。

1.検出したいテキストの羅列

"Apple"
Model.find_by(name: "Apple")
arr=['Apple']

静的解析ではどのような AST が生成されるかを確認します。
Ruby は TypeScript と異なり、AST の文字列ノードは全て str というノードで表現されています。

自分の目で確認したいという方は、rails console を起動し、下記のコードを実行してみてください。

file = File.open('sample.rb') # sample.rb は適当に作成してください
source = Rubocop::ProcessedSource.new(file.read, RUBY_VERSION.to_f)
pp source.ast

今回のケースでは str というノードを指定することで全ての文字列を検出できました。

2.導入手順

rubocop.yml に カスタムCop の読み込みと除外するファイルを指定します。

rubocop.yml
require:
  - ./rubocop/custom_cops/bearer_text.rb

CustomCops/BearerText:
  Enabled: true
  Exclude:
    - '除外したいファイルパス'

require でカスタムCop を読み込んでいます。
またテストファイルなど、検出したくないファイルがある場合は、Exclude で除外することができます。

3.カスタムCop の作成

rubocop/custom_cops/bearer_text.rb

module Rubocop
  module CustomCops
    class BearerText < Rubocop::Cop::Base
      def on_str(node)
        return if in_comment?(node)

        return unless contains_bearer_text?(node.source)

        add_offense(
          node,
          message: '「apple,Apple」は直接記述せずに、環境変数を参照してください。'
        )
      end

      private

      def contains_bearer_text?(str)
        bearer_text = %w[apple Apple]
        bearer_text.any? { |text| str.include?(text) }
      end

      def in_comment?(node)
        processed_source.comments.any? do |comment|
          comment.loc.expression.contains?(node.loc.expression)
        end
      end
    end
  end
end

カスタムCop 内で検出するノードは on_xxx というように記述します。
今回は on_str 内で条件に一致する場合のみ add_offense を呼び出して Rubocop に対してエラーを通知します。
また in_comment? 関数でコメント内に含まれているテキストは検出しないように設定しています。

4.実行

rails restartを実行後に Rubocop を実行してエラーが検出されるかを確認します。

$ bundle exec rubocop

app/models/apple.rb:63:21: C: CustomCops/BearerText: '「apple,Apple」は直接記述せずに、環境変数を参照してください。

実際にエラーが検出できています!

おわりに

行わなかったこと

まずカスタムルールに関するテストコードは今回記述しませんでした。
理由は下記2つです。

  • 単純なルールであるため、テストコードを書くほどではない
  • 頻繁に変更を加える箇所ではないためコストパフォーマンスが悪い

また自動修正機能についても今回は実装を見送りました。

実際に導入して運用した結果

レビューでカスタマイズテキスト漏れに対する指摘が無くなり、設計に関してのレビュー に集中できるようになりました。
更に本番環境でのカスタムテキスト問題が導入以降 一度も 発生していません。

今後の課題

一方でカスタムルールは下記のような課題があります。

  • メンテナンスを行える人が限られていること
  • 別ライブラリへの乗り換えを行う際にはカスタムルールの移行が必要になること

実際に Biome へのリントツールの乗り換えを直近で検討しましたが、Biome にはカスタムルールが現時点で存在しないため、引き続き ESLint を使用することになりました。

おわりに

今回の記事では、開発チームが抱えていた問題を解決するためにリントツールにカスタムルールを導入した経緯と、実際の流れをまとめました。

この記事がカスタムルールを導入したい方の参考になれば幸いです!

最後に宣伝です!

株式会社COUNTERWORKS では共に事業を前進させるメンバーを募集しています。
興味のある方はぜひ以下のリンクからご応募ください!

https://counterworks.co.jp/recruit/?utm_source=zenn&utm_medium=referral

参考文献

https://zenn.dev/paiza/articles/create-typescript-eslint-custom-rule
https://eslint.org/docs/latest/extend/custom-rules
https://moneyforward-dev.jp/entry/2021/09/02/rubocop/
https://developers.bookwalker.jp/entry/2023/03/31/174906

COUNTERWORKS テックブログ

Discussion