何が起きたか:未使用の依存を消したら、本番スクリプトが解決できなくなった

対象は、Remotion を使った動画生成のバッチ系リポジトリです。本番の中核処理は scripts/ 配下にあります。

本番前のフェーズで、AIエージェントが旧経路を撤去しました。そのとき @google-cloud/storage は使われなくなったと判断され、package.json から削除されました。

ところが scripts/render-and-upload.ts の24行目は、google-auth-library を直接 import していました。google-auth-library は package.json に宣言されていませんでした。

削除の結果、この import が解決できなくなりました。@google-cloud/storage が使われていないという判断自体は、誤りではありません。抜けていたのは、削除した依存が間接的に何を支えていたかの確認です。

この不具合には、削除するまで症状が出ないという性質がありました。削除前は何も壊れておらず、削除した瞬間に本番経路だけが壊れます。

なぜ動いていたのか:幽霊依存は宣言されないまま推移依存に乗っている

google-auth-library は、@google-cloud/storage の推移依存(依存の依存)として node_modules に入っていました。自分のコードが直接使っているのに、自分では宣言していない。この状態を幽霊依存(phantom dependency)と呼びます。

一般に、npm や yarn の node_modules は、依存を直下にフラットに配置(hoisting)します。そのため package.json に書いていないパッケージでも、たまたま直下にあれば import できてしまいます。

逆に、pnpm の厳格な node_modules 構造では、宣言していないパッケージの import は失敗します。幽霊依存は、パッケージマネージャの配置の仕方によって見えたり隠れたりします。

このため、幽霊依存は次の条件が揃っている間だけ「動いている」状態になります。

  • 親の依存が package.json に残っている
  • その親が子を直下に置く配置になっている
  • 親が子を依存に含み続けている

relmea の件では、1つ目の条件が依存の削除で崩れました。依存を整理するときに、人が手でやっても気づきにくい箇所です。

どの検査も見ていなかった:ビルドと src 限定の型検査の死角

削除後に走らせた検査は、結果が分かれました。

  • npm run build(Remotion のバンドル):通った
  • npm run typecheck(src/ だけを見る型検査):通った
  • npx tsc --noEmit(プロジェクト全体の型検査):失敗した

捕まえたのは、プロジェクト全体を見る npx tsc --noEmit だけでした。

違いは見ている範囲です。バンドルは動画のエントリから辿れる範囲を対象にし、typecheck は src/ だけを対象にしていました。本番経路の scripts/ は、どちらの視野にも入っていませんでした。

バッチ系のリポジトリは、本番経路が scripts/ にあることが多くあります。その場合、src/ だけを見るゲートは本番を守っていません。

ここで押さえておきたいのは、「通った」の意味です。言えるのは「その検査が見た範囲に問題がなかった」ことだけです。AIから「ビルドも型検査も通りました」と報告されたときは、その検査が本番経路を見ているかを別に確かめる必要があります。

削除の前後に置く2つの検査

この件を受けて、relmea では依存を削除したら次の2つを両方通す運用にしました。

1. プロジェクト全体の型検査

npx tsc --noEmit を、src に絞らず実行します。tsconfig の include の範囲によっては scripts/ が入らないことがあるため、本番経路のファイルが対象に入っているかも確認します。

この検査が見つけるのは、import が解決できないという種類の壊れ方です。実行時にしか分からない壊れ方は、次の検査に任せます。

2. 本番と同じエントリの実走行

本番が実際に叩くエントリを、実際に走らせます。副作用のない dry-run があるなら、それを使います。

型検査は静的な検査で、実行時の挙動までは保証しません。本番と同じ入口から起動すれば、import の解決を含めて実際の経路を通ります。dry-run が無いリポジトリでは、副作用を持たずに実走行できる入口を用意するところから始めることになります。

2つを組み合わせるのは、見ている層が違うからです。1つ目は全ファイルの型と import を、2つ目は実際の起動経路を見ます。

復活させるときと、検査そのものの点検

戻すときは、同じ版を明示する

削除が誤りだと分かったときは、依存を戻します。このとき、新しい版を入れ直さないことが大切です。

relmea では、削除前の package-lock.json(git show HEAD:package-lock.json)で解決されていた版を調べ、同じ版を明示して宣言することにしています。こうすると、挙動を変えずに幽霊依存を正規の依存に変えられます。

「推移依存として偶然入っていたもの」を「自分で宣言したもの」に格上げする作業です。次に親の依存が消えても、同じ形では壊れなくなります。

ゲートが本番を見ているかを点検する

もう1つ、CI のゲートが src 限定になっていないかも点検します。ゲートがあることと、ゲートが本番を見ていることは別の話です。

点検では、ゲートが空振りしていないかを実測で確かめます。たとえば本番経路のファイルに解決できない import を一時的に入れて、ゲートが赤になるかを見ます。赤にならないゲートには、見ていない範囲があります。

relmea には、同じ考え方の規律がほかにもあります。1つは、試験の仕込みが成功したことを確かめてから本題の検証に進むことです。仕込みが落ちていると、空振りの試験が緑に見えます。

もう1つは、検査が何を見ていて何を見ていないかを、実測で確かめることです。今回の件は、後者の実例になりました。

自分の運用に入れるチェックリスト

静的解析ツールには、package.json に無いのに import しているもの(unlisted dependencies)を検出できるものがあります。knip がその一例です。relmea ではこの件の検知に使っていないため、効果は述べません。導入するなら、本番経路が対象に入るかを同じ方法で確かめてください。

AIに依存の整理を任せるなら、削除の前後で次を確認します。

削除の前

  • 削除対象の依存が、本番経路から直接 import されていないかを grep で確認する
  • 削除対象の推移依存に、宣言しないまま使っているパッケージがないかを確認する
  • 現在の package-lock.json をコミットしておき、あとで版を調べられるようにする

削除の後

  • npx tsc --noEmit を src に絞らず通す
  • 本番と同じエントリを、dry-run などの副作用のない形で実走行する
  • build と src 限定の typecheck が通ったことを、完了の根拠にしない

復活させるとき

  • git show HEAD:package-lock.json で、削除前に解決されていた版を調べる
  • 同じ版を明示して宣言し、挙動を変えない

検査の点検

  • CI のゲートが src 限定になっていないかを見る
  • わざと壊した入力でゲートが赤になるかを実測で確かめる
  • 本番経路が scripts/ のようなビルド対象外にある場合は、その場所をゲートの対象に入れる

検査を走らせること自体はAIに任せられます。どの検査がどの範囲を見るかを決めるのは、依頼する側の設計です。