模組二|工程底座與可驗收的 AI(Day 5–9)
《一條線救一隻狗》是我用 PixiJS 和 Matter.js 做的網頁小遊戲,畫面上三十幾張 SVG 素材全部交給 AI 生成。為了讓分批生出來的圖看起來像同一個人畫的,我替素材訂了三層約束:色票、結構規範、語意分組。訂完之後有一個問題沒回答:誰來執行?
這個專案的答案是 scripts/validate-svg.js,516 行【實測: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-xmlns、root-viewBox、root-preserveAspectRatio【實查:scripts/validate-svg.js:217】。17 + 3 = 20,grep 找不到那三條,因為它們在原始碼裡沒有以字串形式出現過。安全性那六條又是一個陣列而非六段 if,按 if 數的人還會再少算。
規則有幾條、覆蓋到哪裡,只要沒有被機器算出來就會漂。 一份寫在文件裡的「11 條」可以錯好幾週沒人發現。
我把 20 條按「防的是哪一種失敗」分成六類:
| 類別 | 條數 | 規則 | 防的是哪一種偷懶 |
|---|---|---|---|
| 安全性與外部依賴 | 6 | no-executable-content、no-raster-image、no-remote-url、no-text-elements、no-font-face、no-event-attributes |
用可執行內容、點陣圖、遠端資源或文字元素冒充向量圖形 |
| 可解析性與根節點 | 5 | valid-xml、root-svg、root-xmlns、root-viewBox、root-preserveAspectRatio |
生成中斷的半截檔案、少了屬性就無法正確縮放 |
| 非 ASCII | 1 | no-non-ascii-visuals |
拿 emoji 與 Unicode 符號充當圖示 |
| 語意結構 | 4 | allowed-color、unique-id、required-groups、semantic-groups |
色彩悄悄漂移、複製貼上式產出、壓平成一坨路徑 |
| 幾何邊界 | 2 | within-viewbox、safe-margin |
貼邊、超框,縮放後被切掉 |
| 登記制 | 2 | registered-file-exists、registered-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-url 把 data: 也一起擋了。 遠端 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。

現在講開頭那個問題。逐條對映 AGENTS.md 與 repo 裡每一個檢查器,結果分三層:
| 層 | 覆蓋 | 檢查者 |
|---|---|---|
| SVG 規則 | 17/18 | scripts/validate-svg.js |
| Project 規則 | 2/2 | scripts/check-no-typescript.js、eslint.config.js 的 no-restricted-imports |
| Engineering 規則 | 2/7 | eslint.config.js 的 no-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。這一條查得到:
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.yml 的 verify job,四個檢查依序跑,觸發條件是 pull_request 與 push 到 main。素材驗證排在最前面,比 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,後者靠留存機制,而後者不會自己長出來——晚一天建立,晚的那段就是永久空白。
一句話:
規則沒有檢查器就是願望;檢查器沒有留存機制,你就永遠證明不了它有用。
具體到可以今天就做的事,三件:
{ file, rule, expected, actual } 四個欄位各自對得上一個動作,它才能直接當成給 AI 的下一輪提示詞。semantic-groups 那樣:它本身沒有價值,但少了它,另外幾條檢查就無從比對。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
如果你卡在語法
深入原理