Git diffはcleanなのにNode.jsが失敗する。同じcommitの環境差分を絞り込むWorldbisect
「git diff」が何も返さないのに、片方の環境ではPASSし、もう片方ではFAILする——そういう事態に遭遇したことは少なくないはずだ。commitは同じ、tracked sourceも同じ。それでも挙動が変わるとき、疑うべきはGitが追跡していない部分だ。
Gitだけでは見えないworld
「git diff」が比較できるのは、Gitが追跡しているファイルの内容だけだ。環境変数、.gitignoreされたファイル、untrackedなローカル設定、node_modules/.cacheのような小さなキャッシュは、diffの外側にある。これらが違えば、同じcommitでも動作は変わりうる。しかも多くの場合、その差分がどれなのかを最初から知っているわけではない。
Worldbisect
Worldbisectは、同じGit commit上にある2つの「world」——繰り返しPASSするGOOD worldと、繰り返しFAILするBAD world——を受け取り、両者の間で明示的に捕捉した差分(環境変数・untracked/ignoredファイル・小さなキャッシュ)を、predicateで実際に試しながら削減していくCLIツールだ。
GOODとBADのworld
Worldbisectが最初に要求するのは、2つのworldが同じcommit・同じtracked sourceであることだ。GOOD worldは指定した実行で繰り返しPASSし、BAD worldは繰り返しFAILすることを前提とする。この前提が崩れていれば、以降の絞り込みは意味を持たない。
predicateとfailure identity
Worldbisectは単純なPASS/FAIL判定だけでは動かない。predicateは特定の失敗をWORLDBISECT:FAIL:stable-idのような形で識別し、exit 1で終了することが期待される。exit 0はPASS。クラッシュ、タイムアウト、spawn失敗、想定外のnonzero終了、出力過大、repeat間の不安定さ、そして「違う失敗」への変化は、いずれもINCONCLUSIVEとして扱われる。ある差分を取り除いたら別のエラーに変わった、というケースを誤って「原因が見つかった」と報告しないための仕組みだ。
差分を削っていく
複数の差分候補から最小集合を探すという考え方自体は、delta debuggingやddminとして既に確立されたものであり、Worldbisectが発明したものではない。Worldbisectはその上に、同一commit確認・環境変数とGit外ファイルを混在させたworld capture・使い捨てディレクトリでの再実行・failure identityの一致確認・保守的な結果報告を、一つのワークフローとして組んだものだ。ddmin的なchunk分割と補集合の試行を繰り返し、各試行はOS一時ディレクトリの下に新しい素のファイルコピーとして実体化される。
synthetic demoで確認する
公開リポジトリの demo/synthetic-demo.cjs を今回実行した。これは実際のNode predicateを使い、関係する環境変数の差1つ、無関係な環境変数の差1つ、未使用のローカルファイル、無害なキャッシュファイルを含む合成デモだ。
node demo\synthetic-demo.cjs
今回の実行結果は次のとおりだった。
{
"status": "PASS",
"candidates": [
"ENV:WB_MODE",
"ENV:WB_UNUSED",
"FILE:.cache/harmless.json",
"FILE:.gitignore"
],
"minimalCause": [
"ENV:WB_MODE"
],
"predicateRuns": 20,
"noiseRemoved": 3
}
4つの候補差分のうち、無関係な3件(ENV:WB_UNUSED・キャッシュファイル・.gitignoreされたファイル)が取り除かれ、ENV:WB_MODEだけが残った。これが観測された1-minimalな原因集合だ。
実在issueで確認する: dotenv #794
合成デモだけでなく、公開されている実在issueへの再現も、今回READMEの手順どおりに再実行して確認した。対象はmotdotla/dotenv issue #794、固定commit 560df1555e7fb5cfe7254942e4dc54a16a3316f3(v16.0.3)だ。dotenv自身のソースは変更していない。GOOD worldはUSERNAME=somethingを明示的に設定し、BAD worldは別の値を明示的に設定する。加えて、無関係な環境変数4件も両者で異なる。
git clone --depth 1 --branch v16.0.3 https://github.com/motdotla/dotenv.git .\dotenv-v16.0.3
node .\reproduce.cjs .\dotenv-v16.0.3
今回の実行結果は次のとおりで、公開されているresult.jsonの記録と一致した。
{
"status": "PASS",
"candidates": [
"ENV:USERNAME",
"ENV:WB_NOISE_0",
"ENV:WB_NOISE_1",
"ENV:WB_NOISE_2",
"ENV:WB_NOISE_3"
],
"minimalCause": [
"ENV:USERNAME"
],
"predicateRuns": 30,
"residualReproduces": false,
"freshCause": "FAIL",
"freshWithoutCause": "PASS"
}
5つの候補差分から30回のpredicate実行を経て、ENV:USERNAMEだけが観測された1-minimalな集合として残った。原因候補を含む状態を再実行するとFAILし、取り除いた状態を再実行するとPASSする——という最終確認も行われている。残りの差分(無関係な4件のノイズ)だけを再現してもFAILしないため、residualReproduces: falseだ。
これは、報告済みの設定衝突を、変更していない上流のdotenvソースを使って再現した、という事実にとどまる。dotenvの新しいバグを発見した、root causeを証明した、という主張ではない。
Windows環境の落とし穴
Worldbisectが実際に踏んだ、そして対処した問題がこれだ。USERNAMEのような変数を明示的にABSENT(環境から外す)と要求しても、Windows上のNode/libuvが親プロセスから値を再注入してくることがある。README記載の検証済みランタイム(Node v24.15.0 / libuv 1.51.0)では、USERNAME・USERPROFILE・SYSTEMROOT・PATH・TEMPがこの再注入の対象になり得た一方、カスタム変数とTMPは要求どおり欠落した。つまりUSERNAMEは、そのランタイム上では「確実にABSENTを表せる」変数ではない。
そのためWorldbisectは、predicateを実行する直前に必ず一時的な子プロセスを起動し、両方のスナップショットで宣言された全変数について、実際の起動時状態(requested environmentではなくeffective environment)を観測する。要求した状態と実際の状態が食い違っていれば、そのtrialはINCONCLUSIVEとして扱われ、predicateExecutedはfalseになり、原因やfailure identityは一切返されない。要求と実態がずれた状態で「原因が見つかった」と報告することを防ぐための、保守的な設計だ。
なお、この確認はNode/libuvが起動する境界までを見るものであり、その後にシェルやpreload、アプリケーション自身が環境を変更した場合までは観測しない。信頼されたNODE_OPTIONSのpreloadは、このprobeの実行中にも走る可能性があり、predicateの信頼境界の一部として扱う必要がある。
「observed 1-minimal」が意味すること
minimalCauseは次を意味する。
- 観測された1-minimal集合であること:報告された各項目を取り除くと、fresh checkでは対象predicateがPASSした
- 捕捉した差分の範囲内、かつ与えられたpredicateの範囲内でのみ成り立つこと
- グローバルな最小性、唯一の原因、因果関係の証明ではないこと
residualReproduces: trueは、報告された集合の外にある差分だけでも対象の失敗が再現した、という意味になる。レポートには候補の識別子、requested/effective環境の確認結果、failure identity、実行ハッシュ、件数が含まれる一方、predicateの生の出力は含まれない。
INCONCLUSIVEになるケース
READMEに記載されている範囲では、次のようなケースがINCONCLUSIVEとして扱われる。
- 環境が再現できない(requested/effectiveの不一致)
- predicateの実行がクラッシュ・タイムアウト・spawn失敗した
- repeat間で結果が不安定
- 対象と異なるfailure identityに変化した
demo/inconclusive-demo.cjsを今回実行し、effective環境の不一致がそのまま原因として扱われないことを確認した。
node demo\inconclusive-demo.cjs
{
"status": "INCONCLUSIVE",
"minimalCause": null,
"note": "An effective-env mismatch must not become a cause."
}
既存手法との違い
git diffはtracked sourceの差分しか見せない。手作業でenvを比較すること自体はできるが、それは候補を見せるだけで、実際にどれが効いているかを確認する手段ではない。手動での二分探索や、delta debugging/ddminの考え方自体も、原理的には同じ探索を実現できる。
Worldbisectの違いは、同一commit確認、環境変数とGit外ファイルを混在させたworld capture、使い捨てディレクトリでのfresh実行、failure identityの一致確認、そして最小化後の最終確認までを一つのワークフローにまとめたことにある。優れている、という話ではなく、workflowとしてまとまっているという違いだ。
制約
- ファイルコピーによる分離であり、OSレベルのセキュリティサンドボックスではない。信頼されたローカルpredicateを前提とする
- 絶対パス、ネットワーク接続、サービス、レジストリ状態、ACL、タイムスタンプ、空ディレクトリ、大きな依存ツリー、detachedなデーモン、任意の外部書き込みは対象外
GitHub
ソースコード、テスト、公開 evidenceはこちらで公開している。
https://github.com/iwadjp/worldbisect
関連するAI coding調査toolは、まとめページに整理しています。