プロジェクト内で完結するESLintのローカルルールを作りたい

12 min read読了の目安(約11300字

はじめに

ある日、クリーンアーキテクチャで定義した各レイヤーの依存関係が正しいかどうかを ESLint がチェックしてくれると楽だよねという話が出た(今のプロジェクトではこのような構成を取り入れております)

ESLint に追加したいルールの要件を整理してみるとわざわざ plugin として公開するほど汎用的なものでもないと思い、プロジェクト内で完結する ESLint の独自ルールを作ってみたというお話

ゴール

下記に示すディレクトリ構成で以下の条件をチェックしたい

  • domains配下のファイルはinfrastructuresadapters, usecases, view-modelsを import しない
  • usecase配下のファイルはinfrastructuresadaptersを import しない
  • view-models配下のファイルはinfrastructuresadapters, usecasesを import しない
ディレクトリ構成

関係あるところだけ

src/core
├── adapters
├── domains
├── infrastructures
├── usecases
└── view-models

全体象

記事のターゲット

  • plugin 作るまででもないけどプロジェクトに ESLint の rule を独自でつくりたい人
  • ESLint でオレオレルールを作り他人のコードを制限したい人

環境

関係ありそうなのだけ

package.json
  "devDependencies": {
    "@typescript-eslint/experimental-utils": "4.21.0",
    "eslint": "7.23.0",
    "eslint-plugin-rulesdir": "0.2.0",
    "typescript": "4.2.3",
  },

いざ実装

今回は typescript でルールを書きたかったので下記のような流れで開発をすることにしました

  1. typescript で rule を定義
  2. コンパイル
  3. コンパイルされた js を eslint の rule として使用する

またディレクトリの構成は下記のようにしました
(clean-archという独自ルールを作る前提)

eslint-rules
├── README.md
├── dist
│   └── clean-arch.js
├── docs
│   └── clean-arch.md
├── rules
│   └── clean-arch.ts
├── tests
│   └── clean-arch.test.ts
└── tsconfig.json

それぞれ

  • dist ts で書いた rules のコンパイル後の js
  • docs rule のドキュメント
  • rules rule の定義場所
  • test rule のテスト
  • tsconfig.json ts で書かれた rule をコンパイルする設定を記載
コード全体
import { TSESLint } from "@typescript-eslint/experimental-utils";

const cleanArch: TSESLint.RuleModule<"cleanArch", []> = {
  meta: {
    type: "suggestion",
    messages: {
      cleanArch: "{{ message }}",
    },
    schema: [],
  },
  create: (context) => {
    return {
      ImportDeclaration(node) {
        // HACK: あんまり書き振りは見ないでぇぇ

        // pathに'src/core'が含まれてるか
        // path例 /Users/yhada/dev/project-name/src/core/adapters/foo/index.ts
        const filePath = context.getFilename();
        const existCore = filePath.indexOf("src/core");
        if (existCore === -1) return;

        // pathに'src'が複数あるか
        const splitPaths = filePath.split("/");
        const matchSrcNames = splitPaths.filter((s) => s === "src");
        if (matchSrcNames.length !== 1) return;

        // 'src/core/{layerName}'までpathがあるか
        const srcIndex = splitPaths.indexOf("src");
        const layerIndex = srcIndex + 2;
        if (splitPaths.length < layerIndex - 1) return;

        // import文にcoreが含まれているか
        // import文例 import { FooModel } from 'core/domains/models/foo'
        const importPath = node.source.value;
        if (typeof importPath !== "string") return;
        const existImportCorePath = importPath.indexOf("core");
        if (existImportCorePath === -1) return;

        // 'core/{layerName}/{resourceName}/{fileName}'までpathがあるか
        const splitImportPaths = importPath.split("/");
        if (splitImportPaths.length < 3) return;

        // 該当のファイルとimportで呼んでいるファイルがクリーンアーキテクチャのどの層か取得
        const layer = splitPaths[layerIndex];
        const importLayer = splitImportPaths[1];

        // HACK: 関数分ける
        if (layer === "domains") {
          if (
            importLayer === "usecases" ||
            importLayer === "adapters" ||
            importLayer === "infrastructures" ||
            importLayer === "view-models"
          ) {
            context.report({
              node,
              messageId: "cleanArch",
              data: {
                message: `domainsでは${importLayer}のモジュールをインポートできないよ`,
              },
            });
          }
        }

        if (layer === "usecases") {
          if (importLayer === "adapters" || importLayer === "infrastructures") {
            context.report({
              node,
              messageId: "cleanArch",
              data: {
                message: `usecasesでは${importLayer}のモジュールをインポートできないよ`,
              },
            });
          }
        }

        if (layer === "view-models") {
          if (
            importLayer === "adapters" ||
            importLayer === "infrastructures" ||
            importLayer === "usecases"
          ) {
            context.report({
              node,
              messageId: "cleanArch",
              data: {
                message: `view-modelsではdomain modelかcomponentのモジュールしかインポートできないよ`,
              },
            });
          }
        }
      },
    };
  },
};

module.exports = cleanArch;

// テストの時に import したいのでexport defaultしてる
export default cleanArch;

必要なライブラリのインストール

eslint と typescript はすでに運用されてるプロジェクトなら入ってるとして@typescript-eslint/experimental-utilsを入れることで eslint の rule を typescript で書けるようになりやす
この記事が参考になりやした

また、eslint-plugin-rulesdirを入れることでプロジェクト内で定義した独自ルールを plugin として扱うことができます。
(eslint-plugin-rulesdirについては補足を後述します)

ruleを書く

ruleは@typescript-eslint/experimental-utilsTSESLint.RuleModuleの型を充したオブジェクトをmodule.exportsすれば良きです

TSESLint.RuleModuleは meta と create 二つのプロパティを持っており、meta はそのまんまで作るruleのメタ情報を定義するプロパティで、create のプロパティで eslint のruleを定義します

どのような meta があるのかは公式ドキュメントへ Let's Go!

どのようにコードを解析するのか知ろう

ESLint は書かれたコードを AST(抽象構文木)という形式で認識してくれます
AST?ナニソレオイシイノ??状態に陥るんですが安心してください
この世には AST Explorer なるとても便利なものがあります

https://astexplorer.net/

AST EXplorerのスクショ
AST EXplorer

上のスクショを見てもらえるとイメージ掴めると思うんですが、左側にコードを貼り付けると AST としてこんなふうに認識してるよってのを右側に表示してくれます

今回は使いたい rule は import 文の from 以降の部分なので、そこをAST Explorerで選択してみるとImportDeclarationの中にある source の value が該当することがわかります

import文のfrom以降の部分
import文のfrom以降の部分

また import 文の from 以降の部分以外に lint 対象のファイルパスも取得したいのですが、ファイルパスはcontext.getFileName()で取得できます

rule に必要な値を取得する書き方はこんな感じになります

create: (context) => {
  return {
    ImportDeclaration(node) {
      // import文のfrom以降の部分
      const fromValue = node.source.value;
      // lint対象のファイルパス
      const filePath = context.getFilename();
    },
  };
};

上記で出てきた context や node、ImportDeclaration などはTSESLintから遡って型を確認することができるのでどのようなプロパティがあるのかなどは型定義を見てもらえればイメージつくかと!(TypeScript サイコウ!!)

rule に違反してることを定義しよう

context.report()を使うことで定められた rule に違反していること表現できます

report メソッドに渡す引数の messageId プロパティはTSESLint.RuleModuleのジェネリクスの型の一つ目の stringを指してて、data の中に可変のエラーメッセージを定義することができます

meta: {
  type: 'suggestion',
  messages: {
    // テンプレート構文みたいな書き方
    // context.reportのdataで定義されたプロパティを使える
    cleanArch: '{{ message }}',
  },
  schema: [],
},
create: (context) => {
  return {
    ImportDeclaration(node) {
      if ('ruleが違反した場合にtrueを返す条件式') {
        context.report({
          node,
          messageId: "cleanArch",
          data: {
            message: `ここにエラーメッセージが書ける`,
          },
        });
      }
    },
  };
};

ts で書いた rule をコンパイルしよう

tsconfig.json を定義しよう
eslint-rules/tsconfig.json
{
  "compilerOptions": {
    "target": "es2015",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["./rules/*.ts"]
}

package.json にビルドコマンドの追加
"build-eslint": " yarn tsc -p eslint-rules/tsconfig.json",

rule のテストをしよう

自分で定義したruleのテストを書きます
ここを参考にしました

TSESLint.RuleTesterの run メソッドの中で valid と invalid に code を渡す感じ
context から使った値はだいたい valid や invalid のプロパティとして定義できるはず(fileName のように)
(valid や invalid のプロパティは型定義されてるので気になる場合は型定義をみてみやしょう)

import { TSESLint } from '@typescript-eslint/experimental-utils'
import cleanArch from "../rules/clean-arch";

const tester = new TSESLint.RuleTester({
  parser: require.resolve("espree"),
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: "module",
    ecmaFeatures: {
      jsx: true,
    },
  },
});

describe("clean-arch rule test", () => {
  tester.run(`clean-arch`, cleanArch, {
    //
    valid: [
      {
        filename: "src/core/usecases/foo.ts",
        code: `import { FooModel } from 'core/domains/models/foo'`,
      },
    ],
    invalid: [
      {
        filename: "src/core/domains/foo.ts",
        code: `import { FooImpl } from 'core/adapters/foo'`,
        errors: [{ messageId: "cleanArch" }],
      },
    ],
  });
});

独自で作った rule を設定しよう

.eslintrc.jsに独自で作った rule を追加する

.eslintrc.js
const rulesDirPlugin = require('eslint-plugin-rulesdir')
rulesDirPlugin.RULES_DIR = 'eslint-rules/dist'

// 関係あるところのみ
module.exports = {
  plugins: [
    'rulesdir',
  ],
  rules: {
    'rulesdir/clean-arch': 'error',
  },
}

作成した rule を build した後に vscode の拡張が有効になっていない場合 reload すれば適応されるはず

独自ルールのエラーメッセージが表示されるところ
独自ルールのエラーメッセージが表示されるところ

補足 1. eslint-plugin-rulesdir について

lint 実行時に--rulesdir を指定するとプロジェクト内で定義した rule も反映されるが、vscode の拡張には反映されないのでプロジェクト内で定義された rule も plugin として vscode の拡張で読み込まれるようにする

その時にeslint-plugin-rulesdirを使うと簡単にプロジェクト内の rule を eslint-plugin として扱うことができるが、commit もスター数も少ないしちょっと怪しい・・・

ただソースをみてみると 1 ファイルしかなくやってることもシンプルだったので、もしなんかまずいことがあったら自作で作り直して eslint の plugin を公開すればいいかなと思った

こんな感じの plugin を公開すれば同様に動くはず(未検証)
// ref: https://github.com/not-an-aardvark/eslint-plugin-rulesdir/blob/master/index.js
const fs = require("fs");
const path = require("path");

const rulesDir = "eslint-rules/dist";
module.exports = {
  get rules() {
    const filePaths = fs
      .readdirSync(rulesDir)
      .filter((fileName) => fileName.endsWith(".js"))
      .map((fileName) => path.resolve(rulesDir, fileName));

    const ruleValues = filePaths.map((filePath) => {
      return {
        ruleName: path.basename(filePath, ".js"),
        filePath,
      };
    });

    const rules = {};
    ruleValues.forEach((ruleValue) => {
      if (rules[ruleValue.ruleName]) {
        throw new Error("ルール名が同じなので登録できません");
      }

      rules[ruleValue.ruleName] = require(ruleValue.filePath);
    });

    return rules;
  },
};

補足 2. デバッグ方法

テストで確認はしたけども実際に動かしてみないと不安だったり、実際にどのような値が入ってるのかを確認する場合のデバッグ方法。
context.report の data プロパティで string を渡せるので、確かめたい値をdataに渡し、エラーになるように rule を設定して lint を実行すればエラーメッセージに確認したい値が出力される
(もっといい方法あれば随時モトム)

デバッグでエラーメッセージに値を出力させる場合は対象のファイルを一つとかにしてlintを実行しましょう

例: context.fileName()の値を確認したい場合
meta: {
  type: 'suggestion',
  messages: {
    cleanArch: '{{ message }}',
  },
  schema: [],
},
create: (context) => {
  return {
    ImportDeclaration(node) {
      context.report({
        node,
        messageId: "cleanArch",
        data: {
          message: context.getFilename(),
        },
      });
    },
  };
};