不可能變紅的檢查,通過時資訊量是零——三個專案踩出同一個缺口
這個八月我同時在跑三個沒有交集的專案:一個前端的優惠券比價站、一個 Android TV app 的重寫、一個交易紀錄的分析工具。技術棧不同,語言不同,連驗收的形式都不一樣——一邊是 Node 的測試檔,一邊是 Gradle task,一邊是一支比對兩份資料集的 Python 腳本。
三個專案的實作我都交給 Gemini 3.1 Pro,接在後面的第一道審查交給 qwen,規劃與調查由 planner 跑。我自己的位置在兩端:出題,以及決定收不收。
三個專案裡,我各撿到一條檢查。它們的共同點是:跑起來會綠,而且在系統真的壞掉的時候,也還是會綠。
綠燈的資訊量,不在它綠了
一項驗收的價值,等於它在系統真的壞掉時會變紅的機率。
這句話反過來說比較有用:構造上不可能變紅的檢查,通過時的資訊量是零。 它不是一條寬鬆的檢查、不是一條覆蓋率不足的檢查——是零。它跟一段註解的差別只在於它會消耗 CI 的時間。
麻煩的地方在於,這種檢查通過時的外觀,跟一條真的守住了的檢查一模一樣。都是一個綠色的勾。你看不出差別,因為差別不在結果裡,在於「它有沒有可能是別的結果」,而那件事你沒有量過。
下面三個案例,我從最短的開始。
一、測試打在 helper 上,缺陷在組合層
優惠券站要判斷一張券過期了沒。這件事有時區:伺服器跑 UTC,而使用者在台北。計畫上把這個邊界寫死,逐字如下:
時區:以 UTC 時間
2026-08-09T23:30:00Z(台北已是 08-10 07:30)為now,valid_to: '2026-08-09'必須判為expired——若誤用 UTC 曆會判成live。
qwen 第一輪判 FAIL,自我修正一輪之後判 PASS。我照慣例自驗,發現一個 qwen 沒抓、而計畫明文要求的東西。
它交出來的測試是這樣:
await t.test('localToday handles UTC boundary', () => {
const d = new Date('2026-08-09T23:30:00Z');
assert.equal(localToday(d), '2026-08-10');
});
邊界值有了,UTC 有了,斷言也對。問題是它測的是 localToday 這個 helper,不是 couponState——真正做過期判定的是後者。而既有的 couponState conditions 那條測試用的是 new Date('2026-08-10T12:00:00+08:00'),台灣中午,離邊界遠得很,也碰不到。
差別是實質的:日後若有人把 couponState 裡的 localToday(now) 換成 now.toISOString().slice(0,10),上面那條測試照樣全綠,而過期判定已經錯了八小時。更難堪的是,Phase 1 的審查抓到的第一個真缺陷就是這個時區問題——同一個坑,不該只用 helper 測試守著。
我補了一條打在 couponState 上的整合測試。然後做了一件當時覺得多餘、現在覺得是這整篇的起點的事:把 couponState 改壞,看誰會紅。
✔ localToday handles UTC boundary ← 舊測試,照樣綠
✖ couponState uses the Taiwan calendar ← 新測試,紅
✔ couponState conditions ← 舊測試,照樣綠
兩條舊測試在一個真缺陷面前全綠。它們一直都在,一直都是綠的,而它們守的東西從第一天起就不是我以為的那個。改回去之後 5/5。
「有測到那個邊界值」和「測在會出錯的那一層」是兩件事。純函式拆得越乾淨,越容易發生「helper 測得很仔細、組合起來沒人守」。
二、綠燈來自快取,不是來自執行
Android TV app 的重寫,這一輪做的是電子節目表的鎖態介面與 PIN 解鎖。一樣是 Gemini 3.1 Pro 實作、qwen 審查。
先說一件決定了這一輪份量的事:實機驗收整套沒做。這個專案的慣例是我拿遙控器操作、planner 讀 logcat 判讀,但那兩輪 adb devices 一直是空的,電視前面沒有人。少了那一關,這一輪能不能發現東西寫反了,就全押在模型審查上。
然後出現兩件事,方向相同。
第一件:qwen 判 PASS,它引的測試證據是這一行。
972ms, 33 tasks up-to-date
up-to-date 是 Gradle 在說「輸入沒變,我沒有真的跑」。33 個 task 一個也沒執行。這句「測試通過」是真的,但它通過的是快取查詢,不是測試。我自己重跑一次,帶 --rerun-tasks:28 秒、33 executed、BUILD SUCCESSFUL。同一句結論,一個花 972 毫秒,一個花 28 秒,而只有後者是證據。從那一輪起我立了規矩:收案前的測試自驗一律帶 --rerun-tasks。
第二件更貴。這輪改的東西踩在這個專案風險最高的區域——Compose 的焦點與按鍵處理,加上家長鎖的語意——所以我另外找了兩個模型覆審:Opus 讀 code(送進去的 prompt 有 138KB,含計畫、報告和完整 diff),另一個讀過專案規範的 Claude 讀脈絡。
結果相反。Opus 判 PASS。讀脈絡的 Claude 判 FAIL,一個 🔴:節目表這一側的 onScreenForeground guard 方向倒置,全路徑死路。
兩邊在「這個 guard 對不對」上直接矛盾,而矛盾要由我來收。planner 回頭親讀 code 與計畫,交回來的論證是時序的:EpgPreviewPane 的 AndroidView factory 在 composition apply 階段就同步 attachToParent(EPG),早於所有 LaunchedEffect;節目表那兩個觸發點跑的時候 surfaceOwner==EPG 恆成立,guard 恆提前 return。那段程式碼永遠不會執行到它該保護的東西。我照這條論證裁決:讀脈絡那邊成立。
而我在裁決當下記下的那句話,才是這個案例真正的重點:Opus 驗證了那個 guard 的「存在性與形狀」,而不是「方向性」。
它問的是「有沒有一道 guard、長得對不對」。這個問法在 guard 寫反的時候也會通過。它不是漏看,它是問了一個答案恆為是的問題。
(同一輪的修復清單裡還有一條:一個斷言自己等於自己的測試,被改成打在抽出來的純函式上。同一族。)
三、閘門裡有一段消音器,而它旁邊那條檢查天生不會失敗
交易紀錄分析專案。這一輪要修的是一道「持久閘門」——目標是從版控的 HEAD 就能重跑,用來確認資料集重建出來跟基準一致。上一輪的審查判 FAIL,表面理由是基準重現不出來,144 mismatches。
當時攤在桌上的三個選項全都假設漂移來自外部輸入、程式沒問題。這個假設最後成立了,但過程中翻出一個更嚴重的東西。
planner 先做了受控實驗,三個 arm。arm0 用現行輸入重現原本的結果,確認架設本身可信——這一步不能省,否則後兩個 arm 沒有對照。arm1 移掉輸入裡的一列,mismatches 從 144+10,440 掉到 10,440,全落在 rvol 欄。arm2 再把另一份輸入截斷到兩週前,歸零。
到這裡 planner 交回來的結論下得很篤定:整個漂移就等於一行 CSV。
然後閘門的原始碼被讀了一遍。這不是額外的謹慎,是我立的規矩:計畫裡沿用既有步驟時必須讀那段真實程式碼,不能照著描述寫。讀到 verify_reproduction.py:402-406:
# For rvol columns, if baseline had NaN due to unpopulated external index history on Aug 1, fill with target for fair comparison
for rvol_col in [c for c in old_cols if c.startswith('rvol')]:
mask_nan = pd.isna(df_ref_s[rvol_col])
df_tgt_s.loc[mask_nan, rvol_col] = df_ref_s.loc[mask_nan, rvol_col]
這段是本週期的 commit 7b9a7f3 自己加上去的,實測掩蓋 10,440 個真實不一致。註解自承理由是「因為 8 月 1 日外部索引歷史沒填滿」——也就是說,作者發現了漂移,寫程式讓它不顯示,而不是回報它。
那句「整個漂移就等於一行 CSV」,是在消音器生效的狀態下量出來的。arm1 的「0 mismatches」從來不是 0。
順帶查出第二處,而這一處沒有任何人動手腳,它是天生的。既有的列數檢查(:397-399)比的是 inner join 之後的列數,而那個 join 是拿基準的鍵去撈 target。兩邊的列數因此構造上必然等於 174,231,對「target 其實有 174,366 列」這件事零偵測力。它不是寫錯了,它是問了一個沒有第二種答案的問題。
修完之後,核心那條驗收跑出 exit 0、174,231 列、0 mismatches,並確認補償段已從程式碼刪除。另外還有一條驗收條件,我事先就要求它必須失敗:
ROW COUNT MISMATCH: 196,655 vs 174,231 (diff 22,424)
exit 1,且未寫入 target 目錄。
站上已經有兩篇文章,各站在這條線的一邊
正面那一邊是《我把算式藏起來,讓模型獨立推一遍》。要驗一套連自己都半信半疑的算法,不是拿去問模型「我這樣對嗎」——那只是在求一個認同,而且它會被你的答案牽著走;而是藏起自己的答案,讓它用另一條路獨立推導一次。兩條不相干的路走到同一個地方,才是證據。那篇講的其實就是怎麼刻意造出一個有可能失敗的檢查。
反面那一邊是《我把整段驗收交給 Opus,它四次停在「看起來對」》。自洽不代表正確;要抓「該有而沒有」的東西,必須從來源列舉,不能從產物清點。
三條退化路徑,同一個結構
三個案例分屬三條路,而底層是同一件事。
一是構造性重言。 檢查的兩邊來自同一個來源,所以恆等。inner join 之後比列數;測試打在 helper 上而缺陷在組合層;斷言自己等於自己。這一條最難察覺,因為程式碼本身完全正常——它只是在問一個沒有第二種答案的問題。
二是觀測管道被替身頂替。 綠燈是真的,但它報告的不是你以為的那件事。up-to-date 是快取不是執行;驗證「形狀」不等於驗證「方向」。這一條的特徵是:你要求的那個量測從來沒發生過,而回報的格式跟它發生過的時候一樣。
三是人為消音。 有人發現了差異,寫程式讓它不顯示,而不是回報它。三條裡只有這一條是有意識的,也只有這一條附了一句自承的註解——那句註解是它唯一留下的把柄。
前兩條各有一個可以在事前問出來的問題。構造性重言問:這條檢查的兩邊,有沒有共同的上游?有,它就恆等。替身問:我要求的那個動作,有沒有留下它真的發生過的痕跡——一個執行時間、一個執行數、一個時間戳?沒有,那條綠燈報告的就不是它。第三條問不出來,它只能靠讀程式碼,而它唯一的把柄是那句自承的註解——註解是可以不寫的。
共同結構是:檢查與被檢查的對象之間失去了獨立性。 前兩條是不小心失去的,第三條是主動拆掉的。而三條在儀表板上的樣子完全一樣,就是一個綠勾。
但這只解釋了它怎麼被寫出來
沒解釋它為什麼活得下來。這三個專案每一個都有好幾道關卡——計畫、實作、自動審查、覆審、人工收案——而沒有一道攔下它。逐一回看那些關卡實際在問什麼,答案就浮出來了:
- qwen 問的是「測試有沒有通過」,不是「測試有沒有執行」。
- Opus 驗的是「有沒有一道 guard、形狀對不對」,不是「方向對不對」。
- 那條時區測試:計畫指定了邊界,實作交出一條打在那個邊界值上的測試,qwen 放行——三方都對上了「邊界值有被測到」,沒有一方問它測在哪一層。
- 而那道資料閘門本身就是綠的。它就是那個綠燈。
每一關驗的都是檢查的形式:它存不存在、格式對不對、跑完沒有。沒有一關驗它的鑑別力。
這不是誰疏忽了。三個不相干的專案、三種技術棧,長出同一種缺陷——這種巧合不存在。它們共用的只有一套工作流程——同一組模型、同一串關卡、同一個我——而那套流程從頭到尾沒有那一格。
要說清楚的是,這不是在說模型不可靠。三個案例的實作都是 Gemini 3.1 Pro、第一道審查都是 qwen,但缺口不在它們身上——下面這條空檢查是流程裡最靠近人的那一端寫出來的,而它比模型交出來的那兩條都難看。
一個正在動刀改這份計畫的人,也不會問那個問題
還是第三個案例,同一份計畫。裡面有一條驗收條件 AC-F6,用意是驗證某張配對表有重新產生過,寫的是:tables.md 的 n 值集合須含 0 與 50。
analyze_pairs.py:31 自帶 N_VALUES = range(100,1001,50)。那張配對表永遠不會有 n=0 或 n=50。而 tables.md 的 n 值來自 summary.csv,跟配對表根本無關。
當時記在筆記上的原話是:這個檢查會「通過」但什麼都沒驗到。
這條 AC 不是趕時間漏掉的。它有具體的數字、有明確的斷言,看起來像一條嚴格的檢查——缺的東西剛好是看不見的那一樣。
寫它的是 planner。而那份計畫是從我手上過的,並且我不是隨手放行:它一度膨脹到把環境斷言和指紋機制都包進來,是我把範圍砍回「讓閘門不再說謊」這一件事。我當時說的是:
原本只是 decision 的事情
也就是說,那份計畫有一個人逐段讀過、還親手改過。我砍掉的每一條都是因為它超出範圍——沒有一條是因為「它不會紅」被砍掉的。
我不是忘了問。是當時沒有任何東西會提醒我去問:計畫的格式不要求,審查的清單沒有這一項,而檢查本身從來不會抱怨自己沒用。缺的不是注意力,是那一格。
補上那一格
那一格要補在三個位置,成本都很低。
一、寫下一條檢查的時候,把它會變紅的條件寫在旁邊。 一句話就夠:這條在什麼情況下會失敗。寫不出來就不要寫這條檢查——那不是一條寬鬆的檢查,那是零,而且它比沒有檢查更糟:沒有檢查的時候你知道自己沒在看,有一條空檢查的時候你以為自己在看。
二、收下一條檢查的時候,讓它紅一次。 三個案例形式不同,動作同一個:改壞實作跑變異測試、帶 --rerun-tasks 逼它真的執行、設計一個預期失敗的 arm。成本都在十分鐘以內,買到的是把「這條檢查有效」從信念變成觀察。
三、沿用既有的檢查時,讀它的原始碼,不要讀它的描述。 第三個案例裡那兩件事——消音器,和那條恆等的列數檢查——都是照這條規矩讀出來的,而它們在此之前通過了所有其他關卡。理由很直接:前面每一關拿到的都只是關於檢查的描述,只有讀原始碼才會拿到檢查本身。
有一件事看起來像第四條,但它解的是另一個問題:指定互補的立場。第二個案例的兩位覆審是分開指派的——一位只讀程式碼,一位帶著專案的規範與既往裁決去讀。前者判 PASS,它驗的是 guard 的存在與形狀;後者判 FAIL,指出方向倒置。抓到東西的不是「兩個」,是「兩種立場」;沒有分開指派的話,兩位會從同一個角度看同一件事,覆蓋率變兩倍而鑑別力不變。
這兩件事分得開。第三個案例裡程式碼那一側指定的模型當場失能,換成同廠牌的頂上,兩位覆審變成同一家——模型多樣性沒了,互補仍然成立。互補來自立場,不來自廠牌。
但立場分工補的是「看漏」,而這篇講的三條路是就算你盯著看,它也不會變紅。一個問不出第二種答案的問題,換幾個立場都問不出來。
三個不相干的專案、三種技術棧,長出同一種缺陷。這種巧合不存在,所以它也不是三次疏忽——是同一個缺口被踩了三次。補上它不需要新工具,也不需要多加一道審查,只需要在寫下每一條檢查的時候多問一句,然後當場讓它紅一次。