はじめに
こんにちは、toraguitarです。
フロントエンジニア(以下、FE)として働いています。
今回は「husky」を用いた「lint-staged」の設定方法、およびよく使われるメソッドについてまとめていきます。
huskyとlint-stagedとは
| ツール | 役割 |
|---|---|
| husky | Gitフック(pre-commitなど)をシェルスクリプトとして管理するツール |
| lint-staged | ステージングされたファイルに対してのみリンター・フォーマッターを実行するツール |
この2つを組み合わせることで、「git commitのタイミングで変更したファイルだけにリンターを自動適用する」という仕組みが作れます。
Gitフック
Gitには「特定の操作をしたときに自動でスクリプトを実行する」仕組みが備わっており、これをGitフックと呼びます。
.git/hooks/ディレクトリにpre-commitを置くことで有効になりますが、Git管理対象外のため.husky/pre-commit(リポジトリ内のフックスクリプト)で管理します。
シェルスクリプト
シェルが解釈できる命令の寄せ集め。シェルが人間からの命令をコンピューターに伝え、コンピューターが実行するという役割。
1.インストール
huskyとlint-stagedをインストールします。
npm install --save-dev husky lint-staged
インストール後、huskyを初期化します。
npx husky init
これにより.husky/ディレクトリが作成され、package.jsonのscripts.prepareにhuskyが自動追加されます。
prepareスクリプトは npm install実行時に自動で呼ばれるため、チームメンバーが初回セットアップする際にhuskyも自動で初期化されます。
2.設定
設定箇所は大きく2つです。
①huskyのフック設定(.husky/pre-commit)
huskyはGitフックをシェルスクリプトとして管理します。
pre-commitファイルにlint-stagedの実行コマンドを記述します。
# .husky/pre-commit
npx lint-staged
「mergeのタイミングはlint-stagedをスキップする」など特定の条件で実行したい場合、シェルスクリプトで条件を制御し、npmコマンドでスクリプトを呼んで実行します。
# .husky/pre-commit
npm run lint-staged:precommit
# precommit.sh
#!/bin/bash
IS_MERGE=$(git rev-parse -q --verify MERGE_HEAD)
# mergeならスキップ
if [ "$IS_MERGE" ]; then
exit 0
fi
./node_modules/.bin/lint-staged
②lint-stagedの設定
「どのファイルパターンに対して何のコマンドを実行するか」をマッピングで記述します。
設定場所の選択肢
| 場所 | 説明 |
|---|---|
package.json の "lint-staged" キー |
シンプルな設定向け |
lint-staged.config.js(または .mjs) |
複雑なロジックが必要な場合に向く |
// lint-staged.config.js
module.exports = {
'*.ts': ['eslint --fix', 'prettier --write'],
'*.{css,scss}': 'prettier --write',
};
3.チェック処理の書き方
文字列のチェック処理を実装し、実行ファイルをlint-staged-config.jsにコマンドとして登録することで使用することができるようになります。
実装の進め方
ステップ1: エントリーポイントで対象ファイルを受け取る
lint-stagedはコマンドの末尾にステージングされたファイルパスを自動的に付加します。スクリプト側では process.argv でそれを受け取り、各ファイルに対してチェック処理を呼び出します。エラー時は process.exit(1) でコミットを中断します。
// main.ts(エントリーポイント)
const init = async () => {
// 対象のファイルパス群を変数に格納する
const paths = process.argv.slice(2);
try {
for (const filePath of paths) {
// ここでfilePathを受け取り、処理を実行する
}
} catch (error) {
console.error(error);
process.exit(1); // 異常終了でコミットを中断
}
};
init().catch((error) => {
console.error(error);
process.exit(1);
});
process.argvの中身
実行コマンドに関する情報が配列に格納されます。
string[]のオブジェクトが入っており、[0]にはnodeなどの実行ファイルのパス、[1]には./main.tsなど実行しているスクリプトのパスが入ってきます。
pnpm exec tsx ~のように実行コマンドが変わると配列番号も変わるので、注意しましょう。
ステップ2: チェックロジックを役割ごとにモジュール分割する
チェックの種類ごとにファイルを分割することで、保守性と可読性が上がります。
各チェック関数はエラー時に throw new Error(メッセージ) を投げるだけにし、エントリーポイントの try-catch でまとめて捕捉するパターンが管理しやすいです。
// check.ts(チェックモジュールの例)
export const checkText = (content: string, filePath: string) => {
if (/* NGパターンの条件 */) {
throw new Error(`[${filePath}] エラーメッセージ`);
}
};
ステップ3: ファイル読み込みは共通ユーティリティとして切り出す
対象ファイルの読み込み処理や複数のチェックで使い回す正規表現定数は
別モジュールに分離して再利用できる形にします。
// utils.ts
import fs from 'fs-extra';
// 共通でチェックする文字列の正規表現
export const SOME_PATTERN = /正規表現/;
// 対象ファイルの全文をstring型で取得する関数
export const readTargetFile = async (filePath: string) => {
const content = await fs.readFile(filePath, 'utf8');
return content;
};
ステップ4: lint-staged設定にコマンドとして登録する
exportした関数をmain.tsでimportします。
import { checkText } from './check';
import { readTargetFile } from './utils';
// main.ts(エントリーポイント)
const init = async () => {
const paths = process.argv.slice(2); // ステップ1と同じく、tsx 実行時は index 2 からがファイルパス
try {
for (const filePath of paths) {
// チェックするファイルの中身を全文取得
const content = await readTargetFile(filePath);
// モジュール分割した関数に渡し、チェック処理を行う
checkText(content, filePath);
}
} catch (error) {
console.error(error);
process.exit(1);
}
};
init().catch((error) => {
console.error(error);
process.exit(1);
});
main.tsの設定が終わったら、lint-staged.config.jsにコマンドとして登録します。
// lint-staged.config.js
module.exports = {
'対象ファイルのパターン': 'tsx ./path/to/main.ts',
};
ディレクトリ構成
tasks/module/lint-staged/
├── module/ # モジュール分割したチェックロジック
│ ├── hoge.ts
│ └── fuga.ts
├── main.ts # チェックロジックの実行場所
└── utils.ts # ファイル読み込み・共通定数
~~~~
lint-staged.config.js # 実行コマンドの登録
4.チェック処理でよく使うメソッドと実装パターン
カスタムチェックスクリプトを実装する際に使用頻度の高いメソッドと、チェック内容に応じた実装パターンを整理します。
「特定の文字列を取得し、その記述がルールに則っているか」をチェックする際に使用するメソッドを中心にまとめています。
よく使うメソッド一覧
| メソッド | 用途 |
|---|---|
fs.readFile(path, 'utf8') |
ファイルを非同期で文字列として読み込む |
fs.existsSync(path) |
ファイルが存在するかbooleanで同期チェック |
string.match(regex) |
正規表現でファイル内容を検索 |
regex.test(string) |
正規表現にマッチするかbooleanで判定 |
string.includes(str) |
特定の文字列が含まれるかbooleanで判定 |
string.split(str) |
文字列を区切り文字で分割して配列に変換 |
string.trim() |
文字列前後の空白を除去 |
string.startsWith(str) |
特定の文字列で始まるかbooleanで判定 |
string.indexOf(str) |
特定の文字列の位置(インデックス)を取得 |
string.charAt(index) |
指定インデックスの文字を1文字取得 |
パターン1: 特定の記述が存在するかチェックする
想定する場面
ファイル内に必須の記述があるか・あってはいけない記述がないかを確認する。
存在確認だけが目的で、内容の取り出しは不要なケース。
実装
match() をgフラグなしで使い、戻り値が null かどうかで存在を判定します。
const START_TAG = /正規表現1/;
const END_TAG = /正規表現2/;
const startMatches = ejsFile.match(START_TAG);
const endMatches = ejsFile.match(END_TAG);
if (startMatches !== null && endMatches === null) {
throw new Error(`[${filePath}] startに対するendがありません`);
}
ポイント
-
match()はgフラグなしの場合、最初のマッチ1件を返すかnullを返す - 存在チェックは
!== null/=== nullで判定できる - 「Aがあるときに限りBが必須」といった条件の組み合わせに向いている
パターン2: マッチした箇所から値を取り出す
想定する場面
正規表現にマッチした箇所からキャプチャグループの値を取り出すケース。
キャプチャグループ
正規表現の中で () で囲んだ部分のことです。マッチした文字列の中から特定の部分だけを取り出すために使います。
実装
match() をgフラグなしで使い、戻り値の配列インデックスでキャプチャグループを参照します。
// 正規表現のキャプチャグループ:(A0\d|B0\d)が match[1] に入る
const START_TAG = /<!--\s+start:(A0\d|B0\d)\s*-->/;
const match = ejsFile.match(START_TAG);
const id = match !== null ? match[1] : null; // 例: 'A0x' / 'B0x' / null
インデックスの対応関係
| インデックス | 内容 |
|---|---|
match[0] |
マッチした文字列全体 |
match[1] |
1番目のキャプチャグループ () の値 |
match[2] |
2番目のキャプチャグループの値 |
match[n] |
n番目のキャプチャグループの値 |
gフラグをつけると match() はキャプチャグループを返さなくなります。
値の取り出しが目的のときはgフラグを使わないことに注意してください。
パターン3: 同じパターンが複数存在し、全件チェックする
想定する場面
同じ種類の記述がファイル内に複数あり、そのすべてに対してバリデーションをかけたいケース。
実装
match()にgフラグをつけると全マッチの文字列配列を返すため、それをfor...ofでループします。
// gフラグで全マッチを取得
const COMMENT_PARAM = /<!--[\s\S]*?(?:start:)[\s\S]*?-->/g;
const matches = ejsFile.match(COMMENT_PARAM);
if (matches !== null) {
for (const match of matches) {
// `<!--`の直後に空白があるかどうかをチェック
if (match.charAt(4) !== ' ') {
throw new Error(`[${filePath}] "<!--"の直後にスペースを含めて下さい`);
}
}
}
gフラグの有無による match() の戻り値の違い
| gフラグなし | gフラグあり | |
|---|---|---|
| 戻り値 |
null または RegExpMatchArray(キャプチャグループ含む) |
null または string[](全マッチの文字列のみ) |
match[1] へのアクセス |
可能 | 不可 |
| 主な用途 | 最初の1件を取得・値を抽出する | 全件取得して繰り返し処理する |
パターン4: 取得したマッチ文字列に対してさらに条件を判定する
想定する場面
パターン3と同様にg付きのmatch()で全件取得した各文字列に対して、includesで絞り込み、追加チェック用の正規表現にtest()をかけるケース。
ここでは <!-- start:A01(引数,…,,) --> のように括弧内にパラメータを含むコメントをSTART_TAGで拾い、A0系だけ末尾に ,,) が並ぶ形式を additionalPattern で確認する例にします。
実装
// 括弧付きのstartコメントのみ対象(例:<!--start:A01(foo,bar,,)-->)
const START_TAG = /<!--\s+start:(A0\d|B0\d)\([^)]*\)\s*-->/g;
const matches = ejsFile.match(START_TAG);
// 追加チェック用はgなし(ループ内のtest()でlastIndexが不安定にならないようにする)
const additionalPattern = /,\s*,\s*\)\s*-->/;
if (matches !== null) {
for (const match of matches) {
if (match.includes('A0') && !additionalPattern.test(match)) {
throw new Error(`[${filePath}] A0のstartコメント末尾に",,"を含む形式が必要です`);
}
}
}
match()とtest()の使い分け
| メソッド | 戻り値 | 使う場面 |
|---|---|---|
string.match(regex) |
マッチ結果またはnull | マッチした文字列や値が必要なとき |
regex.test(string) |
true / false
|
マッチするかどうかだけ判定したいとき |
パターン5: キャプチャグループで値を取り出し、条件分岐で検証する
想定する場面
パターン2と同様に match() でキャプチャグループから値を取り出したあと、その値に対して文字列チェックをしたり、A0 のときだけ追加ルールを適用したりするケース。
実装
<!-- start:A01 --> のような形式を想定した START_TAG をベースにします(gフラグなし)。match[1] にIDが入ります。複数の () を並べれば match[2] 以降で同時に複数値を取り出せます。
const START_TAG = /<!--\s+start:(A0\d|B0\d)\s*-->/;
const matches = ejsFile.match(START_TAG);
if (matches === null) {
return;
}
const id = matches[1];
// A0のときだけ追加チェック(B0では不要なルールの例)
if (id.startsWith('A0') && !/^A0[1-9]$/.test(id)) {
throw new Error(`[${filePath}] A0の2桁目は1〜9である必要があります`);
}
パターン6: ファイルパスの文字列で条件を分岐する
想定する場面
コミット対象のファイルが sample/a と sample/b のどちらのディレクトリ配下にあるかによって、適用する正規表現や、コンテンツ内の <!-- start:A0x --> / <!-- start:B0x --> との整合チェックを切り替えるケース。
実装
lint-stagedから渡されるfilePathに対してincludes()を使い、キャプチャしたIDに対してはstartsWith('A0')/startsWith('B0')で分岐します。
// パスに含まれるディレクトリで、検証に使う正規表現を切り替える
// ディレクトリがどちらかは必ずある前提
const isUnderSampleA = filePath.includes('/sample/a/');
const START_TAG = isUnderSampleA
? /<!--\s+start:(A0\d)\s*-->/ // sample/a 配下では A0 系のみ想定
: /<!--\s+start:(B0\d)\s*-->/; // sample/b 配下では B0 系のみ想定
const match = ejsFile.match(START_TAG);
// ファイル内の start 記述(A0 / B0)と、配置ディレクトリが一致しているか
const hasA0Start = /<!--\s+start:A0\d/.test(ejsFile);
const hasB0Start = /<!--\s+start:B0\d/.test(ejsFile);
const inSampleA = filePath.includes('/sample/a/');
const inSampleB = filePath.includes('/sample/b/');
if (hasA0Start && inSampleB) {
throw new Error(`[${filePath}] sample/b配下でA0系のstartが使われています`);
}
if (hasB0Start && inSampleA) {
throw new Error(`[${filePath}] sample/a配下でB0系のstartが使われています`);
}
パターンまとめ
チェックしたい内容は?
│
├─ 存在するかどうかだけ確認する
│ └─ match() gフラグなし → null チェック(パターン1)
│
├─ マッチした箇所から値を取り出す
│ ├─ 1件だけ取り出す → match() gフラグなし + match[n](パターン2)
│ └─ 取り出した値の検証・条件分岐・split → match[n] + startsWith / split(パターン5)
│
├─ 複数のマッチすべてに対してチェックする
│ └─ match() gフラグあり → for...of(パターン3)
│ └─ ループ内でさらに判定する → gなしの RegExp + test()(パターン4)
│
└─ ファイルパスで条件を分岐する
└─ includes() / startsWith()(パターン6)