このエントリはTSKaigi Mashup Kansai 生成AIでTSを扱うときに考えたい設計&ガードレールでの発表内容です。登壇資料をブログとして読めるように再構成して公開しています。
はじめに
去年あたりは「ジュニアエンジニアレベルと思ってね」と公式各所から言われていたコーディングエージェントも、もはやそのレベルを超えてきました。
今や副操縦席に座っているのは人間の側です。しかし、AIが機長だからといって自由に飛行機を操縦してよいかというとそうではありません。決められた航路や離着陸の手順を守る必要があり、そこを外れないガードレールが必要です。
またこれはAIに限った話でもありません。AIであれ人間であれ、「開発者」はもれなく全員が決められたルールに違反しないようコードを書くべきです。またレビュワーも完璧ではありません。問題に気づかないまま変更を承認してしまうこともあるでしょう。
なので、決められたルールに対する自動検証ツールはいつになっても変わらず必要だと考えています。そのための手段として、ESLintをはじめとする静的解析ツールを適切に設定することはやはり極めて有効な手段です。
そこで今回は自分がガードレールとして設定している以下のESLint(plugin含む)ルールについてお話しします。
| プラグイン | 主な効果 |
|---|---|
| eslint-plugin-security | セキュリティ脆弱性の早期検知 |
| eslint-plugin-unicorn | ファイル名規則の統一 |
| @typescript-eslint/naming-convention | 命名規則の統一 |
| eslint-plugin-import | import順序・依存方向の管理 |
| no-restricted-imports / no-console | 意図しないimport・ログの防止 |
| eslint-plugin-jsdoc | ドキュメントの品質向上 |
| @vitest/eslint-plugin | テストコードの品質向上 |
eslint-plugin-security
セキュリティ的にまずい書き方をしていると怒ってくれるルールです。以下のような脆弱性を検知してくれます。
- detect-eval-with-expression
eval(変数)を禁止する- 任意コードが実行されるのを防ぐ
- detect-child-process
exec(変数)を禁止する- OSコマンドインジェクションを防ぐ
- detect-possible-timing-attacks
==/===によるパスワード・トークン比較を禁止する- 文字列比較の速度差から秘密情報が推測されるのを防ぐ
- detect-unsafe-regex
- バックトラックが爆発する危険な正規表現を禁止する
- 悪意ある入力でイベントループが長時間ブロックされるのを防ぐ
- detect-non-literal-fs-filename
fs.readFile(変数)など変数パスのファイル操作- ディレクトリトラバーサルを防ぐ
脆弱性診断ツールを使うなどによって脆弱性そのものを検知することは可能です。しかし、ツールを使った診断はプロダクトがある程度形になってから行われるものであり、そのタイミングで脆弱性が検知され修正が発生した場合、修正の規模が大きくなってしまいます。
一方で、開発中から脆弱性を検知することができればそれだけ修正のコストが低くすることができます。
eslint-plugin-unicorn (unicorn/filename-case)
過去に自分が書いた記事があります。詳細はこちらをご参考に。
例えば以下のように設定すると、ファイル名がケバブケースに強制されます。
files: [/** 適用 or 除外したいファイルパターンを書く */], rules: { 'unicorn/filename-case': [ 'error', { cases: { kebabCase: true, }, }, ], },
@typescript-eslint/naming-convention
例えば以下のように設定することで、各種命名規則を矯正することができる。
'@typescript-eslint/naming-convention': [ 'error', // 変数: camelCase or UPPER_CASE(定数) { selector: 'variable', format: ['camelCase', 'UPPER_CASE'], }, // 関数: camelCase { selector: 'function', format: ['camelCase'], }, // パラメータ: camelCase(未使用は _ prefix 許可) { selector: 'parameter', format: ['camelCase'], leadingUnderscore: 'allow', }, // boolean 変数: is/has/should/can/will prefix を強制 { selector: 'variable', types: ['boolean'], format: ['PascalCase'], prefix: ['is', 'has', 'should', 'can', 'will'], }, // クラス・interface・type: PascalCase { selector: 'typeLike', format: ['PascalCase'], }, // interface に I prefix を禁止(IUser → User) { selector: 'interface', format: ['PascalCase'], custom: { regex: '^I[A-Z]', match: false, }, }, // enum メンバー: PascalCase { selector: 'enumMember', format: ['PascalCase'], }, // private メンバー: _ prefix を強制 { selector: 'memberLike', modifiers: ['private'], format: ['camelCase'], leadingUnderscore: 'require', }, ], },
eslint-plugin-import
importを使う場所でのあれこれを防いでくれる。
import/order
グループごとに順序を定義してくれます。
設定例
'import/order': ['error', { groups: [ 'builtin', // Node.js標準モジュール (fs, path など) 'external', // npm packages 'internal', // パスエイリアス (@myapp/* など) 'parent', // 親ディレクトリ (../) 'sibling', // 同階層 (./) 'index', // index ファイル 'type', // type imports ], 'newlines-between': 'always', // グループ間に空行を強制 alphabetize: { order: 'asc', caseInsensitive: true }, // 各groupはアルファベット順に並ぶ pathGroups: [ { pattern: '@myapp/**', group: 'internal', position: 'before', }, ], }]
import/no-duplicates
同一モジュールの重複importを禁止してくれます。
設定例
'import/no-duplicates': 'error'
OK/NG
// NG import { foo } from './mod'; import { bar } from './mod'; // OK import { foo, bar } from './mod';
import/newline-after-import
import宣言の後に空行を強制してくれます。
設定例
'import/newline-after-import': 'error'
OK/NG
// NG import { foo } from './mod'; const x = foo(); // OK import { foo } from './mod'; const x = foo();
import/no-restricted-paths
モジュール間の依存方向を強制するルール。target(import先)からfrom(import元)へのimportを禁止する。モジュラーモノリスのアーキテクチャ境界の保護に有効です。
構成例
flowchart TD
subgraph LAYERS["レイヤー構成"]
batch["src/batch/**"]
app["src/app/**"]
domain["src/domain/**"]
libs["src/libs/**"]
subgraph INFRA["src/infra/"]
repo["repository<br/>(公開インターフェース)"]
db["db.ts<br/>🔒 直接import禁止"]
end
end
%% 許可された依存方向
app -->|"✅ 許可"| domain
domain -->|"✅ 許可"| libs
app -->|"✅ 許可<br/>(interface経由)"| repo
batch -->|"✅ 許可<br/>(interface経由)"| repo
domain -->|"✅ 許可<br/>(interface経由)"| repo
repo --> db
%% 禁止された依存方向
batch -->|"❌ 禁止<br/>(Rule 1)"| app
app -.->|"❌ 禁止<br/>(Rule 1)"| batch
libs -.->|"❌ 禁止<br/>(Rule 2)"| domain
app -.->|"❌ 禁止<br/>(Rule 3)"| db
batch -.->|"❌ 禁止<br/>(Rule 3)"| db
domain -.->|"❌ 禁止<br/>(Rule 3)"| db
libs -.->|"❌ 禁止<br/>(Rule 3)"| db
%% スタイル
style db fill:#fee2e2,stroke:#ef4444,color:#991b1b
style batch fill:#dbeafe,stroke:#3b82f6
style app fill:#dbeafe,stroke:#3b82f6
style domain fill:#d1fae5,stroke:#10b981
style libs fill:#fef9c3,stroke:#eab308
style repo fill:#f3f4f6,stroke:#6b7280
設定例
'import/no-restricted-paths': ['error', { zones: [ // appレイヤーからbatchレイヤーへの依存を禁止 { target: './src/app//*', from: './src/batch//', message: 'app cannot import from batch.', }, // batchレイヤーからappレイヤーへの依存を禁止 { target: './src/batch//*', from: './src/app//', message: 'batch cannot import from batch.', }, // 共有ライブラリからドメイン層への依存を禁止 { target: './src/libs/**/', from: './src/domain//*', message: 'libs cannot depend on domain.', }, // 特定ファイルへの直接importを禁止し、公開インターフェース経由を強制 { target: './src/!(infra)//*', from: './src/infra/db.ts', message: 'Use repository interface instead of importing db.ts directly.', }, ], }]
ESLint本体に組み込まれているコアルール系
no-restricted-imports
特定 export の import を禁止します。ACL層別使い分けなどに有効です。
構成例
flowchart TD
subgraph LIB["@myapp/logger"]
appLogger
adminLogger
batchLogger
workerLogger
end
subgraph APP["src/app/"]
A1["アプリケーション層"]
end
subgraph ADMIN["src/admin/"]
A2["管理画面層"]
end
subgraph BATCH["src/batch/"]
A3["バッチ処理層"]
end
subgraph WORKER["src/worker/"]
A4["ワーカー層"]
end
appLogger -->|"✅ 許可"| A1
adminLogger -->|"✅ 許可"| A2
batchLogger -->|"✅ 許可"| A3
workerLogger -->|"✅ 許可"| A4
adminLogger -.->|"❌ 禁止"| A1
batchLogger -.->|"❌ 禁止"| A1
workerLogger -.->|"❌ 禁止"| A1
appLogger -.->|"❌ 禁止"| A2
batchLogger -.->|"❌ 禁止"| A2
workerLogger -.->|"❌ 禁止"| A2
appLogger -.->|"❌ 禁止"| A3
adminLogger -.->|"❌ 禁止"| A3
workerLogger -.->|"❌ 禁止"| A3
appLogger -.->|"❌ 禁止"| A4
adminLogger -.->|"❌ 禁止"| A4
batchLogger -.->|"❌ 禁止"| A4
設定例
// src/app/** → appLogger のみ許可 files: ['src/app/**/*.{ts,tsx}'], rules: { 'no-restricted-imports': ['error', { paths: [{ name: '@myapp/logger', importNames: ['adminLogger', 'batchLogger', 'workerLogger'], // 禁止するものを書く message: 'app layer can only import appLogger.', }], }], },
OK/NG
// src/app/service.ts // OK import { appLogger } from '@myapp/logger'; // NG: app層でbatchLoggerは使用不可 import { batchLogger } from '@myapp/logger';
no-console
コード中にconsole.xxが残っていると怒られる。allowオプションで特定メソッドのみ許可できる。本番コードでのconsole.log混入防止に有効。
設定例
'no-console': ['error', { allow: ['warn', 'error'] }]
OK/NG
// NG console.log('debug'); console.debug('debug'); // OK console.warn('something went wrong'); console.error('fatal error');
eslint-plugin-jsdoc
関数・クラス・メソッドへのJSDoc付与を促進する。files,ignoresを指定することで、特定のファイルに対してだけ有効にできます*1。warnにしておくと段階的な導入がしやすいです。0->1のプロジェクトであれば最初からerrorでいいと思います。
設定例
{ plugins: { jsdoc: pluginJsdoc }, rules: { // JSDocコメント自体の存在を要求 'jsdoc/require-jsdoc': ['warn', { require: { FunctionDeclaration: true, ArrowFunctionExpression: true, ClassDeclaration: true, MethodDefinition: true, }, }], // @param タグの記述を要求 'jsdoc/require-param': 'warn', // @returns タグの記述を要求 'jsdoc/require-returns': 'warn', // description(説明文)の記述を要求 'jsdoc/require-description': 'warn', }, }
OK/NG例
// NG const greet = (name: string): string => { return Hello, ${name}; }; // OK /** 挨拶文を返す @param name - 対象の名前 @returns 挨拶文字列 */ const greet = (name: string): string => { return Hello, ${name}; };
@vitest/eslint-plugin
過去に自分が書いた記事があります。
- consistent-test-it
- テストの記述をitかtestに統一できる
- no-conditional-xxx系
- テストの中でifなどの条件分岐を用いてアサーションを変えたり、実行するテストを変えたりするのを禁止できる
- prefer-mock-promise-shorthand
- mockReturnValueとかmockImplementationのなかでPromiseを使わせないようにする
- vitestが用意してくれているmockRejectedValue, mockResolvedValueを使うよう矯正できる
- require-to-throw-message
toThrowなど例外をアサーションする際には、エラーメッセージも併せて検証するように矯正してくれる
- require-top-level-describe
- テストコードにてネスト可能なdescribeの数を制限できる
- describeが多すぎる場合テストの構成を見直すことにつながる
余談
ガードレールではありませんが、気に入っているESLint Pluginとその考え方を紹介させてください。
@stylistic/eslint-plugin
フォーマッターの役割を果たすplugin。Anthonyの作品。JS/TS環境でフォーマッターといえばPrettierを使ってる人も多いイメージがあります。が、僕は最近はもうこれにフォーマッターの役割を移しています。なんでPrettierを使わないか?という理由を詳しく書くと長くなるのでAnthonyのブログに説明を譲ります。
以下、簡潔にPrettierを使わない理由をまとめます。以下の3点以外にもセキュリティ的にもできるだけ余計なライブラリは入れたくない というのもあります。先に紹介したeslint-plugin-jsdocやeslint-plugin-importなどと同等の役割を果たすツールは存在します。が、やはりESLint一つあれば全て同じルールで設定できるという統一感と、pluginの手数の多さがESLintの魅力だと考えています。
1. 設定の柔軟性がない
Prettier は意図的に「Opinionated」なツールとして設計されており、提供するオプションを最小限に絞ることをポリシーとしています。 つまり、「気に入らない挙動があっても設定で直せない」 という状況が発生する。一方でESLint であればルールごとに細かく ON/OFF や挙動を制御できるのに対し、Prettier はそれができません。
2. 行折り返しの問題
PrettierはprintWidth(デフォルト80文字)を超えると自動的に改行が挿入されます。
// 入力 -- 1行で読みやすい foo(reallyLongArg(), omgSoManyParameters(), IShouldRefactorThis(), isThereSeriouslyAnotherOne()); // Prettier 出力 -- 不必要に分割される foo( reallyLongArg(), omgSoManyParameters(), IShouldRefactorThis(), isThereSeriouslyAnotherOne() );
- 引数を1つ追加するだけで、関係のない行まで差分に含まれる。Git diff が汚くなる。
printWidthを大きくしても完全には解決しない- この動作を完全に無効化できない
3. ESLint との競合
二重管理・二重パースの非効率さがあります。
- ESLint にもコードスタイルを整形するルールがあり、Prettier と役割が被る
eslint-config-prettierやeslint-plugin-prettierを導入して競合を解消する必要があるESLintとPrettierがそれぞれ独立してコードをパースするため、同じファイルを2回解析する
おわりに
面白いことにAIが進化してくれたおかげで自分が欲しいESLintのルールをAIに作らせることも以前より簡単になりました。自分はHonoやUnJSのESLint Pluginを自作しています(よかったらStarをください...!!)。
みなさんも自チームの状況に合わせて、ぜひESLint Pluginを自作してみてはいかがでしょうか?
*1:例えばStoryBook使いながらtsxのコンポーネント本体にコメントとかいらないので除外するといったユースケースがある。他にも設定ファイルなども除外しておきたい。