Day 09 · W2 · AI 線 · 難度 ★★★☆☆
本系列由 AI 協作撰寫。 內容、技術判斷、程式碼由 light-design 數位顧問團隊與 Claude 共同產出,最終由作者驗證後 publish。完整協作模式與把關方式見 Day 01。
Day 4 說過「146 條規則裡,50 條 lint 跑得到」。
那句話漏了一件事:那 50 條不是同一份程式碼跑兩次,是另外寫的 50 個檔案。
規則檔(scan) 146 個 跑在瀏覽器渲染完的 DOM 上
規則檔(lint) 50 個 跑在 tree-sitter 的語法樹(AST)上
碼號交集 50 條 完全重疊,lint 沒有任何獨有規則
一句話主軸:同一句規範,在語法樹上要用完全不同的邏輯判,而且貴得多。
lint 是 scan 的子集。沒有任何一條規則只存在於 lint。 加一條 lint 規則的前提是 scan 已經有那條。
這個約束是刻意的。兩邊共用同一個碼號、同一個 WCAG 準則、同一段描述,差別只在判斷方式。使用者拿到 HM1130103C 這個編號,不管是哪個階段報的,指的都是同一件事。
反過來也一樣重要:lint 不能有自己的規則。 如果讓它加一條 scan 沒有的檢查,同一份程式碼在兩個階段就會給出不一致的清單,使用者會開始問「到底哪個才算數」。編號體系一旦分岔,後面接自評表、接報告、接 CI 全部要處理兩套。
代價是維護成本翻倍。改一條規則的判定方式,要記得兩個檔案都改,而且沒有任何機制會提醒你漏了哪一個。這是目前這個設計最脆的地方。
而「翻倍」還是客氣的說法。同樣那 50 條:
| 總行數 | 倍率 | |
|---|---|---|
| scan 版(DOM) | 1,949 | 1.0 |
| lint 版(語法樹) | 3,815 | 1.96 |
寫第二次,要多寫一倍的程式碼。
我原本以為會反過來。語法樹版少了瀏覽器那一整層,沒有 CSS、沒有 runtime、沒有非同步,聽起來應該更單純才對。結果是兩倍。
原因後面會拆,先講結論:少掉的那一層不是雜訊,是資訊。少了它,程式要自己補上一堆「這種情況我判不了」的分支。

lint 沒有獨有規則,它是 scan 的子集。但同樣的判斷,在語法樹上要多寫近一倍。
差距最大的是 HM1130103C,內容是「表單控制元件要用 <fieldset> 分群,並用 <legend> 提供標題」。
先說清楚語法樹是什麼。lint 不是拿正規表示式在原始碼裡找字串,是先把檔案解析成一棵樹再走。這段 JSX:
<fieldset>
<legend>聯絡方式</legend>
<input />
</fieldset>
解析出來是這樣:
jsx_element
├─ jsx_opening_element name: fieldset
├─ jsx_text "\n " ← 純空白也是一個節點
├─ jsx_element name: legend
├─ jsx_text "\n "
├─ jsx_self_closing_element name: input
└─ jsx_closing_element
「第一個子元素」在這棵樹上是換行符號,不是 <legend>。 前端熟悉的 AST(babel、ESLint 用的那種)通常會把空白跟註解丟掉,tree-sitter 這棵全部保留 —— 所以那些節點得自己跳。
DOM 版的核心邏輯大概長這樣:
children = [c for c in f.find_all(recursive=False) if isinstance(c, Tag)]
if not children: # 空的 fieldset
...
first = children[0]
if first.name.lower() != "legend": # 第一個子元素不是 legend
...
if not first.get_text(strip=True): # legend 沒有文字
...
三個判斷,加上樣板總共 43 行。乾淨得像教科書,因為瀏覽器已經把樹組好了。第一個子元素就是第一個子元素,沒有懸念。
語法樹版 164 行。 多出來的一百多行在處理這些:
<fieldset /> 自閉合,根本沒有 body 可以看
<fieldset {...props}> 有 spread,這個檔案看不出裡面有什麼
<fieldset>{expr}</fieldset> {...} 可以 render 出任何東西
jsx_text 節點 純空白也算一個節點,要跳過
註解節點 也要跳過
HTML 檔 vs JSX 檔 兩條完全不同的解析路徑
還要自己寫兩個 walker:一個取元素名稱,一個取文字內容。DOM 版那兩件事分別是 .name 跟 .get_text()。
解析器用的是 tree-sitter,支援 tsx / jsx / ts / js / html 五種,文法按語言延遲載入再快取。tree-sitter 的一個特性是容錯:遇到寫壞的檔案不會整份放棄,還是吐得出一棵可以走的樹。檢查工具最怕的就是「你的檔案有語法錯誤所以我什麼都不說」。
程式碼註解裡有一句話寫得很白:
// If it has spread, this is most likely a wrapper component
// re-exporting fieldset; we can't tell from this file.
「這個檔案看不出來」是語法樹版的常態,不是例外。
不是寫得夠努力就能補上,是資訊本來就不在那裡。
一、算出來的樣式。 對比度要拿到實際的前景色與背景色。原始碼裡寫的是 class 名稱,真正的顏色來自幾層 CSS 疊加、可能還有變數,而變數本身可能又被深色模式覆寫一次。語法樹看到的是 class="text-muted" 這串字,它連那個 class 存不存在都不知道;那要去翻樣式表,而樣式表在另一個檔案,甚至可能是打包後才生成的。
二、跨檔案的事件綁定。 <div onClick={handleClick}> 那個 handleClick 可能在另一個檔案、可能是從 props 傳進來的、可能被高階元件包過一層。單一檔案的語法樹追不到。
三、runtime 才組出來的樹。 條件渲染、列表展開、非同步載入的內容 —— 那些在原始碼裡是表達式,在 DOM 裡才是元素。
舉個第二類的實例。一個 <button> 綁了 onClick,看起來有互動;但真正決定它能不能用鍵盤操作的,是那個 handler 有沒有同時處理 Enter 與 Space、以及元件本身有沒有正確的 tabindex。這些線索散在三個地方:這個檔案、handler 所在的檔案、還有可能包在外面的高階元件。語法樹只拿得到第一個。
Day 5 那個 <input type="text" placeholder="姓名"> 之所以只有 scan 判得動,就是第一類跟第三類混在一起的結果。
判不準的東西如果照樣報 fail,就是製造誤報。但直接不報,使用者又不知道有這件事。
所以規則的中繼資料裡多了一個布林值 runtime_authoritative。標了的規則,lint 執行時 fail 會自動降級成 caveat,並且在訊息後面接一句說明。
那段程式碼的註解寫得比我能寫的清楚:
AST alone cannot prove the violation; the finding stays surfaced as
caveat ("needs review") rather than fail ("must fix").
146 條規則裡,只有 2 條掛了這個旗標。 而其中一條,Day 4 的讀者已經看過了:
CAVEAT GN1210100E WCAG 2.1.1 L8
<div> 使用 onclick 但未提供鍵盤等效
(lint 無法跨檔/runtime 確認,請人工或 a11y-moda scan 驗證)
那句括號裡的話不是我手寫上去的,是降級機制自動接的。D4 看到的是症狀,這裡是機制。
只有 2 條聽起來很少,但那正是設計目的:這個旗標是「我確定我判不準」的宣告,不是「我不太確定」的保險。 用得太浮濫,caveat 就會變成另一種噪音。

三類都不是「分析得不夠深」,是那個資訊在原始碼階段根本不存在。
因為快,而且不用瀏覽器。
Day 4 實測過:lint 掃一個八行的檔案不到一秒,安裝只要二十秒,不用下載瀏覽器、不用連網路。這三件事加起來只有一個意思:它進得了 pre-commit 跟 pull request 檢查。
一個要開瀏覽器、要等頁面渲染、要跑幾秒的檢查,放進每次 commit 的流程會被關掉。不是因為開發者不在乎,是因為每天要 commit 十幾次,每次多等五秒就是一分鐘,而且那一分鐘還會打斷思路。放進 CI 的話時間成本消失了,但回饋延遲了:開發者看到結果的時候,程式碼已經推上去、可能還已經被別人拉下來了。
兩棵樹對應的是兩個時機,不是兩種精準度:
語法樹 寫程式的當下 擋明顯的錯,錯了當場知道
DOM 部署後 / 每天跑 算得準,但你已經推上去了
第一類抓不到對比度,但它抓得到 <img> 沒有 alt。而後者在真實專案裡的數量遠多於前者。
換個角度說:檢查的價值 = 準確度 × 被執行的次數。 一個很準但一天只跑一次的檢查,跟一個沒那麼準但每次存檔都跑的檢查,後者攔下來的問題通常更多。前提是它不能亂報 —— 一亂報就會被關掉,那時準確度再高也是零。
96 條只有 scan 版。判準只有一句:在原始碼階段猜的準確率,會不會低到製造噪音。
<img> 沒有 alt 這種,語法樹上一眼就看得出來,寫第二版划算。對比度那種,語法樹只能看到 class 名稱,硬要判就是在猜 —— 猜錯的成本是使用者從此不信任這個工具。
判準落到實作上很簡單:這條規則需要的資訊,在單一檔案的語法樹裡拿不拿得到。 拿得到就寫,拿得半套就掛 runtime_authoritative,完全拿不到就只留 scan 版。
所以那 96 條不是「還沒寫」,是判斷過之後決定不寫。這跟 Day 6 那 102 條的性質一樣:看起來像待辦,其實是設計上就沒有納入的部分。
順帶一提,這也是為什麼 lint 的主題分布跟 scan 完全不同。scan 那邊最多的是 responsive 跟 forms,lint 這邊最多的是 keyboard 跟 navigation —— 前者要量畫面,後者看標籤結構就有八成把握。
runtime_authoritative 只掛在 2 條規則上,因為它是「我確定我判不準」的宣告,不是保險明天 Day 10:白底黑字穩過 4.5:1。那漸層背景上的白字呢?公式要吃哪一個顏色?