🛠️

フロントエンドのLinterやCIを改善した話

2023/12/21に公開

この記事は 株式会社エス・エム・エス Advent Calendar 2023 の21日目の記事です。

介護事業者向けの経営支援サービス「カイポケ」のリニューアルプロジェクトでフロントエンド開発をしている @hush_in です。

今年の4月にエス・エム・エスに入社しました。
入社してからフロントエンドのLinterやCIを改善した話をします。

忙しい人向けまとめ

  • ESLint の recommended 系 extends を追加
    • 全般
      • eslint:recommended
      • plugin:import/recommended
    • TypeScript
      • plugin:@typescript-eslint/recommended-type-checked
      • plugin:@typescript-eslint/stylistic-type-checked
      • plugin:import/typescript
      • plugin:jest/recommended
      • plugin:jest/style
      • plugin:testing-library/react
      • plugin:jest-dom/recommended
    • GraphQL
      • plugin:@graphql-eslint/schema-recommended
  • GhatGPTを活用して lint error を自動修正
  • コーディングガイドラインのESLint ルール化
    • eslint-plugin-check-file でファイル名、ディレクトリ名のルール追加
    • no-restricted-syntax で 独自のルール作成
  • CI
    • テストファイルやStorybookも型チェックするように設定追加
    • Jest, ESLint, Prettier, Next.js build のキャッシュ導入

各設定の解説

はじめに

プロジェクトのフロントエンドの技術スタックは主に Next.js, TypeScript, Apollo Client, Storybook, Jest を使用しています。 詳しくは 大規模SaaS 「カイポケ」の未来を支えるフロントエンドの技術選定 をご覧ください。

入社した時点ではESLintの設定は下記のようにそこまで多くのルールは設定されていませんでした。

{
  "extends": [
    "plugin:storybook/recommended",
    "next/core-web-vitals",
    "plugin:@typescript-eslint/recommended",
    "prettier"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": ["import", "unused-imports", "@typescript-eslint"],
  "rules": {
    // ...
  }
}

カイポケのリニューアルプロジェクトは長期間続き、開発者も増える想定です。
そこで下記目的でESLint設定を追加していきました。

  • 単純な実装ミス、バグを未然に防ぎたい
  • コーディングスタイルを統一して書き方のぶれが少なくしたい

設定した順に説明していきます。後で現在の設定を載せます。

基本的な流れ

  1. extend や rule を追加
  2. 自動修正できる箇所をコミット
  3. rule毎エラーになる箇所を修正、もしくは除外したいところを // eslint-disable-next-line コメントを追加してコミット
  4. 修正箇所が多い場合はPRを分ける
    • 一旦ruleを無効化しておいて、順次有効化して修正するPRを出す

eslint:recommended

eslint:recommended を追加しました。意外にも next/core-web-vitals には入ってないので別途入れる必要があります。

import 系

次に plugin:import/recommended, plugin:import/typescript を追加しました。
import の自動整形が便利です。

- import { MouseEvent, SyntheticEvent } from 'react';
- import { KeyboardEvent } from 'react';
+ import { MouseEvent, SyntheticEvent, KeyboardEvent } from 'react';

ただし、実行時間がかかるようになりました。
TIMING=1 npm run lint で時間を計測すると import/namespace がかなりの割合を締めていました。(2023年7月時点)

Rule                              | Time (ms) | Relative
:---------------------------------|----------:|--------:
import/namespace                  | 14879.360 |    87.8%

next/core-web-vitals 内で設定しているparser 周りのオプションが悪そうだったのでそれを使わないように設定したところ、改善しました。

Rule                              | Time (ms) | Relative
:---------------------------------|----------:|--------:
import/namespace                  |  4098.119 |    65.2%

※現時点(2023年12月)で import/namespace の時間を調べたところ、 next/core-web-vitals を使って 9.5秒、使わずに対策しても 6.1秒とそこまで差がでませんでした。

typescript-eslint

ちょうど typescript-eslint v6 が出たタイミングで設定しました。

まずは plugin:@typescript-eslint/recommended-type-checked を追加しました。
型をより厳しくできます。

エラーが100箇所程出るルールがあり、grepや手作業で修正するのが大変だったのでChatGPTにスクリプトを書かせて機械的に置換しました。ChatGPTで yak shaving の時間を短縮できたので良かったです。

no-floating-promises の修正例:
個別の実装で await をつけるべきかは判断できなかったので、現状の挙動と同じになるように void をつけました。

ChatGPTへの依頼文

ESLintで @typescript-eslint/no-floating-promises で警告が出る箇所に一括でvoid operator をつけたい。

ESlint のログは 下記のように出ます。
このようなログからファイル名、行数、列数をパースし、void operator を挿入するスクリプトをperlで書いてください。

path/hoge/fuga.tsx:80:7: Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator. [Error/@typescript-eslint/no-floating-promises]
path/hoge/piyo.tsx:23:7: Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator. [Error/@typescript-eslint/no-floating-promises]

最終的にできたScript

#!/usr/bin/perl
use strict;
use warnings;
use Tie::File;

my $log = './path/to/eslint_log.txt';

open my $fh, '<', $log or die "Can't open file $!";
my @errors = <$fh>;
close $fh;

for my $error (@errors) {
  if ($error =~ m!(.*):(\d+):(\d+):!) {
    my ($filename, $line, $col) = ($1, $2, $3);

    tie my @lines, 'Tie::File', $filename or die "Can't open file $!";
    my $original_line = $lines[$line - 1];
    $lines[$line - 1] = substr($original_line, 0, $col - 1) . "void " . substr($original_line, $col - 1);
    untie @lines;
  }
}

AST操作したほうが確実ですが、これくらいの簡単な修正なら機械的な置換でできました。

次に plugin:@typescript-eslint/stylistic-type-checked を追加しました。
自動修正でスタイル統一の各種ルールが導入ができます。

テスト系

以下のextends を追加しました。特に良かったルールを箇条書きで載せます。

  • plugin:jest/recommended
  • plugin:jest/style
  • plugin:testing-library/react
  • plugin:jest-dom/recommended

Lintエラーを直す最中に、本当はテストが失敗するけど成功してしまうものを発見し、修正することができました。

graphql-eslint

plugin:@graphql-eslint/schema-recommended を追加しました。
命名規則が厳しくなったり @deprecated のschema に気づけたりします。

その他

eslint-config-airbnb-base を参考に良さそうなルールを追加しました。
eslint-config-airbnb-base 自体は2年間更新がないので入れるのをやめました。

eslint-config-standard-with-typescript も導入しようかと思いましたが、依存するplugin のバージョンが古かったのでやめました。

コーディングガイドラインのESLint ルール化

eslint-plugin-check-file でコンポーネントのファイル名はPascalCase、 ディレクトリ名は lowerCamelCase になるようにしました。

no-restricted-syntax で「コンポーネントでchildren を受け取る場合は PropsWithChildren を利用する」という独自ルールを設定しました。
TypeScript ASTは書き慣れていなかったのですが、ChatGPTで草案を作って https://astexplorer.net/ (parser は @typescript-eslint/parser)で確認して微調整してできました。

{
  files: ['*.ts', '*.tsx'],
  rules: {
    'no-restricted-syntax': [
      'error',
      {
        selector:
          "TSTypeLiteral > TSPropertySignature[key.name='children'][typeAnnotation.typeAnnotation.typeName.name='ReactNode']",
        message: 'Use PropsWithChildren instead of manually typing children.',
      },
    ],
  },
},

CI改善

型チェック

Next.js はビルド時にTypeScriptの型チェックできますが、範囲はビルド対象のファイルのみです。
テストファイルやStorybookも型チェックしたいので、
package.json の scripts に下記コマンドを追加し、CIに含めました。

"typecheck": "tsc --noEmit && echo Done."

キャッシュ導入

下記コマンドのGitHub Actions キャッシュを設定し、実行時間を短縮しました。

現在の設定

`.eslintrc.js`
/** @type {import('eslint').ESLint.ConfigData} */
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:import/recommended',
    'plugin:storybook/recommended',
    // next/core-web-vitals を設定すると import/* ルールの実行に時間がかかるので、
    // 内部で使っている設定を使用 https://github.com/vercel/next.js/tree/canary/packages/eslint-config-next
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    'plugin:@next/next/core-web-vitals',
    'prettier' /* prettierはextendsの最後に指定する https://github.com/prettier/eslint-config-prettier */,
  ],
  plugins: ['unused-imports', 'check-file'],
  env: {
    browser: true,
    node: true,
  },
  settings: {
    react: {
      version: 'detect',
    },
  },
  rules: {
    // next/core-web-vitals の設定を一部copy
    'react/react-in-jsx-scope': 'off',
    'react/prop-types': 'off',
    'import/order': [
      'error',
      {
        'newlines-between': 'always',
        alphabetize: {
          order: 'asc',
          caseInsensitive: true,
        },
      },
    ],
    'unused-imports/no-unused-imports': 'error',
    '@next/next/no-img-element': 'off',
    // import 時に名前を強制するため
    'import/no-default-export': 'error',
    // tsserver に任せるので off
    'import/no-unresolved': 'off',
    // Component の記法統一のため
    'react/self-closing-comp': [
      'error',
      {
        component: true,
      },
    ],
    'check-file/filename-naming-convention': [
      'error',
      {
        'src/{lib,services}/**/*.tsx': 'PASCAL_CASE', // Component のファイル名はパスカルケース
      },
      {
        ignoreMiddleExtensions: true,
      },
    ],
    'check-file/folder-naming-convention': [
      'error',
      {
        // ディレクトリ名は LowerCamelCase にする。
        // Next.js 由来の square brackets を許可するため、custom patterns (glob syntax) で記述
        // https://github.com/DukeLuo/eslint-plugin-check-file/blob/main/docs/rules/folder-naming-convention.md#built-in-custom-patterns
        'src/{lib,services}/**/': '?(\\[)[a-z]*',
      },
    ],
    // airbnb ルールを参考に設定
    // https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/best-practices.js
    'array-callback-return': ['error', { allowImplicit: true }],
    'no-else-return': ['error', { allowElseIf: false }],
    'no-throw-literal': 'error',
    eqeqeq: ['error', 'always', { null: 'ignore' }],
    radix: 'error',
    'prefer-regex-literals': [
      'error',
      {
        disallowRedundantWrapping: true,
      },
    ],
    // https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/es6.js
    'prefer-destructuring': [
      'error',
      {
        VariableDeclarator: {
          array: false,
          object: true,
        },
        AssignmentExpression: {
          array: true,
          object: false,
        },
      },
      {
        enforceForRenamedProperties: false,
      },
    ],
    'object-shorthand': [
      'error',
      'always',
      {
        ignoreConstructors: false,
        avoidQuotes: true,
      },
    ],
    'prefer-template': 'error',
    // https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/style.js
    'no-unneeded-ternary': ['error', { defaultAssignment: false }],
    // https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/imports.js
    'import/no-useless-path-segments': ['error', { commonjs: true }],
    // https://github.com/airbnb/javascript/blob/master/packages/eslint-config-airbnb-base/rules/errors.js
    'no-promise-executor-return': 'error',
  },
  overrides: [
    {
      files: ['*.ts', '*.tsx'],
      extends: [
        'plugin:@typescript-eslint/recommended-type-checked',
        'plugin:@typescript-eslint/stylistic-type-checked',
        'plugin:import/typescript',
        'plugin:jest/recommended',
        'plugin:jest/style',
        'plugin:testing-library/react',
        'plugin:jest-dom/recommended',
        'prettier' /* prettierはextendsの最後に指定する https://github.com/prettier/eslint-config-prettier */,
      ],
      parserOptions: {
        // lint 対象のファイルに最も近い tsconfig.json を利用する。
        // ref: https://typescript-eslint.io/linting/typed-linting/#specifying-tsconfigs
        project: true,
      },
      rules: {
        '@typescript-eslint/no-unused-vars': [
          'error',
          {
            ignoreRestSiblings: true,
            caughtErrors: 'all',
            argsIgnorePattern: '^_',
            varsIgnorePattern: '^_',
            destructuredArrayIgnorePattern: '^_',
            caughtErrorsIgnorePattern: '^_',
          },
        ],
        '@typescript-eslint/no-empty-function': 'off',
        '@typescript-eslint/prefer-nullish-coalescing': [
          'error',
          {
            ignorePrimitives: {
              // 論理和 `||` でよく使う 空文字列、false を許可
              string: true,
              boolean: true,
            },
          },
        ],
        // () => void を期待する パラメータに () => Promise<void> を渡してもOKとする
        '@typescript-eslint/no-misused-promises': [
          'error',
          {
            checksVoidReturn: {
              attributes: false,
            },
          },
        ],
        // 意図しない Declaration Merging を避けるため、Props は interface でなく型エイリアスで実装する
        '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
      },
    },
    {
      files: ['*.ts', '*.tsx'],
      excludedFiles: ['src/lib/**/*'],
      rules: {
        'no-restricted-syntax': [
          'error',
          {
            // Component で子要素 = children を受け取る場合は PropsWithChildren を利用する
            selector:
              "TSTypeLiteral > TSPropertySignature[key.name='children'][typeAnnotation.typeAnnotation.typeName.name='ReactNode']",
            message: 'Use PropsWithChildren instead of manually typing children.',
          },
        ],
      },
    },
    {
      files: ['**.stories.tsx', 'src/pages/**/*'],
      rules: {
        'import/no-default-export': 'off',
      },
    },
    {
      files: ['src/**/*.graphql'],
      excludedFiles: ['src/**/*.local.graphql'],
      extends: ['plugin:@graphql-eslint/operations-recommended'],
      parserOptions: {
        operations: 'src/**/*.graphql',
        schema: ['src/generated/schema.graphql', 'src/**/*.local.graphql'],
      },
      rules: {
        '@graphql-eslint/no-deprecated': 'warn',
      },
    },
    {
      files: ['*.test.ts?(x)'],
      plugins: ['testing-library'],
      rules: {
        // userEvent の方が fireEvent よりも忠実にユーザーの操作をエミュレートできるため
        'testing-library/prefer-user-event': 'error',
        // require('next-router-mock') がanyを返すのを許容
        '@typescript-eslint/no-unsafe-return': 'off',
        // parameters.msw.handlers 参照時、 composeStories の型推論 がうまく行かないのでoff
        '@typescript-eslint/no-unsafe-argument': 'off',
        '@typescript-eslint/no-unsafe-member-access': 'off',
      },
    },
    {
      // Storyの型の明示し型推論を効かせる
      files: ['src/**/*.stories.ts?(x)'],
      rules: {
        '@typescript-eslint/explicit-module-boundary-types': 'error',
      },
    },
  ],
};

`package.json` の CI系のscripts
"scripts": {
  "build": "npm run pathpida && next build",
  "postbuild": "next export",
  "prettier-base": "prettier --ignore-unknown --cache --cache-location=.prettier-cache './**/*.{ts,tsx,graphql}'",
  "lint": "next lint -d . --ext .ts --ext .tsx --ext .graphql",
  "lint:prettier": "npm run prettier-base -- --check",
  "fmt": "npm run fmt:eslint && npm run fmt:prettier",
  "fmt:eslint": "npm run lint -- --fix",
  "fmt:prettier": "npm run prettier-base -- --write",
  "test": "jest",
  "typecheck": "tsc --noEmit && echo Done."
},
`.github/workflows/frontend_lint.yml` (CIのうちLint部分)
name: frontend/lint
    
on:
  pull_request:
    paths:
      - "frontend/**"
      - ".github/workflows/frontend_*.yml"
  push:
    branches:
      - 'main'
    paths:
      - 'frontend/**'
  workflow_dispatch:

defaults:
  run:
    working-directory: frontend

jobs:
  run-ci:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: sparse checkout
        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
        with:
          sparse-checkout: frontend
      - uses: actions/setup-node@v4
        with:
          node-version-file: "frontend/.node-version"
          cache: "npm"
          cache-dependency-path: frontend/package-lock.json
      - run: npm ci
      - name: Restore cached Next (next lint), prettier
        uses: actions/cache/restore@v3
        with:
          # https://nextjs.org/docs/pages/building-your-application/configuring/eslint#caching
          path: |
            frontend/.next/cache
            frontend/.prettier-cache
          key: frontend-next-prettier-v1-${{ github.sha }}
          restore-keys: |
            frontend-next-prettier-v1-
      - run: npm run typecheck
      - name: Check the format
        run: |
          npm run fmt
          if ! git diff --exit-code --quiet; then
            git status -s
            echo フォーマット漏れがあります。Mがついているファイルを修正してください
            exit 1
          fi
      - name: Save Next (next lint), prettier cache
        uses: actions/cache/save@v3
        if: github.ref == 'refs/heads/main'
        with:
          path: |
            frontend/.next/cache
            frontend/.prettier-cache
          key: frontend-next-prettier-v1-${{ github.sha }}

おわりに

LinterとCIの改善を通じて、フロントエンドのコードの理解を深め、コード品質の向上に貢献することができました。
入社当初はドメインに知識が少なく、機能実装ではすぐに価値を出すことが難しいと感じていました。
開発環境の改善を並行して行うことで、プロジェクトに貢献している満足感を得ることができ良かったです。

株式会社エス・エム・エスではソフトウェアエンジニアを募集しています。
フロントエンドで開発、解決したい課題がまだたくさんあります!
興味を持った方は エンジニア採用情報 をご覧ください。

Discussion