檢查規則的作用域要顯式列舉:零 error 可能是沒被檢查
論述基礎與限制
本卡的論述基於 blog 工具鏈的一次事故:mdtools 的卡片層 frontmatter 檢查(要求 title / date / description / weight)從未涵蓋 content/report/,該目錄累積至 199 篇卡片、其中 8 篇缺 weight,期間沒有任何一次 commit 被 pre-commit 攔下。症狀最終在完全不同的地方浮現:Hugo 的列表排序把那 8 篇沉到頁面底部。具體限制:
- 單一 case 觀察。作用域耦合的具體形態(路徑常數被多個檢查共用)是這個工具鏈的實作選擇;其他工具鏈用 glob、frontmatter tag 或獨立 config 檔分派作用域,耦合形態不同。
- 「作用域是獨立 fact」的結論不依賴耦合形態,但「先讓規則報錯再修違規」的驗收前提是規則能局部執行;需要全域重建索引才能跑的規則,這個驗收的成本較高。
- 本卡談的是規則涵蓋範圍,不談規則內容的正確性。一條寫錯的規則涵蓋了正確的目錄,是另一類問題。
核心原則
檢查規則承載兩個獨立的 fact:規則說什麼、規則管哪些檔案。 前者被審視、被討論、被寫進規範文件;後者被編碼成一個路徑常數,然後從此沒有任何測試宣稱過「這個常數涵蓋了所有該被檢查的目錄」。
規則沒涵蓋到的目錄不會報錯。於是「零 error」有兩個成因,工具鏈不區分它們:
| 零 error 的成因 | 實際狀態 | 工具鏈的輸出 |
|---|---|---|
| 目錄通過檢查 | 合規 | 靜默 |
| 目錄不在作用域 | 未知,可能已違規 | 靜默 |
CI 綠燈、pre-commit 放行、開發者看見的都是同一個訊號。第二種狀態下違規會持續累積,累積速度等於該目錄的內容產出速度,而且沒有上限 —— 沒有任何機制會讓它自己現形。它最終浮現在某個消費該欄位的下游:排序、索引、渲染。違規本身始終不出聲。
作用域常數被多個檢查共用時,還有第二層問題:擴充作用域的動作會連帶擴充語意。 一個標記「這是知識卡系統的根目錄」的常數,同時決定了 frontmatter schema 分層、orphan 偵測、以及結構段落的鄰卡連結檢查。把一個只需要 schema 分層的目錄加進去,就同時領到了另外兩個它不該受的檢查。這讓「擴作用域」看起來很貴,於是它不被執行 —— 耦合本身在保護違規。
修法是把兩個問題拆成兩個常數:一個回答「哪些路徑套用這組 schema」,一個回答「哪些路徑屬於這個系統」。它們在初期恰好相等,這正是耦合能成立的原因,也正是它遲早會斷的原因。
驗收有順序要求:擴完作用域後,先確認新規則對既有的已知違規報錯,再去修違規。 反過來做(先修違規、再擴作用域)最後看到的一樣是零 error,但那個零同時相容於「規則生效」與「作用域仍然寫錯」。零 error 本身不帶資訊,除非它是從非零變過來的。
第二種形態:偵測視窗也是一個沒被審視的 fact
作用域不是唯一那個被編碼成常數就不再被檢驗的 fact。偵測規則還編碼了「證據會出現在哪裡」——掃描的視窗多大、往哪個方向看、算不算所屬章節的標題。這個參數跟路徑常數一樣,寫下的當時是個假設,寫下之後就被當成定義。
實例是一次掃描「表格含財務數字而未標年度」的工作。掃描的視窗設成表格本身加上往回三行,理由是「期間通常寫在表格前面」——這句話從未被對照真實樣本檢查過。逐一開檔確認的結果是三十個命中裡有十七個是假陽性:期間標示大量寫在表格下方的資料來源行(「資料來源:⋯2025 年度財報」),或寫在所屬章節的標題裡(「## 三家股東的財務比較(2025 FY)」,剛好落在三行視窗之外一行)。
兩種形態的錯誤方向相反,而這正是它容易被忽略的原因。作用域缺漏產生假陰性:沒被檢查的目錄靜默通過。偵測視窗錯誤產生假陽性:一批其實合規的位置被報成違規。假陰性的症狀是「怎麼都沒事」,假陽性的症狀是「怎麼這麼多事」——後者看起來像工具很認真在工作,因此更不會引發對工具本身的懷疑。
代價不只是白工。未經驗證的偵測器產出的數量,會被當成量測結果寫進規劃文件。 上述案例把「約五十處、散在三十七篇」寫進了待辦清單,那個數字來自未驗證的視窗加上五個樣本的外插,而實際可修的是八處。待辦清單上的數字看起來與實際量測過的數字沒有差別。
懷疑的分配是不對稱的,而不對稱的方向可以預測:同一條管線上,你不信任的元件會被抽驗,你自己寫的那一段不會。那次工作裡,交給低階模型做的分類被抽驗了十二項(結果是九項一致),而自己寫的掃描沒有被抽驗——它的誤報率其實高出一個量級。假設之所以沒被審視,正是因為它是自己下的。
操作上多一步:偵測器的輸出在被當成數量使用之前,先手動核對一個樣本,而且核對的重點放在那個編碼了「證據在哪裡」的參數上。核對的成本是打開幾個檔案,而它同時驗證了兩件事——規則說得對不對,以及規則找的地方對不對。
具體 case
case 1:一個常數,三個責任
mdtools 的 Cards.CardsRoot 是單一字串 "content/backend/knowledge-cards",被三處消費:frontmatter 檢查用它判定「這個檔案是不是卡片、要不要套最嚴格的必填欄位」、orphan 偵測用它決定掃描根、結構檢查用它尋找「概念位置」段落的鄰卡連結。
content/report/ 需要第一項(它的 weight 決定 Hugo 列表順序,缺漏會靜默沉底),不需要後兩項(report 卡片的結構是「論述基礎與限制 / 情境 / 理想做法」,沒有概念位置段)。單一常數無法表達這個差異。
case 2:直覺修法會製造假陽性
事故發現後最直接的修法是把 content/report 加進 CardsRoot。實際執行會讓 orphan 與結構檢查一併套上去,對 199 篇沒有概念位置段的卡片各報一次錯。這個結果會讓修法看起來是錯的、被回退,而真正的缺陷(frontmatter 從未被檢查)繼續存在。
正解是新增一個獨立的 FrontMatter.CardPaths,只承擔「哪些路徑套卡片層 schema」這一個責任,CardsRoot 留給知識卡系統本身。拆完之後,把 report 目錄加進前者是安全的,加進後者仍然是錯的 —— 兩個判斷從此可以分開做。
case 3:先讓它報錯
擴完作用域、重建工具、對 content/report/ 執行檢查,輸出是 8 個 missing required field: weight。這一步確認的是作用域真的把這個目錄納管了。找出那 8 個檔案本身沒有價值 —— 一行 grep 就做得到。
補完 8 個 weight 後再跑一次,輸出歸零。這時的零帶有資訊:它是從 8 變成 0 的。若順序顛倒,先補 weight 再擴作用域,得到的零與「作用域常數打錯字、整個目錄仍在管轄外」的零無法區分。
沒這樣做的麻煩
違規以內容產出的速度累積
未被納管的目錄沒有攔截點。每一篇新內容都是一次擲骰,作者記得填欄位就合規、忘記就沉默地欠一筆。這個目錄累積到 199 篇時欠了 8 筆,比例不高正是因為作者多數時候記得 —— 而「多數時候記得」正是工具鏈存在的理由被架空的樣子。
症狀在遠離根因的地方浮現
缺 weight 不會表現成「缺 weight」。它表現成「Hugo 列表某一段順序錯亂」,而且錯亂的形態(那批檔案沉到全部之後、彼此再按日期倒排)需要理解 Hugo 的預設排序規則才能反推回 frontmatter。診斷路徑比缺陷本身長得多。
耦合讓正確的修法看起來很貴
當擴作用域會順帶引入數百個假陽性,維護者的合理反應是不擴。耦合把「這個目錄該不該受檢查」的判斷,偷換成「這個目錄能不能承受那三種檢查」的判斷。前者的答案是肯定的,後者的答案是否定的,於是正確的事情不被做。
跟其他抽象層原則的關係
- #93 URL slug 必須顯式定義為 fact:同構。slug 從檔名推導、作用域從常數推導,兩者都是「看起來不必宣告、實際上是需要被審視的事實」。#93 的修法是把推導值升格成顯式欄位,本卡的修法是把隱含的涵蓋範圍升格成顯式列舉。差別在 slug 錯了會壞連結(可見),作用域錯了不改變任何可觀察行為(不可見)—— 本卡的失效更晚被發現。
- #139 新增頂層 content 資料夾要同步首頁入口:本卡是 #139 在工具鏈維度的對偶。#139 管的是新目錄要在人類導覽層註冊,本卡管的是新目錄要在檢查層註冊。兩者的失效方式相同:目錄本身完全正常運作,只是某個註冊點沒跟上,而註冊點的缺席不產生錯誤訊號。
- #96 適用範圍要展開成 file enumeration:sibling。#96 談人執行任務時的口語範圍描述(「所有教學文件」執行時要心算具體檔案),本卡談工具執行檢查時的編碼作用域。兩者共享「範圍必須被列舉、不能靠推導」的結論,但 #96 的漏判由執行者的心算失誤造成,本卡的漏判由常數的沉默造成 —— 後者沒有執行者可以更謹慎。
- #44 Single Source of Truth:值的住址只能有一處:case 1 的常數共用是 SSoT 的反向失效。SSoT 要求同一個值只存在一處,但這裡是一處存了兩個值(「schema 適用範圍」與「系統成員範圍」)恰好相等。#44 防的是同義值散落多處導致不同步,本卡防的是異義值擠在一處導致不可分別演化。
- #250 資料多出一種形狀時,既有分析邏輯靜默換語意:本卡在分析層的同構。這裡是檢查工具的納管範圍被編碼進路徑常數,那裡是分析邏輯的涵蓋範圍被編碼進條件式;兩者的失效訊號完全相同——零 error 與零命中都無法與「沒被涵蓋」區分。差別在觸發時機:本卡的作用域從一開始就沒涵蓋,#250 的涵蓋範圍是在資料模型變更那一刻失效的,因此它還多一個「變更當下沒有人做錯事」的條件。
- #251 清單過時的代價要落在精度、不落在覆蓋:本卡的常數過時家族成員。這裡的常數是「規則管哪些檔案」,那裡的常數是「判定認得哪些對象」,兩者都在寫下之後不再被審視,而過時的表現也同構——通過的數量增加(一個是零 error、一個是零命中)。#251 補的是本卡沒處理的一種修法:當範圍註定會過時而無法靠列舉窮盡時,加一條獨立於清單的退回路徑,讓過時的代價從覆蓋轉移到精度。
- #252 配額耗盡的症狀落在申請最頻繁的元件、成因在持有最久的那個:本卡在監控層的形態,補的是本卡沒涵蓋的一個 surface。本卡的作用域至少有一個住址(那個路徑常數),問題出在沒有人去審視它涵蓋得齊不齊;監控的涵蓋面散在各個儀表板上、連一個可以打開來核對的住址都沒有,因此「哪些資源有被量測」這個問題連查的起點都要自己建;於是「所有指標正常」與「沒有量測這一項」給出同一個訊號,而後者連一份可以打開來核對的清單都沒有。#252 的診斷順序(先量配額、再查持有者紀錄)等於在故障當下臨時補一次涵蓋面檢查,可視為本卡的作用域驗收在無清單可查時的替代做法。
- #277 通過關卡不等於通過的是同一個程式:同一形態在驗收條件上。「規則存在不等於規則涵蓋」對應「條件通過不等於條件問過」,兩者的零錯誤訊號都與真正合規無法區分。
- #278 機械約束買到被量測的那個數字:相鄰的另一種失效。本卡是規則沒涵蓋到,#278 是規則涵蓋到了但被最便宜的方式滿足;作用域列舉完之後還要問達成路徑有幾條。
判讀徵兆
| 徵兆 | 該做的行動 |
|---|---|
| 新增了與既有受檢目錄同類的內容目錄 | 檢查作用域常數,確認新目錄在列舉裡 |
| 某個目錄從未在 lint 輸出裡出現過(無論 error 或 ok) | 它可能不在作用域內,用一個已知違規測試看它報不報錯 |
| 檢查規則的作用域是單一字串常數、且被多處消費 | 拆責任 —— 每個檢查宣告自己的作用域 |
| 下游(排序、索引、渲染)出現無法用內容解釋的異常 | 往上游追 frontmatter 必填欄位,再追該目錄是否受檢 |
| 擴作用域的提案因為「會噴一堆錯」被擱置 | 那些錯是耦合的證據,先拆常數再擴 |
前兩項是新增內容時的例行檢查,成本近乎為零,跳過的代價是這張卡描述的整個事故。第三項是靜態可讀的結構訊號,不需要等到事故發生。
第四項是這次事故實際被發現的路徑,也是最貴的一條 —— 從「列表順序不對」推回「frontmatter 缺欄位」再推回「這個目錄從未被檢查」需要三段診斷,中間任何一段停手都會得到一個看似合理但錯誤的結論(例如把它當成 Hugo 排序設定問題)。
第五項是耦合正在保護違規的直接證據。「會噴一堆錯」若來自新目錄真的違規,那正是擴作用域的理由;若來自新目錄被套上了它不該受的檢查,問題在常數不在目錄。這兩者從錯誤數量看不出差別,要讀錯誤的種類。
- 掃描或偵測規則裡有一個表示範圍的數字(往回幾行、前後幾個字元、多少天內),而它的來源是「通常都這樣」而非對照過樣本。
- 偵測器的命中數被寫進待辦或規劃文件,而沒有人手動核對過其中任何一個命中。
- 同一條管線上有一段是外部元件、一段是自己寫的,而只有外部那段被抽驗過。
適用範圍與邊界
- 適用:任何用路徑或 pattern 決定作用域的自動檢查 —— linter、formatter、pre-commit hook、CI 的 changed-file 過濾、測試覆蓋率門檻的排除清單。
- 邊界:
- 刻意豁免的目錄不適用:作用域可以有意排除某些路徑(本 repo 的
.claude/skills/就刻意跳過 Hugo 導向的 lint 規則)。豁免與遺漏的差別在於前者有記錄。本卡管的是沒人決定過的沉默。 - 規則本身在演化時,作用域可以落後:新規則先在小範圍試行、確認訊噪比之後再擴,是合理的節奏。這與「常數從第一天就寫死、之後沒人回頭看」不同。
- 零 error 不必然可疑:目錄確實合規時零 error 是正確的輸出。本卡要求的是能區分兩種零,不是懷疑所有的零。
- 刻意豁免的目錄不適用:作用域可以有意排除某些路徑(本 repo 的
Self-case:本卡的觸發來源
觸發於 /report/ 列表排序異常的診斷。表面症狀是兩篇新卡片沒出現、部分卡片順序錯亂,兩個症狀分屬不同根因(前者是 Hugo 的 timeZone 未設定導致當日文章被判為未來文章、後者是 8 篇缺 weight 被 Hugo 的預設排序沉底)。
真正值得立卡的是第三層:那 8 篇為何能長期缺欄位。mdtools 的 CardRequired 早已把 weight 列為卡片必填,規則存在、且正確 —— 缺的是它從未涵蓋 content/report/。規則存在與規則涵蓋是兩個獨立的 fact,而只有前者被寫進規範文件、被討論、被記得。