模組六|測試、效能與上線(Day 26–30)
昨天那三個 404 裡,第二個藏在 index.html 第 5 行——一條寫死的 favicon 路徑。同樣的事在 src/ 底下寫不出來,因為那裡有 ESLint 規則會擋。今天把這個對比講完,再把 Day 8 欠的帳結掉。
Day 8 把 AGENTS.md 逐條對映到 repo 裡每個會擋人的檢查器,攤開三個數字:SVG 規則 17/18、Project 規則 2/2、工程規則 2/7,全域 21/27。當時沒回答為什麼這麼不均勻。
結論先講:一條規則能不能被機器執行,跟它重不重要無關,只跟它有沒有可以被程式解析的目標有關。這個專案裡最該被守住的幾條,正好都沒有這種目標。
分子怎麼算,比分子是多少更需要先講清楚。這系列給「覆蓋」的定義很窄:
有東西會讓
.github/workflows/deploy.yml的 verify job 失敗。
verify job 只跑四個命令:assets:validate、lint、test:unit、build,Playwright 不在裡面(Day 26 講過)。所以有兩條工程規則明明有測試在測,仍不計入分子:
| 規則 | 誰在測 | 為什麼不算覆蓋 |
|---|---|---|
遊戲邏輯用 750×1334 座標系(AGENTS.md:54) |
tests/e2e/smoke.spec.js 有斷言 |
e2e 不在 verify job |
Matter → Pixi 單向同步(AGENTS.md:55) |
tests/unit/RenderSyncSystem.test.js |
測的是那個檔案的行為,不是通則 |
第二列值得多看一眼。RenderSyncSystem.test.js 證明的是「這個檔案沒有把資料倒著寫回去」,不是「整個 codebase 沒有人倒著寫」。行為測試守的是它點名的檔案,通則檢查守的是 glob 涵蓋的全部。
| 合約層 | 位置 | 條數 | 有機器執行 | 執行者 |
|---|---|---|---|---|
| SVG 規則 | AGENTS.md:36-48 |
18 | 17 | scripts/validate-svg.js |
| Project 規則 | AGENTS.md:16 |
2 | 2 | check-no-typescript.js、ESLint no-restricted-imports |
| 工程規則 | AGENTS.md:52-58 |
7 | 2 | ESLint no-restricted-syntax |
| 合計 | — | 27 | 21 | — |
三層的覆蓋率是 94%、100%、29%。中間那層滿分,最後那層不到三成。
SVG 那 18 條全部針對同一個東西——一份可以被 XML parser 讀開的檔案。 「viewBox 是不是這個值」「有沒有 <script>」「所有 fill 在不在色票裡」,每一句都能翻成一次遍歷加一個比較。
Project 那兩條滿分的理由更露骨:「不引入 TypeScript」變成 /\.(ts|tsx)$/ 掃檔名(scripts/check-no-typescript.js:21),「不引入前端框架」變成一份套件名清單(eslint.config.js:22)。文件上是價值判斷,程式裡是字串比對。
AGENTS.md 的 ## Engineering Rules 那章七條,兩條有人守。原文:
- Use JavaScript with ES modules.
- Runtime asset URLs must be composed with `import.meta.env.BASE_URL` through `assetUrl()` from `src/config/paths.js`.
- Game logic uses the 750 x 1334 internal coordinate system from `PRD.md`.
- PixiJS handles rendering and Matter.js handles physics; keep synchronization one-way from Matter bodies to Pixi display objects.
- Event listeners must have matching cleanup logic.
- Random gameplay behavior must use the project seeded random utility once it exists.
- Keep foundation changes free of gameplay initialization until the render foundation change.
有覆蓋的是第二條與第六條。它們跟另外五條的差別不在重要性,在於各自可以塌縮成程式碼裡的單一節點:
// eslint.config.js:54-70
{
files: ['src/**/*.js'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
"Literal[value=/^\\/assets\\//], TemplateElement[value.raw=/^\\/assets\\//]",
message:
'Use assetUrl() from src/config/paths.js instead of hard-coded /assets/ paths.'
},
{
// Catches both `Math.random()` and aliasing such as `const r = Math.random`.
selector:
"MemberExpression[object.name='Math'][property.name='random']",
message:
'Gameplay randomness must come from createRandom() in src/utils/seededRandom.js so simulations stay reproducible.'
}
]
}
}
一個是「以 /assets/ 開頭的字串字面值」,一個是「Math.random 這個成員存取」。兩條各自對應到 AST 上的一種節點,寫得出選擇器就擋得住。
剩下五條沒有這種節點。「每個 listener 都要有對應的清理」要成立,得先知道這個 on() 對應到哪一個 off(),而那兩行常常不在同一個檔案、甚至不在同一個生命週期。這是跨檔案的語意,不是節點。
順便把昨天那個問題答完:no-restricted-syntax 的 files 寫的是 src/**/*.js,而 index.html 不是 .js,ESLint 根本不會讀它。一條規則的覆蓋範圍等於它的 glob,不等於它的意圖。 我寫那個 glob 時想的是「遊戲程式在這裡」,不是「路徑會出現在這裡」,兩者差了一個 index.html。

把七條工程規則的執行者欄位填出來:
| # | 規則 | 執行者 |
|---|---|---|
| E1 | 使用 ES modules | 無。sourceType: 'module' 是 parser 選項不是規則 |
| E2 | 素材 URL 一律經 assetUrl() |
ESLint 選擇器 ✅ |
| E3 | 遊戲邏輯用 750×1334 座標系 | 只有 e2e |
| E4 | Matter → Pixi 單向同步 | 只有單一檔案的行為測試 |
| E5 | 每個 listener 都要有清理 | 無 |
| E6 | 隨機走 seeded random | ESLint 選擇器 ✅ |
| E7 | foundation 變更不含 gameplay 初始化 | 無 |
把 E5 跟 SVG 那層的任何一條放在一起看:
| 規則 | 有檢查嗎 | 出事的話會怎樣 |
|---|---|---|
| 不准用 emoji 當圖示 | ✅ 一行正規表達式(no-non-ascii-visuals) |
圖示跟其他素材不一樣 |
| 每個 listener 都要有清理 | ❌ | 玩久了記憶體一直長 |
下面那條重要得多,也正是後來花掉我最多時間的地方——Day 23 整篇處理的就是它。上面那條寫一行正則就守住了。
排序不是我做的,是「有沒有可 parse 的目標」做的。 我沒有選擇讓 emoji 那條有檢查、讓 listener 那條沒有;我只是寫得出前者、寫不出後者。

上面談的是規則能不能被執行。還有一個更前面的問題:規則本身會不會過期。
這個專案有兩份給 AI 讀的文件,AGENTS.md 與 CLAUDE.md,維護紀錄差距大到值得單獨列一張表:
| 檔案 | commit 數 | 行數變化 | 最後一次被改 |
|---|---|---|---|
AGENTS.md |
1 | 69 → 69 | 進 repo 那刻(1dd9971,08-06 03:36) |
CLAUDE.md |
10 | 68 → 139 | 6239002,08-06 18:40 |
AGENTS.md 自從被建立以來,一個 commit 都沒有、一行都沒被動過【實測:git log --numstat -- AGENTS.md】。
而它裡面躺著兩條過期條款,就在上面那段原文的最後兩行:
| 行號 | 條款 | 為什麼過期 |
|---|---|---|
:57 |
隨機必須走 seeded random「once it exists」 | src/utils/seededRandom.js 於 08-06 13:22(6a5b0d2)建立,那個條件句從此沒有意義 |
:58 |
foundation 變更不含 gameplay 初始化「until the render foundation change」 | 那個 change 於 08-06 04:08(314a7b4)完成,比 AGENTS.md 自己晚 32 分鐘 |
兩條都是時效性條款,時效都在合約寫完當天就到期,而到今天沒有人發現——包括我,我是為了寫這篇去查 commit 歷史才看到的。
對照組就在同一個 repo 裡。色票這件事有三份拷貝:
| 位置 | 色票數 | 誰在讀 |
|---|---|---|
docs/art-style.md |
17 | scripts/validate-svg.js 的 loadPalette()(:48)每次驗證都 parse |
PRD.md:109-125 散文表 |
15 | 沒有人 |
24a8d1c(08-06 17:58)為了洞穴地形加了 SOIL 與 CAVE 兩個 Token。docs/art-style.md 加了兩行,驗證器一個字都不必改就跟著把新色碼放行了——因為它讀的是規格本身,不是規格的拷貝。同一天,PRD.md 裡那張手抄的色票表沒跟上,到現在還停在 15 個。
PRD.md:106 自己寫了一句免責:機器可讀的色票以 docs/art-style.md 為準。這讓那張散文表不算說謊,但它仍然是一份會被人讀到、而且已經跟現況不符的東西。
檢查器不會過期,因為它一過期 CI 就紅。 更省事的是連「更新檢查器」都不必:讓它去 parse 那份規格,不要把規格抄進它的常數裡。
Day 5 留了一個伏筆。src/utils/ 只有兩個檔案,geometry.js 208 行、seededRandom.js 62 行,兩個檔案的 import 區塊都是空的【實測:grep -n '^import' src/utils/*.js 零命中】。這條紀律撐住了整個測試策略:八個單元測試檔用得到它們,沒有一個需要 mock。
而它不在 AGENTS.md 的 27 條裡。全檔 69 行沒有一句規定 utils/ 不准 import;ESLint 那兩個 rule entry 也管不到目錄之間的依賴方向。今天在 geometry.js 頂端加一行 import { Application } from 'pixi.js',四個 CI 命令全綠。
跟前面的東西擺成一張表,這篇要講的四種狀態就齊了:
| 有檢查 | 沒檢查 | |
|---|---|---|
| 寫進合約 | 21 條 | 6 條(E1、E3、E4、E5、E7、CSS 形狀) |
| 沒寫進合約 | 5 條——驗證器比合約嚴的那幾條(Day 8) | utils/ 零 import |
右下角那格最不穩。它現在成立,因為只有我一個人在寫、專案只活了一天、而我記得。靠慣性維持的紀律,在人數與時間上都是負相關。
我沒有補上這個檢查。誠實的說法是:我知道怎麼補(ESLint 的 no-restricted-imports 加一組 files: ['src/utils/**'] 的 pattern 就行),只是還沒做。這篇不是「我做完了教你做」,是「我把清單列出來了,第一項是我自己的」。
Day 8 講過 docs/rejected/,它的 README 開頭寫著「存放未通過驗證的原始檔案,不是垃圾桶」。當時它是空的,理由是它比第一批素材晚了將近十一個小時才建立——素材在 08-06 04:30 與 04:48 進 repo,README 在 15:45(59b8741)才被寫出來。
今天重跑 ls docs/rejected/:還是只有那一個 README.md。 原因跟上次不同:上次是機制還沒建,這次是機制建好了,但第一階段的 19 個素材早就全部通過驗證,之後沒有新素材進來。
容易被誤讀的是這裡:目錄空不代表閘門沒作用。閘門有沒有作用,看的是 CI 會不會因為它而紅;目錄空不空,看的是有沒有留存。這是兩個獨立的問題,我這個專案的答案分別是「有」跟「沒有」。 所以這篇沒有故事,只有結構。
這個系列從 Day 1 就押在一句話上:AI 產出的品質,取決於你設得出幾道它過不去的自動檢查。二十九天下來,這句話該修正兩次。
第一次修正:那些檢查不是均勻分佈的,而且分佈的形狀不是你選的。 素材是我交出去最多、也最放心的部分——19 個 SVG 幾乎全是 AI 產的,因為那一層有 18 條規則裡的 17 條在守。核心系統交得少,不是因為我不信任,是因為那一層只守得住兩條。
第二次修正:AI 最容易出錯的地方,跟沒有檢查的那份清單高度重疊。 這句要標成推論:需要跨檔案理解全域狀態的工作——誰該清理誰、重試時該重置什麼、中斷路徑要不要多開一個狀態——正好就是 E4、E5、E7。我沒做過對照實驗,也沒紀錄每次退回的原因,這是從「哪些地方我不敢不看」倒推的,不是量出來的。
但兩件事指向同一個機制,而那機制不神秘:跨檔案的語意,人類讀起來也一樣費力。 寫不出斷言的地方,就是必須有人親自出席的地方;那份出席名單,跟你設不出檢查的那份是同一張。
你能安心交出去多少工作,等於你設得出多少道檢查;而規則能不能變成檢查,取決於它有沒有可 parse 的目標,跟它多重要無關。
三件今天就能做的事:
docs/art-style.md,所以色票加兩個它自動跟上;PRD.md 那份手抄的拷貝就停在原地。少一份拷貝,少一個過期的來源。明天 Day 30,完賽篇。我會把所有數字重跑一次收在一張表上——commit 數、src/ 行數、素材數、測試數、bundle 大小——然後花更長的篇幅講另一張表:我會重做的事。上面那個 utils/ 沒補的檢查只是其中之一,還有效能預算訂了沒量、e2e 沒進 CI。最後會回到那個很誘人的時程數字,並且把它退場。
本篇數字的快照時間:2026-08-07 12:35(+0800),對應 commit
5aa3705;文中的對映表與 git 歷史為 2026-08-07 19:30 重跑。專案仍在開發中,量體數字會變動。
可玩網址:https://save-the-dog-web.vercel.app/|原始碼:https://github.com/HarryFan/save-the-dog-web
如果你卡在語法
深入原理