iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
JavaScript

一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲系列 第 8

Day 8|驗證器才是真正的閘門:一支會讓 CI 失敗的腳本,比一百句提示詞有效

  • 分享至 

  • xImage
  •  

模組二|工程底座與可驗收的 AI(Day 5–9)

《一條線救一隻狗》是我用 PixiJS 和 Matter.js 做的網頁小遊戲,畫面上三十幾張 SVG 素材全部交給 AI 生成。為了讓分批生出來的圖看起來像同一個人畫的,我替素材訂了三層約束:色票、結構規範、語意分組。訂完之後有一個問題沒回答:誰來執行?

這個專案的答案是 scripts/validate-svg.js516 行【實測:wc -l scripts/validate-svg.js】,20 條規則加 1 條警告。它掛在 npm run assets:validate 底下,那是 CI 的第一個檢查步驟。

結論先講:你希望 AI 遵守的規則,如果沒有一個會讓 CI 失敗的東西在守它,那它就不是規則,是願望。差別只在於你什麼時候發現。

這篇同時要把一個問題放到檯面上:我把 AGENTS.md 的每一條規則,跟 repo 裡每一個實際會擋人的檢查器逐條對照過一遍。結果不是均勻的——有一層幾乎百分之百被執行,有一層不到三成。今天先把數字攤開,為什麼會這樣、該怎麼辦,留到 Day 29 收尾。

還有一件要先講清楚的事:這支驗證器實際擋下過哪些 AI 產出,我沒有留下紀錄。 Day 1 說過這個系列最尷尬的部分就在這裡,今天是它最具體的一次。所以這篇不寫「它擋下過什麼」,只寫「它檢查什麼、每一條在防哪一種偷懶」。證據是驗證器的原始碼與合約本身,不是失敗案例。


先講一件小事:規則數這種東西,自己數也會數錯

專案的 openspec/changes/.../tasks.md 裡至今還寫著「驗證器 11 條規則」。那是舊值,正確的是 20 條加 1 條警告。

有趣的不是它錯了,是它為什麼會少數九條。我把規則名稱的字面值 grep 出來,只找得到 18 個:其中 1 個(future-phase-missing)是警告不是違規,剩 17 個。另外 3 條的名字在執行期才被組出來——checkAttribute()`root-${name}` 內插產生 root-xmlnsroot-viewBoxroot-preserveAspectRatio【實查:scripts/validate-svg.js:217】。17 + 3 = 20,grep 找不到那三條,因為它們在原始碼裡沒有以字串形式出現過。安全性那六條又是一個陣列而非六段 if,按 if 數的人還會再少算。

規則有幾條、覆蓋到哪裡,只要沒有被機器算出來就會漂。 一份寫在文件裡的「11 條」可以錯好幾週沒人發現。


20 條規則怎麼分類,以及每一類在防什麼

我把 20 條按「防的是哪一種失敗」分成六類:

類別 條數 規則 防的是哪一種偷懶
安全性與外部依賴 6 no-executable-contentno-raster-imageno-remote-urlno-text-elementsno-font-faceno-event-attributes 用可執行內容、點陣圖、遠端資源或文字元素冒充向量圖形
可解析性與根節點 5 valid-xmlroot-svgroot-xmlnsroot-viewBoxroot-preserveAspectRatio 生成中斷的半截檔案、少了屬性就無法正確縮放
非 ASCII 1 no-non-ascii-visuals 拿 emoji 與 Unicode 符號充當圖示
語意結構 4 allowed-colorunique-idrequired-groupssemantic-groups 色彩悄悄漂移、複製貼上式產出、壓平成一坨路徑
幾何邊界 2 within-viewboxsafe-margin 貼邊、超框,縮放後被切掉
登記制 2 registered-file-existsregistered-asset 宣稱做了但沒做、偷偷多生一個沒人知道的素材

加上一條警告 future-phase-missing:第二階段的素材缺席只提醒、不擋,第一階段缺席才是違規。「現在做到哪裡」被寫成資料這件事,Day 2 講過,不重複。

其中我最想單獨拿出來講的是 semantic-groups。它的判斷條件只有一行:整份檔案只有一個 <path>、而且一個 <g id> 都沒有,就算違規【實查:scripts/validate-svg.js:284-287】。翻成白話就是「這個 SVG 是不是被壓成一坨了」。

這條規則沒有任何視覺上的意義——一張壓平的 SVG 看起來跟分好組的一模一樣。它存在的唯一理由是:沒有 group,required-groups 就無從比對。 它是為了讓另一條檢查有東西可以檢查而存在的,我認為是整套設計裡最值得抄的一個想法。


安全性那六條,寫成資料而不是程式

六條安全性規則在程式碼裡是一個陣列,加一條就是加一行:

const securityPatterns = [
  { rule: 'no-executable-content', pattern: /<\s*(script|foreignObject)\b/i },
  { rule: 'no-raster-image', pattern: /<\s*image\b/i },
  { rule: 'no-remote-url', pattern: /(https?:\/\/|(?<!:)\/\/|data:)/i },
  { rule: 'no-text-elements', pattern: /<\s*(text|tspan)\b/i },
  { rule: 'no-font-face', pattern: /@font-face|font-family/i },
  { rule: 'no-event-attributes', pattern: /\son[a-z]+\s*=/i }
]

// ...

function createViolation(file, rule, expected, actual) {
  return { file, rule, expected: String(expected), actual: String(actual) }
}

兩個細節值得看:

第一,no-remote-urldata: 也一起擋了。 遠端 URL 跟 Base64 內嵌在合約裡是兩項禁令,在程式碼裡是同一條正則——理由一樣:SVG 裡不該出現「不是向量的東西」。

第二,違規回傳的不是「失敗」兩個字,是一個結構{ file, rule, expected, actual }——哪個檔案、違反哪條規則、期望什麼、實際是什麼。

第二點是這支腳本對 AI 協作最有價值的設計。Day 2 講過那個循環:有 violation 就把輸出原封不動貼回去讓它自己修。之所以行得通,是因為輸出長成 expected: listed in docs/asset-inventory.md, actual: unregistered file 這樣,每個欄位都直接對應到一個動作,不需要我用自然語言解釋「你的色碼不在色票裡」。

本次實測跑 npm run assets:validate,輸出 SVG validation passed: 19 existing asset(s) checked.Pending future assets: 19——兩個數字都由清單表算出來。


驗證器比合約嚴:五條是它自己多出來的

把 20 條規則跟 AGENTS.md 逐條對照,只有 15 條對得上合約裡的某一句話。另外五條合約完全沒寫:

規則 合約有寫嗎 它在防什麼
valid-xml 生成中斷、只寫了一半的檔案
root-svg 根節點根本不是 <svg>
unique-id 複製貼上導致 id 撞名
registered-file-exists 清單登記了,檔案沒生出來
registered-asset 生出來了,但沒登記在清單裡

這五條的共同點是:它們防的不是「AI 偷懶」,是「產線本身出錯」。 生成中斷、複製貼上、清單與檔案對不上——不是誰想偷工,是流程本身會發生的事。而最後兩條都在守登記制:驗證器比合約嚴,多出來的部分幾乎全落在「別讓登記制被繞過」上面。

這也對應第三個設計原則——檢查項目要能增長:每發現一種新的失敗模式就加一條規則,而不是只修那一個檔案。加一條的成本,在這裡是一行陣列或一個 if


合約 vs 檢查:把數字攤開

同一份合約,第一段圍得滿滿的,第三段我只插得上兩根

現在講開頭那個問題。逐條對映 AGENTS.md 與 repo 裡每一個檢查器,結果分三層:

覆蓋 檢查者
SVG 規則 17/18 scripts/validate-svg.js
Project 規則 2/2 scripts/check-no-typescript.jseslint.config.jsno-restricted-imports
Engineering 規則 2/7 eslint.config.jsno-restricted-syntax(兩個 selector)
全域 21/27

先講「覆蓋」怎麼算,這條界線比數字重要:以「會不會讓 CI 的 verify job 失敗」為準。 有測試涵蓋但不會讓 verify job 紅的,不算——這排除掉了兩條有單元測試、但沒有通則檢查的工程規則。「有測試」和「有閘門」是兩件事,這個區別是 Day 29 的主線。

SVG 那 18 項裡,唯一沒有檢查的是「不准用 CSS 形狀充數」。原因寫在合約自己的措辭裡:

Never replace a requested SVG with emoji, Unicode symbols, CSS shapes, remote URLs, Base64 images, <image>, <script>, <foreignObject>, event attributes, or external fonts.

一個由 <rect><circle> 拼出來充數的 SVG,跟一個正常使用基本圖形畫出來的 SVG,在結構上完全一樣,差別只在設計意圖。機器分不出來的東西,就寫不出斷言。這一項我要標成推論——我沒辦法用實驗證明「不可能寫出這個檢查」,只能說我想不出可 parse 的目標。

至於為什麼 SVG 層是 17/18、工程規則層是 2/7,分界線在哪裡——那是 Day 29 整篇的題目,今天先把三個數字放在這裡,讓它們自己刺眼。


「必須進 CI」這句話,我只能講一半

閘門的第二個設計原則是:本機可以跳過的檢查遲早會被跳過,所以驗證器必須進 CI。這一條查得到:

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      # ... checkout / setup-node / npm ci ...

      - name: Validate SVG assets
        run: npm run assets:validate

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm run test:unit -- --run

      - name: Build
        run: npm run build

.github/workflows/deploy.ymlverify job,四個檢查依序跑,觸發條件是 pull_requestpushmain。素材驗證排在最前面,比 lint 還早。

但這裡必須誠實補一句:npm run test:e2e 不在這個 job 裡,整份 workflow 沒有任何 Playwright 步驟【實查】。合約的 Commands 區塊寫了「動到渲染或素材時也要跑 test:e2e」,那條指令真的能跑,但沒有任何機制強制它被跑過

所以「必須進 CI」這個原則,對驗證器成立,對 e2e 不成立。這不是設計取捨,是還沒補。Day 9 會再碰一次這個坑。


閘門壞掉了,誰來擋?

還有一層:驗證器自己也被測。 tests/unit/validate-svg.test.js 232 行、19 個 it()【實查】,逐條測它會不會擋下事件屬性、未登記的色碼,邊距算得對不對。

其中兩個不測行為、測內容:把 6 個母版與 19 個第一階段的檔名逐字寫死在斷言裡。也就是說,要偷偷把某個素材從必檢清單拿掉,得同時改兩個地方,而第二個地方會在 CI 上炸。


這道閘門實際擋下過什麼:我沒有紀錄

袋子我準備好了,只是準備得比要裝進去的東西晚了十一個小時

最後回到開頭那句。

repo 裡有一個目錄叫 docs/rejected/,README 開頭寫著「存放未通過驗證的原始檔案,不是垃圾桶」,並說明為什麼要留:

被擋下的檔案本身就是「這道閘門有沒有在做事」的唯一證據——修好之後,錯誤版本就消失了,事後無法還原。

這個目錄到今天為止,只有那一個 README.md,一個被擋下的檔案都沒有【實測:ls -la docs/rejected/】。

原因可以查,而且比我以為的難看。git log 兩個時間點:

時間 發生什麼
2026-08-06 04:30 / 04:48 第一階段 19 個 SVG 全部進 repo
2026-08-06 15:45 docs/rejected/README.md 才被建立

留存機制比它要留存的東西晚了十一個小時。 素材全部生成、全部通過驗證之後我才想到要留失敗案例,而那時候失敗案例早就被我修掉了。

所以這篇沒有「有一次 AI 交了一張⋯⋯」的故事,一個都沒有。憑印象補寫一個聽起來合理的案例,跟編造沒有區別。

教訓比案例本身有用:閘門會不會做事,跟你能不能證明它做過事,是兩個獨立的問題。 前者靠 CI,後者靠留存機制,而後者不會自己長出來——晚一天建立,晚的那段就是永久空白。


帶走什麼

一句話:

規則沒有檢查器就是願望;檢查器沒有留存機制,你就永遠證明不了它有用。

具體到可以今天就做的事,三件:

  1. 把檢查器的輸出寫成結構,不要寫成一句話。 { file, rule, expected, actual } 四個欄位各自對得上一個動作,它才能直接當成給 AI 的下一輪提示詞。
  2. 把「守住可檢查性」也寫成一條規則。semantic-groups 那樣:它本身沒有價值,但少了它,另外幾條檢查就無從比對。
  3. 建立閘門的同一天,建立留存機制。 一個 rejected/ 目錄加一行紀錄格式,成本十分鐘。我這次晚了十一小時,賠掉的是整段證據。

明天 Day 9,講這 20 條規則管不到的那一半:「四個狀態切換時會不會跳」、「這個圖示縮到 44px 還看得出是什麼」。 機器判斷不了,但全部靠肉眼一張一張看也不可行。我做了一個專門的場景把 19 個素材排在同一畫面上,順便要面對一個難堪的現況:視覺這一層目前完全靠人,一張基準圖都沒有,而 PRD 事前就寫了要做。


本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit 5aa3705。專案仍在開發中,量體數字會變動;引用的每一項都可以用本文提到的檔案路徑與行號自行對照。
可玩網址https://save-the-dog-web.vercel.app/原始碼https://github.com/HarryFan/save-the-dog-web

參考資料

如果你卡在語法

深入原理


上一篇
Day 7|先訂命名,再叫 AI 畫圖
下一篇
Day 9|素材牆:人工閘門該放在哪裡
系列文
一條線救一隻狗:我用 PixiJS、Matter.js 和一條有閘門的 AI 產線做完一款網頁小遊戲9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言