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