こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトのリポジトリで、使っていない devDependency を削除しました。eslint-config-next です。設定ファイルからは参照していないし、消しても問題ないはずでした。
消した瞬間、lint が起動しなくなりました。
Error: Cannot find module '@next/eslint-plugin-next'
eslint.config.mjs はこのプラグインを import していました。そして package.json には、そのプラグインの宣言がありませんでした。
なぜ今まで動いていたか
@next/eslint-plugin-next は eslint-config-next の依存です。npm は依存をフラットに展開するので、node_modules の直下に実体が置かれます。Node の解決はそこを見つけるので、宣言していなくても import が通ります。
これが phantom dependency(幽霊依存)と呼ばれるものです。自分の package.json には書いていないのに、誰かの依存として入ってきたパッケージを直接使ってしまっている状態を指します。
厄介なのは、壊れるタイミングが自分の変更と関係ないことです。今回はたまたま自分で提供元を消したので原因がすぐ分かりましたが、提供元が依存を差し替えた、メジャーバージョンを上げた、npm の巻き上げ方が変わった、といった理由でも同じことが起きます。そのときは自分のコードを一行も触っていないのに壊れます。
どのゲートにも掛からない
このプロジェクトの pre-commit では、lint、型検査、unit テスト、E2E を回しています。それでも今回の宣言漏れは検出できませんでした。理由は単純で、どのゲートも見ていないからです。
- lint: 動く。
node_modulesに実体があるので import は解決する - 型検査: 通る。同じ理由で型定義も見つかる
- テスト: 通る。実行時も解決できる
- ビルド: 通る
全部のゲートが「ローカルの node_modules にあるかどうか」を見ているので、宣言の有無は誰も検査していません。壊れるのは、依存ツリーが変わって実体が消えたときです。
こういう「規約はあるが破っても何も落ちない」種類の問題は、放っておくと必ず破られます。実際に破られていたので、検査を追加しました。
やることは 2 つの集合の差
検査の中身は難しくありません。ソースから import 指定子を集めてパッケージ名に正規化し、package.json の宣言と突き合わせるだけです。
export function packageNameOf(specifier: string, isBuiltin: (name: string) => boolean): string | null {
if (specifier === '') return null;
if (specifier.startsWith('.') || specifier.startsWith('/')) return null; // 相対・絶対
if (specifier.startsWith('@/') || specifier.startsWith('~/')) return null; // tsconfig エイリアス
const name = specifier.startsWith('@')
? specifier.split('/').slice(0, 2).join('/') // scoped は 2 セグメント
: specifier.split('/')[0];
if (isBuiltin(name)) return null;
return name;
}
@scope/pkg/sub/path から @scope/pkg を取り出すところと、node:fs のような組み込みを除くところだけ気をつければ、あとは差集合です。
例外も少しあります。
const BUNDLER_PROVIDED: readonly string[] = ['server-only', 'client-only'];
server-only と client-only は Next.js が内部に持っていて alias で解決する公式の慣習なので、npm パッケージとしては存在しません。宣言しても意味がないので、検査から外します。
そして設定ファイルも検査対象に含めます。今回の事故がまさに eslint.config.mjs だったからです。「アプリのコードではないから」で対象から外すと、同じ穴が残ります。
難しいのは「どこが import か」の判定
最初はこう書きました。
for (const m of source.matchAll(/(?:from\s+|import\s+|require\()['"]([^'"]+)['"]/g)) { … }
全文に当てると誤検出します。実際、この検査を入れた直後に pre-commit が止まりました。報告されたパッケージ名がこれです。
) || line.startsWith(
原因は、検査スクリプト自身のコードでした。
if (line.startsWith('import ') || line.startsWith('export {')) return;
報告された名前は、コードの中の「文字列と文字列のあいだ」がそのまま取れたものです。この行にはシングルクォートが 4 つあります。
if (line.startsWith('import ') || line.startsWith('export {')) return;
① ② ③ ④
正規表現には、どれが文字列の中でどれが外かが分かりません。順に見るとこうなります。
- ①〜② の文字列の中身に
importがあるので、import\s+がそこに当たる - その直後の ②(本当はリテラルの閉じクォート)を、指定子の開きクォートだと解釈する
- 指定子は
[^'"]+=「次のクォートまで」なので、② から ③ までを取る
② から ③ までにあるのは ) || line.startsWith( です。文字列が終わってから次の文字列が始まるまでの、コードそのものです。それが「パッケージ名」として報告され、当然そんなパッケージは無いので pre-commit が止まりました。コメントに書いた例示の import も同様に拾います。つまり、import 指定子を正しく取るには、文字列・テンプレートリテラル・コメント・正規表現リテラルを読み飛ばして、コードの位置だけを見る必要があります。
そこで、雑ですが字句を順に走査する形にしました。
/** 文字列リテラルの直前に来ていれば import 指定子とみなす文脈。
* `.` を前置きから除くのは `obj.from('x')` のようなメソッド呼び出しを拾わないため。 */
const IMPORT_CONTEXT = /(?:^|[^\w$.])(?:from|import)\s*$|(?:^|[^\w$.])(?:require|import)\s*\(\s*$/;
from の直前に . があるものを除いているのは、Array.from('abc') や Buffer.from(x) を import と誤認しないためです。この手のヒューリスティックは、除外条件を 1 つ書き忘れるとすぐ誤検出になります。
正規表現リテラルの読み飛ばしも要りました。JavaScript は / が除算にも正規表現にもなるので、直前の文字で判定します。
const REGEX_ALLOWED_BEFORE = /(?:^|[([{,;:=!&|?+\-*%~^<>]|\b(?:return|typeof|case|in|of|do|else))\s*$/;
このあたりは定番のヒューリスティックで、完全ではありません。ただ、パーサを持ち込むほどの話でもないと判断しました。検査の目的は宣言漏れを見つけることであって、JavaScript を正しく解析することではないからです。
誤検出は「検査を止める」ほうへ倒れる
この手の検査を入れるときに意識しておきたいのが、誤検出のコストが検出漏れより高くつきやすいことです。
pre-commit で走る検査が実在しないパッケージ名で止まると、その場でコミットできなくなります。理由が分からなければ、いちばん手軽な対処は検査を無効にすることです。そうやって一度外された検査は、たいてい戻ってきません。
なので、判定できないケースは「報告しない」側に倒しました。副作用として、JSDoc の型注釈だけで参照しているパッケージ(@type {import('pkg')})は検出できません。コメントを読み飛ばしているためです。ただ、型でしか使わないパッケージが消えれば型検査が落ちるので、そちらのゲートに任せられます。
検査を足すときは、自分が拾いたい範囲と、他のゲートが既に守っている範囲を分けて考えると、無理に完璧を目指さずに済みます。
worktree で作業するときの落とし穴
もう 1 つ、実際に踏んだ環境の話です。
このプロジェクトでは、並行作業のために git worktree を使い、node_modules を親から cp -al でハードリンク共有しています。ディスクと npm install の時間を節約するためです。
この状態だと、依存を変更する作業には使えません。共有している node_modules には親の依存がすべて入っているので、宣言漏れがあっても解決できてしまいます。宣言の変更を検証したいなら、その worktree だけ共有を解いて実体を持たせる必要があります。
rm -rf node_modules # ハードリンク共有を解く(親の実体は消えない)
npm install # この worktree の package.json だけで入れ直す
npm run lint && npm run build
phantom dependency は「ローカルにたまたま実体がある」ことで隠れる問題なので、検証も「実体を持たせ直した環境」でやらないと意味がありません。
まとめ
-
package.jsonに宣言していないパッケージでも、transitive に入っていれば import は通る - lint / 型検査 / テスト / ビルドはどれも実体の有無しか見ないので、宣言漏れは全部素通りする。壊れるのは提供元の依存が変わったとき
- 検査の本体は import 指定子とパッケージ宣言の差集合で、難しいのは「どこが import か」の判定
- 文字列・コメント・正規表現の中身を読み飛ばさないと、実在しないパッケージ名で pre-commit が止まる
- 誤検出は検査そのものを外される原因になるので、判定できないケースは報告しない側に倒す
- 設定ファイル(
eslint.config.mjs等)も検査対象に含める。今回の事故はそこだった