論述基礎與限制

本卡抽自一次 dotfiles bootstrap 的乾淨機器冷測:在全新 macOS VM 上跑 install.sh,一路撞出多個只在乾淨機器現形的失敗,每一個都追得回「原機上存在、但 repo 沒記錄」的狀態。限制:單一案例、平台是 macOS(涉及的狀態類型如 shell profile、/etc/paths.d 帶 macOS 色彩);但機制(repo 沒記錄的既有狀態掩蓋重現缺口)不依賴平台,Linux 的 ~/.bashrc、系統 package 手裝殘留同型。

核心原則

環境實際依賴的狀態,散落在 repo 之外的許多地方:安裝器寫進 shell profile 的那行、系統 PATH 的 drop-in 檔、幾個月前手動跑過的 installer、來源寄居在別專案裡的工具——這些 repo 不追蹤的檔案與設定,無聲地讓整台工作機得以運作。因此「這個環境能從 repo 重現」在被驗證之前只是一個宣稱;真正的驗證,是在一台沒有這些累積狀態的機器上、把宣告的步驟重跑一遍。

驗證的工具是「在乾淨機器上實跑重現」、不是「讀 repo」——讀 repo 只顯示宣告了什麼,實跑重現才顯示實際依賴什麼。兩者的差額,正好是那些被依賴、卻沒被宣告的狀態。

情境

dotfiles repo 宣稱能重現開發環境:clone + 跑 install.sh。在原機上一切正常。同一支 install.sh 在全新 macOS VM 上跑,撞出一連串失敗,每個都追得回「原機有、repo 沒有」的狀態:

  • brew 跟所有 brew 裝的工具都不在 PATH——原機的 ~/.zprofile 有一行 eval brew shellenv,那是安裝器寫過一次、repo 從未追蹤的檔。
  • Go 的 binary 靠 /etc/paths.d/go 被找到,那是官方 pkg installer 丟的系統檔,不在 repo。
  • node / go / uv / flutter「本來就在」,因為幾個月前手動裝過。
  • 幾個 workflow 工具在原機上 by-name 裝得起來,因為它們的來源目錄(別專案的 .claude/skills/)在本地存在;到乾淨機器上它們不在 PyPI、直接失敗。

這些沒有一個在「讀 repo」或跑 bash -n 時出現。

為什麼

工作機的狀態只增不減,而且從不標示哪些是承重的。每次手動安裝、每個編輯 shell profile 或丟 paths.d 檔的 installer、每個指向本地目錄的工具——每一筆都加進一條 repo 不知道的依賴。讀 repo 只能揭露已宣告的內容,在結構上看不見「被依賴、卻未宣告」的部分。能量到真實依賴的儀器,是乾淨機器重現:一台沒有任何累積狀態的機器跑過宣告的步驟,每一個與預期分歧的點,都是一條未宣告的依賴。

這也是為什麼「在我機器上能跑」是量測問題、不是人的問題:原機是一台被污染的儀器,它偵測不到自己的污染。

理想做法

  1. 把任何「可重現 / portable / 從 repo 就能裝起來」的宣稱當成未驗證,直到一次乾淨機器重現通過。
  2. 乾淨 = 沒有任何累積狀態:全新 VM、全新使用者帳號、拋棄式 container——不是原機、也不是用過的機器。
  3. 跑真正宣告的步驟(clone + 真實的 install),不是心裡走一遍、也不是 lint。靜態推理跟原機共享同一組盲點。
  4. 每個分歧都是 finding:指出那條未宣告的狀態,把它搬進 repo(或明確標成刻意手動)。產出是「原本沒被意識到的依賴狀態清單」、不是「通過了」。
  5. 儀器本身要保持乾淨:一台跑過一次重現的 VM 現在被污染了;有意義的重跑之間要重置它。

沒這樣做的麻煩

重現宣稱會一路成立,直到它真的要緊的那一刻——新同事的筆電、重灌後的機器、CI runner、換的下一台機器。所有撐著原機的未宣告狀態都不在了,失敗一次全部到齊,在最不方便的時機、落到一個不知道是哪個非-repo 檔在撐著的人手上。而且因為讀 repo「看起來很完整」,這個缺口讀起來像「全都涵蓋了」——正是靜默截斷的失敗模式。

跟其他抽象層原則的關係

原則關係
#44 Single Source of Truth:值的住址只能有一處本卡是它在「重現」維度的症狀:機器本地檔(~/.zprofile/etc/paths.d/go)是值住在 repo 之外的第二個住址,SSoT 違反的表現就是重現缺口;#44 說住址只能一處、本卡說那第二住址在乾淨機器重現前是隱形的。
#93 URL slug 必須顯式定義為 fact同一族:config 靠隱式推導(slug 從檔名、PATH 從機器預設)vs 顯式宣告。#93 是 identifier 的個案、本卡是環境狀態的個案,共同修法都是把推導來源提成顯式 fact。
#11 在開發循環裡早一點用 playwright 看真實結果方法同型:靜態推理看不見的、只有實跑才現形(那裡是 live DOM、這裡是未宣告依賴)。兩者都在靜態推理證明有盲點後、把「推理它」換成「跑它、讀真實結果」。
操作指引的「怎麼做」要帶環境專屬工具路徑環境差異咬人族的 sibling:那卡管「同一動作在不同環境的工具路徑不同」、本卡管「同一份 repo 在乾淨機器缺了原機的隱藏狀態」,都是「在別的環境才現形」的缺口。
  • #248 推翻一個假說之後,替補者是在驗屍的空檔裡上位的:本卡處理環境宣告與實際依賴的落差,#248 處理解釋的宣告與它的驗證的落差。共同結構是未經對照的敘述讀起來與已驗證的敘述完全相同,因此都要靠一次刻意的獨立執行才分得開——那裡是乾淨機器,這裡是對照組。
  • #249 對當下段落沒有收益的標註不會自發發生:同屬宣告與實際的落差家族,落差的位置不同。本卡在環境(讀 repo 只看到宣告了什麼),那張卡在文字(讀段落只看到論證成立、看不到它建立在什麼條件上)。共同點是缺的東西不產生訊號,要靠一個刻意的動作才現形。

判讀徵兆

訊號該做的事
宣稱「clone 下來跑 X 就能重現」但只在自己機器驗過在乾淨機器重跑一次才算數、原機驗不出缺口
某個東西「在我機器上能跑、換機器就壞」找那條 repo 沒記錄、原機卻存在的狀態(profile / paths.d / 手裝殘留)
安裝器印出「Next steps: 手動加進 ~/.zprofile」而 script 沒做那步就是未宣告依賴、搬進 repo 或在 script 內補上
工具 by-name 裝得起來、但來源指向本地目錄或別專案換機器就裝不到、來源要提成可重現的 spec 或標成手動
讀 repo / 跑 lint 都「看起來完整」完整感來自原機污染、換成乾淨機器實跑才是真檢查