同一段 CSS 寫在註解裡零命中,當實碼就命中

一條 AI code review 的 Major finding 指控掃描器把註解當程式碼掃。它讀起來完全合理, 呼叫端的變數名也支持它。用一次乾淨安裝做對照實驗,十分鐘就推翻了——順帶發現同一個 版本號底下躺著兩份不同的程式碼。

我們用一個模型去 review 另一個模型改出來的東西。這一輪回來三條 finding,其中一條標成 Major:

掃描器把未剝除註解的原始內容餵給偵測函式,因此註解裡的 CSS 也會被當成實碼命中,產生誤報。

這條指控有兩個特徵,讓它比一般的 finding 更難反駁。

第一,它具體。它指名了檔案、指名了函式、指名了資料流的方向。空泛的 finding 好處理,具體的不好處理。

第二,它自帶證據。呼叫端長這樣:

findings.push(...scanInsetStripeCss(content, filePath))

而被呼叫的那個函式,簽章的第一個參數叫 rawContent。

raw。原始的、未處理的。一個模型讀到這裡,把「呼叫端傳進去的東西沒剝過註解」寫成 Major finding,推理鏈是通順的。

不要用讀 code 來反駁讀 code

當下的直覺反應是回去讀那段程式碼,確認它到底有沒有剝註解。這個反應不算錯,但它有個問題:你跟 review 用的是同一種證據來源。它讀錯了一次,你不能保證自己不會用另一種方式讀錯第二次。兩份推理互相對撞,贏的往往是講得比較有自信的那一份。

講到這裡你可能會想:直接回它一句「你確定嗎」不就好了?別這樣做。你得到的多半是它退讓(「您說得對,我重新確認……」)或加倍堅持,而兩種回應都不含新證據——它手上的資料跟剛才一模一樣,變的只有語氣。用追問來驗證一個模型的說法,等於用它的自信程度當證據。

比較便宜也比較硬的做法是做一次對照實驗:把指控翻譯成一個可證偽的預測,然後只改一個變因。

這條 finding 的預測很清楚——同一段會觸發偵測的 CSS,包在 /* */ 裡面應該照樣命中。

於是:

  1. 抓一份乾淨安裝的掃描器(不是我們本機那份,避免本地改動干擾)
  2. 準備一段確定會命中的 CSS——這裡用的是會被判定成 side-tab 的 ::before 直條
  3. 跑兩次:一次把那段 CSS 當實碼,一次原封不動包進 /* */
  4. 兩次之間只有註解符號這一個差異

結果:

當實碼   → 命中 side-tab
包在註解 → 0 findings

指控的預測沒有發生。到這裡,這條 Major finding 已經出局了,而整個過程不需要對那段程式碼有任何理解。

打開被呼叫的那個函式

推翻之後才有必要問「那為什麼」。答案在被呼叫的函式的第一行:

function blankCssComments(css) { /* … */ }

function scanInsetStripeCss(rawContent, filePath, lineOffset = 0) {
  const content = blankCssComments(rawContent)
  // …
}

(scripts/detector/engines/regex/detect-text.mjs,第 785 行起。)

參數名叫 rawContent 不是疏忽,是契約宣告:這個函式要求你傳原文進來,剝註解是它自己的責任。同一支檔案裡負責另一種偵測的 scanCssTextForPseudoStripe 也是同樣的設計。

所以呼叫端傳原文進去不是 bug,那正是這個 API 期待的用法。

review 讀到了呼叫端,沒有打開被呼叫的函式。而那個參數名——本來是要告訴讀者「剝註解已經被處理掉了」——反而變成了誤判的證據。

這類誤報的形狀

值得記下來的不是這一條 finding,是它的形狀:

一條指控,如果它的證據全部來自呼叫端,那它就還沒有被驗證過。

跨模型 review 特別容易停在呼叫端,原因不難理解:它拿到的 context 比你少,而它被要求在有限的視窗內給出一份讀得下去的結論。順著資料流往下多打開一層,成本是實實在在的;不打開而寫出一條讀起來合理的 finding,成本接近零。

我們之前寫過讓 AI 寫程式時,真正會出事的四個地方,其中一條是「把搜尋不到當成不存在的證明」——那講的是負向結論的取證問題。這一條是它的鏡像:正向指控同樣需要取證,而且更難反駁,因為正向指控總是附著一段看起來成立的推理。

兩者的解法是同一個:在相信一個結論之前,先把它翻譯成一個只改一個變因就能證偽的實驗。

至於這條 finding 為什麼值得花十分鐘去驗——如果當時直接照著修,我們會在一個本來就正確的函式外面再包一層剝註解,讓下一個讀這段程式碼的人以為剝了兩次是有原因的。誤報的成本不是它浪費的十分鐘,是它留下的那層永遠不會被拿掉的補丁。

副產物:同一個版本號,兩份程式碼

做對照實驗需要一份乾淨安裝,於是我們順手把它跟本機那份 diff 了一下。

兩邊的版本號都是 4.1.1。內容差 219 行。

上游用同一個版本號重新發布過。補上的那 219 行是 monorepo 根目錄辨識、workspace pattern 分組,以及 Windows / Node 24 的 fetch dispatcher 修正。差異是單向的(我們這份只有少、沒有多),所以確定不是本地改動造成的漂移。

更值得記的是為什麼它一直沒更新:安裝指令對已經存在的目錄會整支跳過,不比對、不報錯、也不提示有新版。

# 已存在 → 靜默跳過,你會以為自己是最新的
npx skills add <name>

# 要更新必須先刪
rm -rf .claude/skills/<name> && npx skills add <name>

驗證方式也很直接:重裝之後,對一份剛抓下來的乾淨上游安裝跑 diff -rq,零差異才算數。

收束

這兩件事看起來無關,共同結構是同一個:

你以為在看的實際上在看的
一條經過驗證的 review finding一段只讀了呼叫端的推理
版本號 4.1.1同一個版本號底下的舊快照

標籤跟實體之間沒有任何保證。名字、版本號、措辭聽起來多確定都一樣——唯一能把兩者接起來的,是當場做一次只改一個變因的對照。

所以下次收到一條寫得很有把握的 finding,先別看它的理由寫得多完整,先問一句:**它打開過被呼叫的那個函式嗎?**答不出來,那條 finding 就還只是一個假設。