プロジェクト内で完結するESLintのローカルルールを作りたい
はじめに
ある日、クリーンアーキテクチャで定義した各レイヤーの依存関係が正しいかどうかを ESLint がチェックしてくれると楽だよねという話が出た(今のプロジェクトではこのような構成を取り入れております)
ESLint に追加したいルールの要件を整理してみるとわざわざ plugin として公開するほど汎用的なものでもないと思い、プロジェクト内で完結する ESLint の独自ルールを作ってみたというお話
ゴール
下記に示すディレクトリ構成で以下の条件をチェックしたい
-
domains
配下のファイルはinfrastructures
、adapters
,usecases
,view-models
を import しない -
usecase
配下のファイルはinfrastructures
、adapters
を import しない -
view-models
配下のファイルはinfrastructures
、adapters
,usecases
を import しない
ディレクトリ構成
記事のターゲット
- plugin 作るまででもないけどプロジェクトに ESLint の rule を独自でつくりたい人
- ESLint でオレオレルールを作り他人のコードを制限したい人
環境
関係ありそうなのだけ
"devDependencies": {
"@typescript-eslint/experimental-utils": "4.21.0",
"eslint": "7.23.0",
"eslint-plugin-rulesdir": "0.2.0",
"typescript": "4.2.3",
},
いざ実装
今回は typescript でルールを書きたかったので下記のような流れで開発をすることにしました
- typescript で rule を定義
- コンパイル
- コンパイルされた 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-utils
のTSESLint.RuleModule
の型を充したオブジェクトをmodule.exports
すれば良きです
TSESLint.RuleModule
は meta と create 二つのプロパティを持っており、meta はそのまんまで作るruleのメタ情報を定義するプロパティで、create のプロパティで eslint のruleを定義します
どのような meta があるのかは公式ドキュメントへ Let's Go!
どのようにコードを解析するのか知ろう
ESLint は書かれたコードを AST(抽象構文木)という形式で認識してくれます
AST?ナニソレオイシイノ??状態に陥るんですが安心してください
この世には AST Explorer
なるとても便利なものがあります
AST EXplorer
上のスクショを見てもらえるとイメージ掴めると思うんですが、左側にコードを貼り付けると AST としてこんなふうに認識してるよってのを右側に表示してくれます
今回は使いたい rule は import 文の from 以降の部分なので、そこをAST Explorer
で選択してみるとImportDeclaration
の中にある source の value が該当することがわかります
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 を定義しよう
{
"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 を追加する
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 を実行すればエラーメッセージに確認したい値が出力される
(もっといい方法あれば随時モトム)
例: context.fileName()の値を確認したい場合
meta: {
type: 'suggestion',
messages: {
cleanArch: '{{ message }}',
},
schema: [],
},
create: (context) => {
return {
ImportDeclaration(node) {
context.report({
node,
messageId: "cleanArch",
data: {
message: context.getFilename(),
},
});
},
};
};
Discussion