<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>可重現性 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E5%8F%AF%E9%87%8D%E7%8F%BE%E6%80%A7/</link><description>Recent content in 可重現性 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 15 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E5%8F%AF%E9%87%8D%E7%8F%BE%E6%80%A7/index.xml" rel="self" type="application/rss+xml"/><item><title>乾淨機器驗證：repo 宣告了什麼、機器實際依賴什麼</title><link>https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/clean-machine-verification/</link><pubDate>Wed, 15 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/clean-machine-verification/</guid><description>&lt;p>Dotfile repo 記錄的是&lt;strong>宣告&lt;/strong>——環境應該長什麼樣（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/00-dotfile-mindset/dotfile-iac-parallel/" data-link-title="Dotfile 跟 Infra IaC 的平行關係" data-link-desc="想理解 dotfile 管理在工程實踐裡的定位、或釐清「重建指令」跟「備份」的差異時回來讀">Dotfile 是重建指令，不是備份&lt;/a>）。但一台用了很久的工作機，實際依賴的狀態往往比 repo 宣告的更多。這兩者的差額，就是「以為可重現、其實不然」的來源。驗證這個差額的唯一工具，是在一台沒有累積狀態的乾淨機器上、把 &lt;code>install.sh&lt;/code> 實跑一遍——不是讀 repo 確認「看起來有寫」。&lt;/p>
&lt;h2 id="宣告-vs-實際依賴">宣告 vs 實際依賴&lt;/h2>
&lt;p>讀 repo 只顯示宣告了什麼。它在結構上看不見「機器實際依賴、但 repo 沒宣告」的部分——因為那些依賴根本不在 repo 裡，無從讀起。&lt;/p>
&lt;p>實際依賴散落在 repo 追蹤不到的地方：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>安裝器寫進非-repo 追蹤的 shell profile&lt;/strong>。裝 Homebrew、rustup、nvm、conda 這類工具時，安裝器常會在 &lt;code>~/.bashrc&lt;/code>、&lt;code>~/.zprofile&lt;/code>、&lt;code>~/.profile&lt;/code> 尾端 append 一行 PATH 或 init。如果 dotfile 沒把這些 profile 收進 repo（或沒在自己管理的 &lt;code>env.zsh&lt;/code> 重新宣告），新機器上工具裝了、shell 卻找不到（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/02-shell-config/path-plugin-prompt/" data-link-title="PATH、Plugin 與 Prompt" data-link-desc="PATH 越來越長不知道怎麼管、要選 zsh plugin manager、或想設計 prompt 時回來讀">PATH 的集中宣告&lt;/a>）。&lt;/li>
&lt;li>&lt;strong>系統層的 PATH drop-in&lt;/strong>。官方 &lt;code>.pkg&lt;/code> / 套件會丟 &lt;code>/etc/paths.d/&lt;/code>（macOS）或 &lt;code>/etc/profile.d/&lt;/code>（Linux）的檔案來設 PATH。這些是系統檔、不在家目錄的 dotfile 範圍，重灌後不會自己回來。&lt;/li>
&lt;li>&lt;strong>手動裝過、忘了它存在的 runtime&lt;/strong>。幾個月前手動裝的 Go、Node、Python 就在機器上，於是 bootstrap 從沒被迫處理「這台沒有 runtime」的情況。&lt;/li>
&lt;li>&lt;strong>來源寄居在別專案的工具&lt;/strong>。一個全域使用的 CLI，來源目錄卻在某個別專案的 checkout 裡（&lt;code>~/project/foo/tools/bar&lt;/code>）。換機器那個專案不在，工具就裝不回來——重建鏈斷在那個 checkout。&lt;/li>
&lt;/ul>
&lt;h2 id="原機是一台被污染的儀器">原機是一台被污染的儀器&lt;/h2>
&lt;p>一台工作機的狀態只增不減，而且從不標示哪些是承重的。每次手動安裝、每個編輯 profile 的 installer、每個指向本地目錄的工具，都加進一條 repo 不知道的依賴。&lt;/p>
&lt;p>因此「在原機上驗證 dotfile 可不可重現」本身是矛盾的：原機正是累積了所有這些未宣告狀態的機器，它偵測不到自己的污染。讀 repo「看起來很完整」的那種完整感，來自原機把缺口補起來了——換一台乾淨機器，那些補丁全部消失。&lt;/p>
&lt;h2 id="怎麼驗乾淨機器實跑">怎麼驗：乾淨機器實跑&lt;/h2>
&lt;p>驗證要在一台沒有累積狀態的機器上跑，且要跑真正宣告的步驟：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>乾淨 = 沒有任何累積狀態&lt;/strong>：全新 VM、拋棄式 container、或全新使用者帳號——不是原機、也不是用過的機器。涵蓋目標作業系統（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/" data-link-title="模組八：同步、Bootstrap 與環境重建" data-link-desc="換機器或重灌時怎麼還原工作環境 — bootstrap script 設計、套件清單管理、跨機器同步策略、secret 排除，以及 VM 快照和 dotfile 重建兩種思路的場景判讀">可觀測的 bootstrap&lt;/a> 對「要在真正乾淨、且涵蓋目標 OS 的環境各跑一次」的展開）。&lt;/li>
&lt;li>&lt;strong>跑真實的 install，不是讀 repo、也不是心裡走一遍&lt;/strong>：靜態檢視跟原機共享同一組盲點——它看得到宣告，看不到缺的依賴。&lt;/li>
&lt;li>&lt;strong>每個失敗都是一條未宣告的依賴&lt;/strong>：乾淨機器跟預期分歧的每一個點，都指出一條 repo 漏掉的狀態。把它搬進 repo（收進 dotfile、或在 &lt;code>install.sh&lt;/code> 補上），或明確標成刻意手動。產出不是「通過了」，是「原本沒被意識到的依賴清單」。&lt;/li>
&lt;li>&lt;strong>驗證用的乾淨機器跑過一次就被污染了&lt;/strong>：有意義的重跑之間要重置（VM 還原快照、container 重建）。&lt;/li>
&lt;/ol>
&lt;p>這條紀律的定期版本：每隔一段時間在拋棄式環境跑一次完整 bootstrap，確認這份重建指令真的還能重建（見 &lt;a href="https://tarrragon.github.io/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/" data-link-title="跨機器同步、Secret 管理與環境重建流程" data-link-desc="多台機器的 dotfile 怎麼同步、哪些東西不該進 repo 時回來讀">同步策略與機密處理&lt;/a>）。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>宣稱 dotfile 可重現、但只在自己機器上驗過&lt;/td>
 &lt;td>在乾淨機器實跑一次才算數——原機驗不出缺口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>某個工具「這台能用、換機器就 command not found」&lt;/td>
 &lt;td>找那條 repo 沒記錄、原機卻存在的狀態（profile / drop-in / 手裝 runtime）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安裝器印出「請把這行加進 ~/.bashrc」而 install.sh 沒做&lt;/td>
 &lt;td>那步就是未宣告依賴——收進 dotfile 或在 script 內補上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具的來源指向某個別專案的目錄&lt;/td>
 &lt;td>換機器就裝不到——來源要提成可重現的 spec 或標成手動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>讀 repo / 跑 lint 都「看起來完整」&lt;/td>
 &lt;td>完整感來自原機污染——換乾淨機器實跑才是真檢查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這條原則的工程檢討版（跨平台、非 dotfile 專屬）見 &lt;a href="https://tarrragon.github.io/blog/report/clean-room-reproduce-reveals-non-repo-state/" data-link-title="可重現性只有乾淨機器重跑才驗得出：工作機的非-repo 狀態撐著環境、讓缺口隱形" data-link-desc="判斷一個 setup / bootstrap / 環境是否真的可重現、或追查某個東西為何『在我機器上能跑、換機器就壞』時讀：驗證要靠乾淨機器重跑、讀 repo 驗不出來">報告 #227：可重現性只有乾淨機器重跑才驗得出&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Dotfile repo 記錄的是<strong>宣告</strong>——環境應該長什麼樣（見 <a href="/blog/linux/dotfile/00-dotfile-mindset/dotfile-iac-parallel/" data-link-title="Dotfile 跟 Infra IaC 的平行關係" data-link-desc="想理解 dotfile 管理在工程實踐裡的定位、或釐清「重建指令」跟「備份」的差異時回來讀">Dotfile 是重建指令，不是備份</a>）。但一台用了很久的工作機，實際依賴的狀態往往比 repo 宣告的更多。這兩者的差額，就是「以為可重現、其實不然」的來源。驗證這個差額的唯一工具，是在一台沒有累積狀態的乾淨機器上、把 <code>install.sh</code> 實跑一遍——不是讀 repo 確認「看起來有寫」。</p>
<h2 id="宣告-vs-實際依賴">宣告 vs 實際依賴</h2>
<p>讀 repo 只顯示宣告了什麼。它在結構上看不見「機器實際依賴、但 repo 沒宣告」的部分——因為那些依賴根本不在 repo 裡，無從讀起。</p>
<p>實際依賴散落在 repo 追蹤不到的地方：</p>
<ul>
<li><strong>安裝器寫進非-repo 追蹤的 shell profile</strong>。裝 Homebrew、rustup、nvm、conda 這類工具時，安裝器常會在 <code>~/.bashrc</code>、<code>~/.zprofile</code>、<code>~/.profile</code> 尾端 append 一行 PATH 或 init。如果 dotfile 沒把這些 profile 收進 repo（或沒在自己管理的 <code>env.zsh</code> 重新宣告），新機器上工具裝了、shell 卻找不到（見 <a href="/blog/linux/dotfile/02-shell-config/path-plugin-prompt/" data-link-title="PATH、Plugin 與 Prompt" data-link-desc="PATH 越來越長不知道怎麼管、要選 zsh plugin manager、或想設計 prompt 時回來讀">PATH 的集中宣告</a>）。</li>
<li><strong>系統層的 PATH drop-in</strong>。官方 <code>.pkg</code> / 套件會丟 <code>/etc/paths.d/</code>（macOS）或 <code>/etc/profile.d/</code>（Linux）的檔案來設 PATH。這些是系統檔、不在家目錄的 dotfile 範圍，重灌後不會自己回來。</li>
<li><strong>手動裝過、忘了它存在的 runtime</strong>。幾個月前手動裝的 Go、Node、Python 就在機器上，於是 bootstrap 從沒被迫處理「這台沒有 runtime」的情況。</li>
<li><strong>來源寄居在別專案的工具</strong>。一個全域使用的 CLI，來源目錄卻在某個別專案的 checkout 裡（<code>~/project/foo/tools/bar</code>）。換機器那個專案不在，工具就裝不回來——重建鏈斷在那個 checkout。</li>
</ul>
<h2 id="原機是一台被污染的儀器">原機是一台被污染的儀器</h2>
<p>一台工作機的狀態只增不減，而且從不標示哪些是承重的。每次手動安裝、每個編輯 profile 的 installer、每個指向本地目錄的工具，都加進一條 repo 不知道的依賴。</p>
<p>因此「在原機上驗證 dotfile 可不可重現」本身是矛盾的：原機正是累積了所有這些未宣告狀態的機器，它偵測不到自己的污染。讀 repo「看起來很完整」的那種完整感，來自原機把缺口補起來了——換一台乾淨機器，那些補丁全部消失。</p>
<h2 id="怎麼驗乾淨機器實跑">怎麼驗：乾淨機器實跑</h2>
<p>驗證要在一台沒有累積狀態的機器上跑，且要跑真正宣告的步驟：</p>
<ol>
<li><strong>乾淨 = 沒有任何累積狀態</strong>：全新 VM、拋棄式 container、或全新使用者帳號——不是原機、也不是用過的機器。涵蓋目標作業系統（見 <a href="/blog/linux/dotfile/08-sync-bootstrap/" data-link-title="模組八：同步、Bootstrap 與環境重建" data-link-desc="換機器或重灌時怎麼還原工作環境 — bootstrap script 設計、套件清單管理、跨機器同步策略、secret 排除，以及 VM 快照和 dotfile 重建兩種思路的場景判讀">可觀測的 bootstrap</a> 對「要在真正乾淨、且涵蓋目標 OS 的環境各跑一次」的展開）。</li>
<li><strong>跑真實的 install，不是讀 repo、也不是心裡走一遍</strong>：靜態檢視跟原機共享同一組盲點——它看得到宣告，看不到缺的依賴。</li>
<li><strong>每個失敗都是一條未宣告的依賴</strong>：乾淨機器跟預期分歧的每一個點，都指出一條 repo 漏掉的狀態。把它搬進 repo（收進 dotfile、或在 <code>install.sh</code> 補上），或明確標成刻意手動。產出不是「通過了」，是「原本沒被意識到的依賴清單」。</li>
<li><strong>驗證用的乾淨機器跑過一次就被污染了</strong>：有意義的重跑之間要重置（VM 還原快照、container 重建）。</li>
</ol>
<p>這條紀律的定期版本：每隔一段時間在拋棄式環境跑一次完整 bootstrap，確認這份重建指令真的還能重建（見 <a href="/blog/linux/dotfile/08-sync-bootstrap/sync-strategy-secret/" data-link-title="跨機器同步、Secret 管理與環境重建流程" data-link-desc="多台機器的 dotfile 怎麼同步、哪些東西不該進 repo 時回來讀">同步策略與機密處理</a>）。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>宣稱 dotfile 可重現、但只在自己機器上驗過</td>
          <td>在乾淨機器實跑一次才算數——原機驗不出缺口</td>
      </tr>
      <tr>
          <td>某個工具「這台能用、換機器就 command not found」</td>
          <td>找那條 repo 沒記錄、原機卻存在的狀態（profile / drop-in / 手裝 runtime）</td>
      </tr>
      <tr>
          <td>安裝器印出「請把這行加進 ~/.bashrc」而 install.sh 沒做</td>
          <td>那步就是未宣告依賴——收進 dotfile 或在 script 內補上</td>
      </tr>
      <tr>
          <td>工具的來源指向某個別專案的目錄</td>
          <td>換機器就裝不到——來源要提成可重現的 spec 或標成手動</td>
      </tr>
      <tr>
          <td>讀 repo / 跑 lint 都「看起來完整」</td>
          <td>完整感來自原機污染——換乾淨機器實跑才是真檢查</td>
      </tr>
  </tbody>
</table>
<p>這條原則的工程檢討版（跨平台、非 dotfile 專屬）見 <a href="/blog/report/clean-room-reproduce-reveals-non-repo-state/" data-link-title="可重現性只有乾淨機器重跑才驗得出：工作機的非-repo 狀態撐著環境、讓缺口隱形" data-link-desc="判斷一個 setup / bootstrap / 環境是否真的可重現、或追查某個東西為何『在我機器上能跑、換機器就壞』時讀：驗證要靠乾淨機器重跑、讀 repo 驗不出來">報告 #227：可重現性只有乾淨機器重跑才驗得出</a>。</p>
]]></content:encoded></item><item><title>可重現性只有乾淨機器重跑才驗得出：工作機的非-repo 狀態撐著環境、讓缺口隱形</title><link>https://tarrragon.github.io/blog/report/clean-room-reproduce-reveals-non-repo-state/</link><pubDate>Wed, 15 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/report/clean-room-reproduce-reveals-non-repo-state/</guid><description>&lt;h2 id="論述基礎與限制">論述基礎與限制&lt;/h2>
&lt;p>本卡抽自一次 dotfiles bootstrap 的乾淨機器冷測：在全新 macOS VM 上跑 &lt;code>install.sh&lt;/code>，一路撞出多個只在乾淨機器現形的失敗，每一個都追得回「原機上存在、但 repo 沒記錄」的狀態。限制：單一案例、平台是 macOS（涉及的狀態類型如 shell profile、&lt;code>/etc/paths.d&lt;/code> 帶 macOS 色彩）；但機制（repo 沒記錄的既有狀態掩蓋重現缺口）不依賴平台，Linux 的 &lt;code>~/.bashrc&lt;/code>、系統 package 手裝殘留同型。&lt;/p>
&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>環境實際依賴的狀態，散落在 repo 之外的許多地方：安裝器寫進 shell profile 的那行、系統 PATH 的 drop-in 檔、幾個月前手動跑過的 installer、來源寄居在別專案裡的工具——這些 repo 不追蹤的檔案與設定，無聲地讓整台工作機得以運作。因此「這個環境能從 repo 重現」在被驗證之前只是一個&lt;strong>宣稱&lt;/strong>；真正的驗證，是在一台沒有這些累積狀態的機器上、把宣告的步驟重跑一遍。&lt;/p>
&lt;p>驗證的工具是「在乾淨機器上實跑重現」、不是「讀 repo」——讀 repo 只顯示&lt;strong>宣告&lt;/strong>了什麼，實跑重現才顯示&lt;strong>實際依賴&lt;/strong>什麼。兩者的差額，正好是那些被依賴、卻沒被宣告的狀態。&lt;/p>
&lt;h2 id="情境">情境&lt;/h2>
&lt;p>dotfiles repo 宣稱能重現開發環境：clone + 跑 &lt;code>install.sh&lt;/code>。在原機上一切正常。同一支 &lt;code>install.sh&lt;/code> 在全新 macOS VM 上跑，撞出一連串失敗，每個都追得回「原機有、repo 沒有」的狀態：&lt;/p>
&lt;ul>
&lt;li>&lt;code>brew&lt;/code> 跟所有 brew 裝的工具都不在 PATH——原機的 &lt;code>~/.zprofile&lt;/code> 有一行 &lt;code>eval brew shellenv&lt;/code>，那是安裝器寫過一次、repo 從未追蹤的檔。&lt;/li>
&lt;li>Go 的 binary 靠 &lt;code>/etc/paths.d/go&lt;/code> 被找到，那是官方 pkg installer 丟的系統檔，不在 repo。&lt;/li>
&lt;li>node / go / uv / flutter「本來就在」，因為幾個月前手動裝過。&lt;/li>
&lt;li>幾個 workflow 工具在原機上 by-name 裝得起來，因為它們的來源目錄（別專案的 &lt;code>.claude/skills/&lt;/code>）在本地存在；到乾淨機器上它們不在 PyPI、直接失敗。&lt;/li>
&lt;/ul>
&lt;p>這些沒有一個在「讀 repo」或跑 &lt;code>bash -n&lt;/code> 時出現。&lt;/p>
&lt;h2 id="為什麼">為什麼&lt;/h2>
&lt;p>工作機的狀態只增不減，而且從不標示哪些是承重的。每次手動安裝、每個編輯 shell profile 或丟 &lt;code>paths.d&lt;/code> 檔的 installer、每個指向本地目錄的工具——每一筆都加進一條 repo 不知道的依賴。讀 repo 只能揭露已宣告的內容，在結構上看不見「被依賴、卻未宣告」的部分。能量到真實依賴的儀器，是乾淨機器重現：一台沒有任何累積狀態的機器跑過宣告的步驟，每一個與預期分歧的點，都是一條未宣告的依賴。&lt;/p>
&lt;p>這也是為什麼「在我機器上能跑」是&lt;strong>量測問題&lt;/strong>、不是人的問題：原機是一台被污染的儀器，它偵測不到自己的污染。&lt;/p>
&lt;h2 id="理想做法">理想做法&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>把任何「可重現 / portable / 從 repo 就能裝起來」的宣稱當成未驗證&lt;/strong>，直到一次乾淨機器重現通過。&lt;/li>
&lt;li>&lt;strong>乾淨 = 沒有任何累積狀態&lt;/strong>：全新 VM、全新使用者帳號、拋棄式 container——不是原機、也不是用過的機器。&lt;/li>
&lt;li>&lt;strong>跑真正宣告的步驟&lt;/strong>（clone + 真實的 install），不是心裡走一遍、也不是 lint。靜態推理跟原機共享同一組盲點。&lt;/li>
&lt;li>&lt;strong>每個分歧都是 finding&lt;/strong>：指出那條未宣告的狀態，把它搬進 repo（或明確標成刻意手動）。產出是「原本沒被意識到的依賴狀態清單」、不是「通過了」。&lt;/li>
&lt;li>&lt;strong>儀器本身要保持乾淨&lt;/strong>：一台跑過一次重現的 VM 現在被污染了；有意義的重跑之間要重置它。&lt;/li>
&lt;/ol>
&lt;h2 id="沒這樣做的麻煩">沒這樣做的麻煩&lt;/h2>
&lt;p>重現宣稱會一路成立，直到它真的要緊的那一刻——新同事的筆電、重灌後的機器、CI runner、換的下一台機器。所有撐著原機的未宣告狀態都不在了，失敗一次全部到齊，在最不方便的時機、落到一個不知道是哪個非-repo 檔在撐著的人手上。而且因為讀 repo「看起來很完整」，這個缺口讀起來像「全都涵蓋了」——正是靜默截斷的失敗模式。&lt;/p>
&lt;h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>原則&lt;/th>
 &lt;th>關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="../single-source-of-truth/">#44 Single Source of Truth：值的住址只能有一處&lt;/a>&lt;/td>
 &lt;td>本卡是它在「重現」維度的症狀：機器本地檔（&lt;code>~/.zprofile&lt;/code>、&lt;code>/etc/paths.d/go&lt;/code>）是值住在 repo 之外的第二個住址，SSoT 違反的表現就是重現缺口；#44 說住址只能一處、本卡說那第二住址在乾淨機器重現前是隱形的。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../url-slug-must-be-explicit-fact/">#93 URL slug 必須顯式定義為 fact&lt;/a>&lt;/td>
 &lt;td>同一族：config 靠隱式推導（slug 從檔名、PATH 從機器預設）vs 顯式宣告。#93 是 identifier 的個案、本卡是環境狀態的個案，共同修法都是把推導來源提成顯式 fact。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../playwright-early-in-loop/">#11 在開發循環裡早一點用 playwright 看真實結果&lt;/a>&lt;/td>
 &lt;td>方法同型：靜態推理看不見的、只有實跑才現形（那裡是 live DOM、這裡是未宣告依賴）。兩者都在靜態推理證明有盲點後、把「推理它」換成「跑它、讀真實結果」。&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="../operational-how-needs-environment-specific-tooling/">操作指引的「怎麼做」要帶環境專屬工具路徑&lt;/a>&lt;/td>
 &lt;td>環境差異咬人族的 sibling：那卡管「同一動作在不同環境的工具路徑不同」、本卡管「同一份 repo 在乾淨機器缺了原機的隱藏狀態」，都是「在別的環境才現形」的缺口。&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;ul>
&lt;li>&lt;a href="../hypothesis-replacement-inherits-no-scrutiny/">#248 推翻一個假說之後，替補者是在驗屍的空檔裡上位的&lt;/a>：本卡處理環境宣告與實際依賴的落差，#248 處理解釋的宣告與它的驗證的落差。共同結構是未經對照的敘述讀起來與已驗證的敘述完全相同，因此都要靠一次刻意的獨立執行才分得開——那裡是乾淨機器，這裡是對照組。&lt;/li>
&lt;li>&lt;a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249 對當下段落沒有收益的標註不會自發發生&lt;/a>：同屬宣告與實際的落差家族，落差的位置不同。本卡在環境（讀 repo 只看到宣告了什麼），那張卡在文字（讀段落只看到論證成立、看不到它建立在什麼條件上）。共同點是缺的東西不產生訊號，要靠一個刻意的動作才現形。&lt;/li>
&lt;/ul>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>宣稱「clone 下來跑 X 就能重現」但只在自己機器驗過&lt;/td>
 &lt;td>在乾淨機器重跑一次才算數、原機驗不出缺口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>某個東西「在我機器上能跑、換機器就壞」&lt;/td>
 &lt;td>找那條 repo 沒記錄、原機卻存在的狀態（profile / paths.d / 手裝殘留）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>安裝器印出「Next steps: 手動加進 ~/.zprofile」而 script 沒做&lt;/td>
 &lt;td>那步就是未宣告依賴、搬進 repo 或在 script 內補上&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工具 by-name 裝得起來、但來源指向本地目錄或別專案&lt;/td>
 &lt;td>換機器就裝不到、來源要提成可重現的 spec 或標成手動&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>讀 repo / 跑 lint 都「看起來完整」&lt;/td>
 &lt;td>完整感來自原機污染、換成乾淨機器實跑才是真檢查&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table></description><content:encoded><![CDATA[<h2 id="論述基礎與限制">論述基礎與限制</h2>
<p>本卡抽自一次 dotfiles bootstrap 的乾淨機器冷測：在全新 macOS VM 上跑 <code>install.sh</code>，一路撞出多個只在乾淨機器現形的失敗，每一個都追得回「原機上存在、但 repo 沒記錄」的狀態。限制：單一案例、平台是 macOS（涉及的狀態類型如 shell profile、<code>/etc/paths.d</code> 帶 macOS 色彩）；但機制（repo 沒記錄的既有狀態掩蓋重現缺口）不依賴平台，Linux 的 <code>~/.bashrc</code>、系統 package 手裝殘留同型。</p>
<h2 id="核心原則">核心原則</h2>
<p>環境實際依賴的狀態，散落在 repo 之外的許多地方：安裝器寫進 shell profile 的那行、系統 PATH 的 drop-in 檔、幾個月前手動跑過的 installer、來源寄居在別專案裡的工具——這些 repo 不追蹤的檔案與設定，無聲地讓整台工作機得以運作。因此「這個環境能從 repo 重現」在被驗證之前只是一個<strong>宣稱</strong>；真正的驗證，是在一台沒有這些累積狀態的機器上、把宣告的步驟重跑一遍。</p>
<p>驗證的工具是「在乾淨機器上實跑重現」、不是「讀 repo」——讀 repo 只顯示<strong>宣告</strong>了什麼，實跑重現才顯示<strong>實際依賴</strong>什麼。兩者的差額，正好是那些被依賴、卻沒被宣告的狀態。</p>
<h2 id="情境">情境</h2>
<p>dotfiles repo 宣稱能重現開發環境：clone + 跑 <code>install.sh</code>。在原機上一切正常。同一支 <code>install.sh</code> 在全新 macOS VM 上跑，撞出一連串失敗，每個都追得回「原機有、repo 沒有」的狀態：</p>
<ul>
<li><code>brew</code> 跟所有 brew 裝的工具都不在 PATH——原機的 <code>~/.zprofile</code> 有一行 <code>eval brew shellenv</code>，那是安裝器寫過一次、repo 從未追蹤的檔。</li>
<li>Go 的 binary 靠 <code>/etc/paths.d/go</code> 被找到，那是官方 pkg installer 丟的系統檔，不在 repo。</li>
<li>node / go / uv / flutter「本來就在」，因為幾個月前手動裝過。</li>
<li>幾個 workflow 工具在原機上 by-name 裝得起來，因為它們的來源目錄（別專案的 <code>.claude/skills/</code>）在本地存在；到乾淨機器上它們不在 PyPI、直接失敗。</li>
</ul>
<p>這些沒有一個在「讀 repo」或跑 <code>bash -n</code> 時出現。</p>
<h2 id="為什麼">為什麼</h2>
<p>工作機的狀態只增不減，而且從不標示哪些是承重的。每次手動安裝、每個編輯 shell profile 或丟 <code>paths.d</code> 檔的 installer、每個指向本地目錄的工具——每一筆都加進一條 repo 不知道的依賴。讀 repo 只能揭露已宣告的內容，在結構上看不見「被依賴、卻未宣告」的部分。能量到真實依賴的儀器，是乾淨機器重現：一台沒有任何累積狀態的機器跑過宣告的步驟，每一個與預期分歧的點，都是一條未宣告的依賴。</p>
<p>這也是為什麼「在我機器上能跑」是<strong>量測問題</strong>、不是人的問題：原機是一台被污染的儀器，它偵測不到自己的污染。</p>
<h2 id="理想做法">理想做法</h2>
<ol>
<li><strong>把任何「可重現 / portable / 從 repo 就能裝起來」的宣稱當成未驗證</strong>，直到一次乾淨機器重現通過。</li>
<li><strong>乾淨 = 沒有任何累積狀態</strong>：全新 VM、全新使用者帳號、拋棄式 container——不是原機、也不是用過的機器。</li>
<li><strong>跑真正宣告的步驟</strong>（clone + 真實的 install），不是心裡走一遍、也不是 lint。靜態推理跟原機共享同一組盲點。</li>
<li><strong>每個分歧都是 finding</strong>：指出那條未宣告的狀態，把它搬進 repo（或明確標成刻意手動）。產出是「原本沒被意識到的依賴狀態清單」、不是「通過了」。</li>
<li><strong>儀器本身要保持乾淨</strong>：一台跑過一次重現的 VM 現在被污染了；有意義的重跑之間要重置它。</li>
</ol>
<h2 id="沒這樣做的麻煩">沒這樣做的麻煩</h2>
<p>重現宣稱會一路成立，直到它真的要緊的那一刻——新同事的筆電、重灌後的機器、CI runner、換的下一台機器。所有撐著原機的未宣告狀態都不在了，失敗一次全部到齊，在最不方便的時機、落到一個不知道是哪個非-repo 檔在撐著的人手上。而且因為讀 repo「看起來很完整」，這個缺口讀起來像「全都涵蓋了」——正是靜默截斷的失敗模式。</p>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../single-source-of-truth/">#44 Single Source of Truth：值的住址只能有一處</a></td>
          <td>本卡是它在「重現」維度的症狀：機器本地檔（<code>~/.zprofile</code>、<code>/etc/paths.d/go</code>）是值住在 repo 之外的第二個住址，SSoT 違反的表現就是重現缺口；#44 說住址只能一處、本卡說那第二住址在乾淨機器重現前是隱形的。</td>
      </tr>
      <tr>
          <td><a href="../url-slug-must-be-explicit-fact/">#93 URL slug 必須顯式定義為 fact</a></td>
          <td>同一族：config 靠隱式推導（slug 從檔名、PATH 從機器預設）vs 顯式宣告。#93 是 identifier 的個案、本卡是環境狀態的個案，共同修法都是把推導來源提成顯式 fact。</td>
      </tr>
      <tr>
          <td><a href="../playwright-early-in-loop/">#11 在開發循環裡早一點用 playwright 看真實結果</a></td>
          <td>方法同型：靜態推理看不見的、只有實跑才現形（那裡是 live DOM、這裡是未宣告依賴）。兩者都在靜態推理證明有盲點後、把「推理它」換成「跑它、讀真實結果」。</td>
      </tr>
      <tr>
          <td><a href="../operational-how-needs-environment-specific-tooling/">操作指引的「怎麼做」要帶環境專屬工具路徑</a></td>
          <td>環境差異咬人族的 sibling：那卡管「同一動作在不同環境的工具路徑不同」、本卡管「同一份 repo 在乾淨機器缺了原機的隱藏狀態」，都是「在別的環境才現形」的缺口。</td>
      </tr>
  </tbody>
</table>
<ul>
<li><a href="../hypothesis-replacement-inherits-no-scrutiny/">#248 推翻一個假說之後，替補者是在驗屍的空檔裡上位的</a>：本卡處理環境宣告與實際依賴的落差，#248 處理解釋的宣告與它的驗證的落差。共同結構是未經對照的敘述讀起來與已驗證的敘述完全相同，因此都要靠一次刻意的獨立執行才分得開——那裡是乾淨機器，這裡是對照組。</li>
<li><a href="../annotation-with-no-local-payoff-needs-a-trigger/">#249 對當下段落沒有收益的標註不會自發發生</a>：同屬宣告與實際的落差家族，落差的位置不同。本卡在環境（讀 repo 只看到宣告了什麼），那張卡在文字（讀段落只看到論證成立、看不到它建立在什麼條件上）。共同點是缺的東西不產生訊號，要靠一個刻意的動作才現形。</li>
</ul>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>宣稱「clone 下來跑 X 就能重現」但只在自己機器驗過</td>
          <td>在乾淨機器重跑一次才算數、原機驗不出缺口</td>
      </tr>
      <tr>
          <td>某個東西「在我機器上能跑、換機器就壞」</td>
          <td>找那條 repo 沒記錄、原機卻存在的狀態（profile / paths.d / 手裝殘留）</td>
      </tr>
      <tr>
          <td>安裝器印出「Next steps: 手動加進 ~/.zprofile」而 script 沒做</td>
          <td>那步就是未宣告依賴、搬進 repo 或在 script 內補上</td>
      </tr>
      <tr>
          <td>工具 by-name 裝得起來、但來源指向本地目錄或別專案</td>
          <td>換機器就裝不到、來源要提成可重現的 spec 或標成手動</td>
      </tr>
      <tr>
          <td>讀 repo / 跑 lint 都「看起來完整」</td>
          <td>完整感來自原機污染、換成乾淨機器實跑才是真檢查</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>