👻

「自分のPCでは動くのに…」を防ぐnpmの落とし穴と対策

に公開

はじめに

ローカルでは通るのに、GitHub Actionsだけ落ちる。これ、あるあるです。

Rollup failed to resolve import "lucide-react"

原因の定番は ホイスティングPhantom Dependencies(幽霊依存)。npmの「便利さ」の裏にある罠ってやつです。

ホイスティングとは

npmやYarnは node_modules をできるだけフラットにして、重複を減らします。これがホイスティング(hoisting)です。

ホイスティングのメリット

メリット 説明
ディスク容量の節約 重複パッケージを1つにまとめる
インストール時間の短縮 ダウンロード・展開が減る
依存解決が単純化 フラット構造で追いやすい

例えばAとBが両方 lodash@4.17.21 に依存していれば、1つだけ入ります。

node_modules/
├── package-a/
├── package-b/
└── lodash/        # 共通化されて1つだけ

Phantom Dependencies(幽霊依存)とは

問題はここから。

Phantom Dependencies は、package.json に書いてないのに、たまたま node_modules にあるから使えてしまう依存のことです。

具体例

// components/Icon.tsx
import { Home } from "lucide-react";

package.jsonlucide-react を入れ忘れていても、他の依存(例: UIライブラリ)が引いていれば、ホイスティングでルートに上がってきます。

その結果、ローカルでは動くんですよね。

node_modules/
├── some-ui-library/
│   └── (lucide-reactに依存)
└── lucide-react/    # ホイスティングでルートに配置

なぜCI/CDで落ちるのか

CI環境はローカルと条件が違います。よくあるズレはこんな感じです。

  1. キャッシュがない
  2. lockfileの状態が違う
  3. パッケージマネージャーが違う
  4. ホイスティングの順序が変わる

特にpnpmは依存関係が厳密なので、幽霊依存は素直にエラーになります。

対策

1. Biomeの noUndeclaredDependencies を使う

BiomenoUndeclaredDependencies は、package.json にないimportをちゃんと拾ってくれます。

biome.json
{
  "linter": {
    "rules": {
      "correctness": {
        "noUndeclaredDependencies": "error"
      }
    }
  }
}

例:

components/Icon.tsx:1:1 lint/correctness/noUndeclaredDependencies ━━━━━━━━━

  ✖ The current dependency is not specified in your package.json.

  > 1 │ import { Home } from "lucide-react";
      │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

2. pnpmの厳密モードを活用する

pnpmはもともと厳密ですが、さらに強くしたいなら .npmrc で調整できます。

.npmrc
# すべてのパッケージをhoistしない(最も厳密)
hoist=false

# 特定のパッケージだけhoist
hoist-pattern[]=*eslint*
hoist-pattern[]=*babel*

逆にレガシー事情で緩めたいなら:

.npmrc
# npm/Yarnに近いフラット構造にする(非推奨)
shamefully-hoist=true

3. 依存関係を明示的に入れる

結局これが一番確実です。

pnpm add lucide-react

まとめ

ホイスティングは便利ですが、幽霊依存という副作用があります。

やることはシンプルです。

  1. Biomeで未宣言依存を検知
  2. pnpmで厳密に管理
  3. CIではクリーンインストール

これだけで「ローカルでは動くのにCIで落ちる」はだいぶ減ります。

参考リンク

Discussion