系列說明 >> 本系列大部分 PR 來自私人或公司專案,部分程式碼、環境設定與實作細節不便公開。
我只能保證文中提到的 PR 與問題皆為真實案例,但會經過必要的匿名化與內容調整。
本系列主要分享問題如何被發現、背後的思考方式,以及解法如何形成,不會深入討論完整實作與部署細節,敬請見諒。
Change Ref: PR #148-feat(audit): pass12 source.row tiebreak + Cf 字元淨化 + init hygiene lint
Issue: Pre-audit 的 85 個 Case 中,有 27 案因 workbook_row_ambiguous 被 Block;同一組 (object, api) 在 Workbook 可能一次對到 14~20 列,Audit 根本不知道該拿哪一列當答案。
Root Cause: Workbook 的實際粒度是「Method × 回傳欄位」,Audit 卻只用 (object, api) 當 Alignment Key,把真正有差異的維度壓扁了;另外還有肉眼看不見的 Unicode Format Character 混在資料裡搞事。
Solution: (object, api) 一對多時,只允許 Case YAML 的 source.row 在 Candidate Rows 裡做 Tiebreak,不升格成 Lookup Key;同步加入 row_resolution、Unicode Cf 淨化與 Advisory workbook_lint.json。
Evidence: 全套測試 2026 passed、policy_check 0 fail;原本 27 個 workbook_row_ambiguous 實機重跑後歸零,27/27 都記錄為 source_row_tiebreak,並繼續進入 needs_pass3。
上一篇,我們終於讓長時間 Audit 可以中斷續跑。
Case 跑完會進 Bucket,Worklist 知道哪些做過,--resume 不會每次都把前面幾個小時的人生重新活一次。Summary 還開始記:
Case
Workbook Row
Expected
Verdict
Bucket
看起來有序、流暢、舒服、爽。
一套自動化系統終於開始有「昨天做過的事,今天還記得」這種人類基本能力。
然後 Pre-audit 一跑,85 個 Case 分成:
confirmed 8
block 27
needs_pass3 50
27 個 Block,Reason 全部一樣:
workbook_row_ambiguous
What The Fu... Form!!!
一定又是哪個 Excel Cell 寫歪了。
查著查著~欸,好像不是耶。
Audit 找得到資料,而且找得非常勤勞。
一找就是二十列。
例如 getRadioStats():
row263
row264
row265
...
row276
row454
...
老Go看了一眼。
「你不是說上一篇已經把 Workbook Row 記下來了?」
「有啊。」
「那是哪一列?」
「……這二十列其中一列,One of them。」
很好。
昨天努力留下的證據,今天正式升級成:
我有存。
存哪個?阿災。
同一個:
WiFi.Radio.{i}.
getRadioStats()
竟然出現二十列。
這不是 Duplicate 是什麼?
去重啊。
Excel 最擅長的就是製造重複資料,我最擅長的就是嫌 Excel。
Perfect match。
但真的把內容攤開:
row263 getRadioStats() BroadcastPacketsReceived
row265 getRadioStats() BytesReceived
row276 getRadioStats() UnicastPacketsSent
row454 getRadioStats() FailedRetransCount
...
靠。
它們根本不是 Duplicate。
同一支 getRadioStats() 會回很多 Field,Workbook 的設計就是一個 Field 一列。Object 一樣、API 一樣,真正不同的東西藏在另一個欄位。
所以如果我很帥地寫個 Script:
duplicate row → remove
然後一鍵清掉十九列。
那不叫 Data Cleanup。
那叫滅門。
Excel 沒有重複。
是我們眼睛只看兩欄,硬把二十個不同的人叫成同一個名字。
pass12 原本找 Workbook Row 的方法很單純:
normalize(object)
normalize(api)
↓
key = (object, api)
↓
Workbook Index
一列就拿。
零列是 workbook_row_missing。
兩列以上不猜,直接 workbook_row_ambiguous。
這邏輯其實沒毛病。
我甚至很喜歡它「不知道就 Block」的個性。
至少不像某些 Agent,資料不夠也可以先給你兩千字 Root Cause Analysis,最後補一句:
Based on the available evidence, this is highly likely.
Highly likely 個鬼。
問題是 Workbook 真正的資料粒度比較像:
(object, api, return-field)
Audit 卻只留下:
(object, api)
BroadcastPacketsReceived、BytesReceived、FailedRetransCount……
二十個不同 Test Item,全被拍成同一個 getRadioStats()。
然後我們再很震驚地問:
為什麼一個 Key 對二十列?
因為你自己把名字削掉了啊,大哥。
Parser 沒瘋。
是我們問問題時只講了一半。
這也是這一篇真正的問題。
不是 Workbook 有 Duplicate。
是 Workbook 跟 Audit 對同一筆資料的粒度根本不一樣。
二十個李逵站成一排。
Audit 看著他們:
你們誰是李逵?
全部舉手。
更討厭的是,每一個 D### Case 本來就有:
source:
row: 263
D263 指 Row 263,D265 指 Row 265,D276 指 Row 276。
也就是說:
答案一直都在。
只是 Audit Alignment 做久了,老了、油了,完全把它當空氣。
A Component 很認真地說:
我的設計只相信
(object, api)。
旁邊 Metadata:
我知道答案耶。
A Component:
閉嘴,你不是 Authority。
原則沒有錯。
但原則用過頭,就會從 Architecture 變固執。
於是第一個想法自然變成:
那直接用 source.row 不就好了?
Case 說第 263 列,我們就拿第 263 列。
一翻兩瞪眼。
老Go又來了。
「那 Workbook 中間插一列之後,263 還是原本那個 263?」
「……」
好了。
下一題。
直接把 source.row 當 Lookup Key,今天當然很爽。
至於半年後會不會多寫十篇,那是半年後的我。
但 Workbook 是活的。有人插 Row、有人換版、有人整理 Template。263 不會因為內容漂移就自己羞愧地變成 UNKNOWN。
它只會非常堅定地繼續當 263。
所以 PR #148 沒讓 source.row 登基。
它只給它一個比較小的工作:
(object, api)
先找 Candidate
只有一列
→ unique
有多列
→ source.row 只在 Candidate 裡 Tiebreak
對不上
→ 繼續 Block
source.row 說的不是:
「第 263 列是真理。」
而是:
「你已經抓到這二十個嫌疑犯,我可以告訴你原始 Case 指的是哪一個。」
有投票權。
沒有登基。
Metadata 可以輔助 Identity。
不能因為正好救了你一次,就現場加冕成皇帝。
Row Tiebreak 差不多有方向後,對抗性檢查又翻出另一個東西。
Workbook 有幾列 AffiliatedSTA,肉眼看完全正常,但就是對不上。
我盯了半天。
沒有多一個點。
沒有少一個括號。
大小寫也一樣。
Agent 把 Codepoint 拆出來。
裡面躲了一個:
U+200B ZERO WIDTH SPACE
零寬空白。
這名字取得真好。
它的主要功能就是讓你看不到它。
對人眼:
AffiliatedSTA.{i}
AffiliatedSTA.{i}
「一樣啊?」
對 Python:
不一樣,謝謝。
很好。
前面二十列是東西太多。
這次是東西根本看不到。
Excel 非常公平,各種方向都照顧到了。
所以 normalize_object()、normalize_api() 也開始清 Unicode Cf Format Character。
但 Raw Workbook Value 還是留著。
比對時可以忽略垃圾。
稽核時還是要知道垃圾原本在哪。
不然 System 很貼心地幫你擦掉,再宣布:
Data quality looks good.
那不是 Normalization。
那比較像毀屍滅跡。
既然都看到這裡了,這次 audit init 也順手產:
workbook_lint.json
先列 Duplicate Keys、Invisible Characters、Empty Verdicts。
注意,它只是 Advisory。
因為剛才那二十列已經證明:Duplicate Key 不一定有罪,可能只是 Workbook 的資料模型比你的 Index 更細。
Lint 只負責先說:
這邊怪怪的,你最好知道。
至於是不是 Bug,後面再判。
而Lint ,就是hygiene lint,這次PR的主角擔當,扛屎擦尿。
另外 audit init 原本 stdout 只吐 RID,外部 Script 可以直接:
RID=$(testpilot audit init ...)
所以 Lint Summary 改走 stderr。
不然「只是多印一行」就能讓舊 Script 當場往生。
這句話在自動化工具裡的地位,大概跟恐怖片裡的:
我出去看一下。
差不多。
修完之後,當然要把那 27 個原本 Block 的 Case 重新跑一遍。
然後昨天才做好的 --resume 馬上出來表演什麼叫忠於職守。
我:
幫我重跑這 27 案。
Resume:
已經 Bucketed,Skip。
我:
我就是要重驗。
Resume:
但你昨天不是說做過的不要重跑?
我:
……
靠。
完全符合 Spec。
一點 Bug 都沒有。
問題是今天我要的不是 Resume。
我要的是 Re-evaluate。
所以這次重驗原本 Block 的 Case,反而不能帶 **--resume**。
昨天不重跑是正確。
今天不重跑就是錯。
Tool 沒壞。
是你今天叫它做的事變了。
實機重跑後:
workbook_row_ambiguous
27 → 0
27/27 的 pass1_baseline.json 都留下:
row_resolution = source_row_tiebreak
全套測試:
2026 passed
policy_check:
0 fail
也就是原本被 (object, api) 壓在一起的 Case,現在能 deterministic 地認回各自的 Workbook Candidate Row。
實機途中仍然有既有環境不穩定,但那不是今天要證明的東西。
今天只問一題:
原本 ambiguous 的 Workbook Row,現在到底認不認得回來?
答案是認得。
別順手替它寫成:
整套 Wi-Fi Audit 從此天下太平。
沒有。
先不要。
真正有意思的是下一個結果:
27 / 27 → needs_pass3
沒有一案因為 Row 對準了,就自動變成 Confirmed。
看到這個我反而比較安心。
因為今天只是把題目找對。
系統沒有順便幫我答題。
回頭看這一天。
一開始我以為 Workbook 有 Duplicate,差點想去重。
結果是 Audit 自己把資料粒度拍扁,二十個不同回傳欄位只剩同一張 (object, api) 身分證。
接著發現 Case YAML 早就留了 source.row。
但它也不能因為剛好救場,就突然升格成唯一 Authority。
最後修的其實不是一個「更聰明的猜法」。
而是把它的權力範圍講清楚:
(object, api)
先圈 Candidate
source.row
只負責 Candidate 內 Tiebreak
對不上
就 Block
再把 Unicode Cf 與 Workbook Hygiene 往前搬,至少別每次都等踩雷才知道 Excel 裡住了誰。
27 個 Block 因此歸零。
很好。
但 27 個 Case 全部還在 needs_pass3。
因為找到正確 Workbook Row,只能證明:
現在我們至少在回答同一題。
它不能證明 Workbook 的 Expected 是對的。
更不能證明一條 Case 連跑三次 PASS,就真的有測到它嘴上說要測的東西。
假設 Criteria 寫成:
只要 Output 不是空的
→ PASS
正確值會 PASS。
錯誤值搞不好也 PASS。
然後你跑三次:
PASS
PASS
PASS
穩。
穩得跟沒測一樣。
所以李逵現在是找到了。
下一個問題是:
這個李逵,到底會不會打李鬼?
如果我故意餵它一個錯答案,它會不會真的 FAIL?
還是它只會笑笑跟你說:
Looks good to me.
下一篇,我們不再一直餵正確答案。
換餵錯的。
看它到底會不會咬人。
Have a nice day.