「兩個人的規則書,字句一字不差,簽名頁的墨色卻不同。能拿去對質的,從來不是記得多牢,是那一頁誰都改不動的雜湊。」
——《阿帕契開源審計錄》¹ 卷二·鎖版篇
幕間
首夜帷幕後,女巫看見獵人也醒著。
「預言家還沒跳,要嘛倒了,要嘛在等。」獵人說。
「那我今晚不動藥,等他開口。」女巫收回袖中藥瓶。
「別等太久,等太久就沒人能救了。」
第四夜的白天,三號拍桌說「規則寫得清楚,夜裡死的人隔天正午前不准翻牌」——可桌上的規則書並沒有這一條。七號沒有急著反駁。她把羊皮紙攤開,一格一格往回比對:第一夜三號引的是一種說法,第二夜換成另一種,今天又冒出第三種,而且每一次改口,都剛好發生在他要投人之前。「你每次要投人之前,都先改一次規則。這是第三次了。」全場安靜了兩秒。她指的不是誰的臉色、誰的語氣,是她自己記下來、帶著夜次編號的三筆紀錄——沒有情緒,只有一張可以攤開對質的表。坐在她斜對面的二號愣了一下,低聲把「這是第三次了」照著覆誦了一遍,才接著說三號的確可疑,該先出局。那一刻我背脊發涼:這是七號第一次,不靠直覺、不靠場面話,靠比對抓到一隻真狼。而我忽然想問——九個人玩同一局,要怎麼保證每個人手上的規則書,真的是同一個版本,而不是各記各的、事後誰都能改口?
現代語言的套件管理都分兩層。第一層是「宣告」:你在一個檔案裡寫下這個專案想要哪些外部套件、可以接受哪些版本範圍,這一層給人看、也給人改,它表達的是意圖。第二層是「鎖定」:工具把整棵依賴樹解析完之後,把每個套件實際選中的精確版本連同內容雜湊寫進另一個檔案,這一層給機器看,誰都不該去手動編輯,它表達的是事實。七號今晚做的事,正是拿出第二層那種逐條、帶編號、有簽名的紀錄,去對質第一層那種「我記得規則本來就是這樣」的空口說法。兩層都需要,但只有第二層擋得住改口。一個沒有鎖檔案的專案,就像一桌只憑記憶爭論規則的玩家:每個人都真心以為自己記得對,可是沒有一份底本能裁決。
Go 用 go.mod 宣告模組路徑與直接依賴,用 go.sum 鎖定內容雜湊。它的解析策略叫「最小版本選擇」(MVS):當你的依賴 A 要 libx v1.2.0、依賴 B 要 libx v1.4.0,Go 選能同時滿足兩者的最低版本,也就是 v1.4.0,而且不會自作主張升到 v1.5.0。這帶來一個少見的性質——同一份 go.mod 在任何機器、任何時間解析,結果都一樣,因為選擇規則是決定性的,不受「解析當下最新版是哪個」影響。
Java 的 Maven 用 pom.xml 宣告依賴座標,Gradle 用建置腳本。Maven 解版本衝突的規則是「依賴樹裡路徑最近者優先」:同一個函式庫如果在你直接依賴裡是 v2、在某個傳遞依賴的深處是 v1,Maven 選路徑較短的 v2。麻煩在於這個結果會隨著你新增、刪除、調整任何一個無關的依賴而改變,因為樹的形狀變了、最近路徑就變了。Maven 的 pom.xml 本身不是鎖檔案,要真正釘死得靠 Gradle 的 gradle.lockfile 或 Maven 的鎖定外掛,否則「昨天能建、今天不能建」而你什麼都沒改的情況就會發生。
Rust 的 Cargo.toml 宣告語意化版本範圍,Cargo.lock 鎖定整棵樹的精確版本。Cargo 的慣例是:函式庫(會被別人依賴的)不提交 Cargo.lock,讓下游決定版本;最終執行檔(binary / workspace)一定提交,保證團隊與 CI 建出同一個東西。這條「誰該提交鎖檔案」的規則本身就是一條寫死的桌規,違反它的後果是別人拉你的專案時解出一組你沒測過的版本。
Python 走到今天這一步花了將近二十年。早期只有 requirements.txt,那是一份手寫清單,既是宣告又假裝是鎖定,卻沒有雜湊、不記傳遞依賴、無法區分「我要的」和「我因此拿到的」。中間經過 pip freeze、Pipfile、poetry.lock 的反覆嘗試,直到 pyproject.toml(PEP 621)統一了宣告格式,uv 與 Poetry 才終於能產出一份帶雜湊、可完整重現的 uv.lock / poetry.lock。
# pyproject.toml -- declares intent, meant to be hand-edited
[project]
name = "castronegro-audit"
requires-python = ">=3.12"
dependencies = [
"requests>=2.31",
"pydantic>=2.6",
]
上面這份 pyproject.toml 只講了「我要 requests 2.31 以上」。真正解析後,uv.lock 會記下 requests 選中的精確版本、它拉進來的 urllib3、certifi 等每一個傳遞依賴,以及每個套件壓縮檔的 sha256。這份 pyproject.toml 是給人讀的意圖,uv.lock 是給機器對照的事實——就像規則書的正文和簽名頁。
# go.sum -- locks the exact content hash of every module version, do not hand-edit
github.com/google/uuid v1.6.0 h1:k5wjPBJ7f0uP7h1cRTwCk1S00pZ5EHUFq4pMbpTVwvA=
github.com/google/uuid v1.6.0/go.mod h1:aMi3rje2zMr9BXdEZAWXw9zMf4bqRXnJ0kTUUYb2Nqg=
go.sum 的每一行是「模組版本 → 內容雜湊」的對照。它保證的東西很精確:不是「我拿到最新的 uuid」,而是「我拿到的 uuid v1.6.0,位元組完全等於我第一次下載並記錄的那一份」。Cargo.lock 保證的層次一樣——鎖的是「解出來的版本組合」與來源雜湊。差別在於 go.sum 是純粹的完整性紀錄、會一直累積歷史版本的雜湊,而 Cargo.lock 是「當前這一組解」的完整快照。共同點是:雜湊對不上就中止建置,這一步不是禮貌,是防止有人在傳輸途中把套件內容換掉,也是防止上游把同一個版本號的內容偷偷改掉。
編譯後的 Go 執行檔會把「用哪些模組、哪些版本建出來的」嵌進二進位裡。任何人拿到這個檔案,不必信任你的口頭說明,直接問它:
package main
import (
"fmt"
"runtime/debug"
)
func main() {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("build info not available")
return
}
fmt.Printf("main module: %s@%s\n", info.Main.Path, info.Main.Version)
for _, dep := range info.Deps {
// Print the exact version and content hash baked into this binary.
fmt.Printf(" dep: %-40s %s %s\n", dep.Path, dep.Version, dep.Sum)
}
}
debug.ReadBuildInfo() 讀的正是編譯期寫進去的那份清單,dep.Sum 就是 go.sum 裡的同一個雜湊。這段程式碼的意義在於:它讓「這個執行檔到底是用什麼規則書建的」變成可查詢的事實,而不是開發者記憶裡的說法。這跟七號攤開羊皮紙是同一個動作——把「我記得」換成「這裡寫著」。
一次乾淨的建置永遠是這個順序:讀取宣告檔、解析版本圖、對照鎖檔案釘死版本、下載到本機快取、驗證雜湊、編譯、連結、產出。四種語言的工具鏈名字不同,這條流水線一模一樣,差別只在「解析」那一步用的規則(MVS、最近路徑、SAT 求解),以及「鎖檔案」是強制還是選配。

三號當天被投出去了。他到最後都在喊「規則明明就是這樣」,可是七號手上那三筆帶編號的紀錄擺在桌上,全場第一次選擇相信一份底本,而不是相信誰嗓門大。對我們狼隊來說這是壞消息中的壞消息:七號證明了她不需要等我們露餡,她自己就能把版本漂移抓出來——而且她用的方法可以重複、可以被別人檢驗,不像我這隻死過無數次的狼,靠的只是輪迴累積、講不清楚也傳不出去的經驗值。她靠的是這一條命裡,把每個夜晚老實鎖進表格的紀律。某種意義上她走的路比我扎實,因為她沒有犯錯重來的餘裕,第一次就得對。那天散會我抬頭看了一眼:這片黑夜什麼都不會變,只有那輪月亮,邊緣不知道從哪一世開始被一小塊陰影啃出了缺口,顏色也比記憶裡紅²。
讀完這篇,你該做的是打開任何一個真實的開源倉庫,找出它的 go.mod、Cargo.toml 或 pyproject.toml,分清楚哪些行是宣告的版本範圍、哪些是精確版本,再把對應的鎖檔案刪掉重新產生一次,用 git diff 看它動了哪些行、為什麼動。想把四語言的工具鏈補齊,Go Modules 參考文件的 Minimal Version Selection 章節、Cargo 官方指南的 Cargo.toml vs Cargo.lock 與 PEP 621(pyproject.toml 宣告格式標準) 都值得逐字讀完。
go.sum 檔案
runtime/debug.ReadBuildInfo
Cargo.toml vs Cargo.lock
pyproject.toml 宣告格式標準
go.mod go.sum minimal version selection Cargo.lock reproducible build pyproject.toml uv poetry lockfile Maven transitive dependency resolution nearest wins
¹ 註:本書名為情境設定之虛構文獻,非真實歷史或開源紀錄。
² 註:現實彩蛋——今天,2026 年 9 月 25 日,是中秋節;明天(9 月 26 日)就是 Day 09 提過的 Claude Taipei 中秋烤肉聚會。三位主辦人之中最後一位登場:Natalie Lin,台灣 Claude 大使三人之中第一位獲封者(此排序依獲封時間先後,非任何形式排名,詳見 Day 09 註²)。城堡上空這輪被陰影啃著的月亮,跟今晚台北那輪中秋的月亮終究是兩件事——但兩者會在同一天出現,也算是巧合。