diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 998d4bbc9..a130d6d8e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,6 +46,22 @@ jobs:
node-version: '24'
cache: 'pnpm'
+ # 002 記分守門 CI 端強制(issue #661):驗 PR 最終態 vs merge-base,堵 --no-verify 與網頁端 merge 繞過。
+ # 002 未變更時即刻跳過;與 pre-commit 共用 validate002 核心,零 npm 依賴故置於 install 前搶先紅燈。
+ - name: Verify 002 log (vs merge-base)
+ if: github.event_name == 'pull_request'
+ run: node scripts/verify-002-log.mjs --base-ref "${{ github.event.pull_request.base.sha }}"
+
+ # main 直推兜底:守門不假設 branch protection 永遠有效——這道守門的存在理由就是防止
+ # 稽核證據被湮滅。基準取 github.event.before(本次 push 前的 main tip),可涵蓋一次推多個
+ # commit;merge commit 的 before 即第一父,語意等同「main 上這次多了什麼」。
+ # 用 --base-commit 而非 --base-ref:force push 時 before 不是 HEAD 的祖先,
+ # 取 merge-base 會退到更早的共同祖先,讓被改寫掉的條目驗不出來。
+ # 分支初建後 before 為全零無法解析,該情境跳過而非誤紅。
+ - name: Verify 002 log (main push)
+ if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000'
+ run: node scripts/verify-002-log.mjs --base-commit "${{ github.event.before }}"
+
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 147792264..b2f83144e 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -10,7 +10,7 @@
echo "執行 Pre-commit 檢查..."
# 1. Lint-staged (程式碼格式化與 Lint)
-echo "\nStep 1/5: Lint-staged..."
+echo "\nStep 1/6: Lint-staged..."
pnpm lint-staged
if [ $? -ne 0 ]; then
echo "\nLint-staged 失敗,請修復後再提交"
@@ -18,7 +18,7 @@ if [ $? -ne 0 ]; then
fi
# 2. TypeScript 檢查 (快速驗證類型錯誤)
-echo "\nStep 2/5: TypeScript 檢查..."
+echo "\nStep 2/6: TypeScript 檢查..."
pnpm typecheck
if [ $? -ne 0 ]; then
echo "\nTypeScript 檢查失敗,請修復類型錯誤後再提交"
@@ -26,7 +26,7 @@ if [ $? -ne 0 ]; then
fi
# 3. 格式檢查 (確保所有文件已格式化)
-echo "\nStep 3/5: Prettier 格式檢查..."
+echo "\nStep 3/6: Prettier 格式檢查..."
pnpm format
if [ $? -ne 0 ]; then
echo "\n格式檢查失敗,請執行 'pnpm format:fix' 後再提交"
@@ -36,26 +36,37 @@ fi
# 4. SSOT 同步驗證 (確保 SEO 配置一致性)
# [可選] 僅在 ratewise 相關檔案有變更時執行
if git diff --cached --name-only | grep -qE "(seo-paths|app\.config)"; then
- echo "\nStep 4/5: SSOT 同步驗證..."
+ echo "\nStep 4/6: SSOT 同步驗證..."
node scripts/verify-ssot-sync.mjs 2>/dev/null
if [ $? -ne 0 ]; then
echo "\nSSOT 同步驗證失敗,請確保 seo-paths.config.mjs 與 seo-paths.ts 一致"
exit 1
fi
else
- echo "\nStep 4/5: SSOT 同步驗證 (跳過 - 無相關變更)"
+ echo "\nStep 4/6: SSOT 同步驗證 (跳過 - 無相關變更)"
fi
# 5. 版本 SSOT 驗證 (確保版本管理不繞過 SSOT)
if git diff --cached --name-only | grep -qE "(apps/ratewise/src|apps/ratewise/package\.json|package\.json)"; then
- echo "\nStep 5/5: 版本 SSOT 驗證..."
+ echo "\nStep 5/6: 版本 SSOT 驗證..."
node scripts/verify-version-ssot.mjs
if [ $? -ne 0 ]; then
echo "\n版本 SSOT 驗證失敗,請依照訊息修正"
exit 1
fi
else
- echo "\nStep 5/5: 版本 SSOT 驗證 (跳過 - 無相關變更)"
+ echo "\nStep 5/6: 版本 SSOT 驗證 (跳過 - 無相關變更)"
+fi
+
+# 6. 002 記分守門 (issue #608:檔頭記分與新增條目自動對帳)
+# 無條件執行:以 git diff 判斷是否觸發會被 `git mv` 繞過(--name-only 只列新路徑,
+# 舊路徑不出現),守門不應依賴 diff 呈現方式。跳過與否由腳本自行以 index 與 HEAD
+# 的存在性決定;開銷遠低於同一 hook 內的 pnpm typecheck,對體感無影響。
+echo "\nStep 6/6: 002 記分守門..."
+node scripts/verify-002-log.mjs
+if [ $? -ne 0 ]; then
+ echo "\n002 記分守門失敗,請依照訊息修正檔頭記分與條目"
+ exit 1
fi
echo "\n所有 Pre-commit 檢查通過"
diff --git a/AGENTS.md b/AGENTS.md
index 5b924f43e..03fe0e543 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -108,20 +108,32 @@ scripts/ # 驗證/SEO/版本/SSOT 腳本
## 控制矩陣(Audit Control Matrix)
-| 控制 ID | 控制項 | 必須要求 | 證據 | SSOT / 來源 |
-| ------------ | ------------ | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------------------------------ |
-| `AGT-CTX-01` | 官方文件查證 | 遇 build/test/lint 錯誤、新工具、CI/CD 變更、major 升級時,先查官方文件 | Context7 / Web 查詢紀錄、引用來源 | 本 SOP、`CLAUDE.md` |
-| `AGT-DOC-01` | 開發文檔編號 | `docs/dev/` 新檔名必須 `00X_*.md` | `git diff`, 檔名紀錄 | `docs/dev/` 結構 |
-| `AGT-LOG-01` | 獎懲記錄更新 | 每次 `git commit` 前更新 `docs/dev/002...`(含本次分數變化與累計總分) | 002 檔案 diff、總分更新 | `docs/dev/002_development_reward_penalty_log.md` |
-| `AGT-LOG-02` | 002 格式治理 | `docs/dev/002...` 新增紀錄格式必須與該檔案當前檔頭規範一致;若調整檔頭格式,必須同 PR 同步更新 002 本體 | 002 檔案 diff、格式區塊一致性 | `docs/dev/002_development_reward_penalty_log.md` |
-| `AGT-CMT-01` | 提交格式 | commit message 通過 commitlint 硬規則 | `commit-msg` hook / commitlint 結果 | `commitlint.config.cjs` |
-| `AGT-PC-01` | 提交前檢查 | `pre-commit` 5 步驟通過 | hook log | `.husky/pre-commit` |
-| `AGT-PP-01` | 推送前檢查 | `typecheck` + `test` + `build:ratewise` 通過 | hook log / CI | `.husky/pre-push` |
-| `AGT-QA-01` | QA 截圖管理 | 截圖集中於 `screenshots/`,不得污染 root | 檔案路徑、`git status --ignored --short` | `.gitignore`, 本 SOP |
-| `AGT-DOC-02` | 文件同步 | 流程/規則變更需同步更新 `AGENTS.md` / `CLAUDE.md` | 文件 diff | 本 SOP、`CLAUDE.md` |
-| `AGT-MRG-01` | 主支合併 | 透過 PR 與 `gh` 進行合併;避免未審查直推主支 | PR 編號、merge 記錄 | GitHub / `gh` |
-| `AGT-VER-01` | SemVer 決策 | 每個 PR/功能 **必須**以正確 bump 類型建立 changeset;發版前 CHANGELOG 條目必須存在 | `.changeset/*.md` 存在、CHANGELOG diff | `CLAUDE.md` Phase 7、semver.org |
-| `AGT-VER-02` | 發版 SSOT | 執行 `pnpm changeset:version` 完成版本升級;禁止手動修改版本號或個別執行 prebuild scripts | `git diff` 包含全部版本嵌入產出物 | `scripts/update-release-metadata.js` |
+| 控制 ID | 控制項 | 必須要求 | 證據 | SSOT / 來源 |
+| ------------ | ------------ | ------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -------------------------------------------------------- |
+| `AGT-CTX-01` | 官方文件查證 | 遇 build/test/lint 錯誤、新工具、CI/CD 變更、major 升級時,先查官方文件 | Context7 / Web 查詢紀錄、引用來源 | 本 SOP、`CLAUDE.md` |
+| `AGT-DOC-01` | 開發文檔編號 | `docs/dev/` 新檔名必須 `00X_*.md` | `git diff`, 檔名紀錄 | `docs/dev/` 結構 |
+| `AGT-LOG-01` | 獎懲記錄更新 | 每個 PR 必須更新 `docs/dev/002...`(含本次分數變化與累計總分);落盤時機依 `AGT-LOG-03` | 002 檔案 diff、總分更新 | `docs/dev/002_development_reward_penalty_log.md` |
+| `AGT-LOG-02` | 002 格式治理 | `docs/dev/002...` 新增紀錄格式必須與該檔案當前檔頭規範一致;若調整檔頭格式,必須同 PR 同步更新 002 本體 | 002 檔案 diff、格式區塊一致性 | `docs/dev/002_development_reward_penalty_log.md` |
+| `AGT-LOG-03` | 002 單一提交 | 一個 PR 的 002 條目必須集中在單一 commit,檔頭寫 PR 聚合淨變化 | `git log --oneline -- docs/dev/002...` | `scripts/verify-002-log.mjs`、`.github/workflows/ci.yml` |
+| `AGT-CMT-01` | 提交格式 | commit message 通過 commitlint 硬規則 | `commit-msg` hook / commitlint 結果 | `commitlint.config.cjs` |
+| `AGT-PC-01` | 提交前檢查 | `pre-commit` 6 步驟通過 | hook log | `.husky/pre-commit` |
+| `AGT-PP-01` | 推送前檢查 | `typecheck` + `test` + `build:ratewise` 通過 | hook log / CI | `.husky/pre-push` |
+| `AGT-QA-01` | QA 截圖管理 | 截圖集中於 `screenshots/`,不得污染 root | 檔案路徑、`git status --ignored --short` | `.gitignore`, 本 SOP |
+| `AGT-DOC-02` | 文件同步 | 流程/規則變更需同步更新 `AGENTS.md` / `CLAUDE.md` | 文件 diff | 本 SOP、`CLAUDE.md` |
+| `AGT-MRG-01` | 主支合併 | 透過 PR 與 `gh` 進行合併;避免未審查直推主支 | PR 編號、merge 記錄 | GitHub / `gh` |
+| `AGT-VER-01` | SemVer 決策 | **有 package 變更**(`apps/*/**`)的 PR 必須以正確 bump 類型建立 changeset;純 root 工具變更則否 | `.changeset/*.md` 存在、CHANGELOG diff | `CLAUDE.md` Phase 7、semver.org |
+| `AGT-VER-02` | 發版 SSOT | 執行 `pnpm changeset:version` 完成版本升級;禁止手動修改版本號或個別執行 prebuild scripts | `git diff` 包含全部版本嵌入產出物 | `scripts/update-release-metadata.js` |
+
+### 已知殘餘風險(非自動化控制,不列入上表)
+
+上表每一列都是**有自動化或流程強制**的控制項。以下項目**沒有**對應的自動守門,僅靠人工審查;刻意與控制矩陣分開列出,避免只掃表格的稽核者因「有編號=有控制」的視覺慣性而誤判覆蓋範圍。
+
+| 殘餘風險 | 說明 | 責任歸屬 |
+| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 002 條目語意掏空/內容對調 | ID 與各欄位非空性都保留、只改寫敘述文字(可淡化或對調 penalty 敘事)。守門判準是「有沒有從有變成無」而非「內容有沒有變」,故**不擋**——擋了會同時封死合法的精確性修正 | PR review(見 § 為什麼堵「掏空」但不堵「改寫」) |
+| 無基準版時首次加入 002 | 沒有可對帳的歷史,任何累計總分皆合法。屬設計允許 | PR review |
+| `before` 為全零時跳過 main push 守門 | 分支初建情境;屬 GitHub 營運權限面,非腳本可涵蓋 | branch protection 設定 |
+| 本機 object store 被清空後掏空 002 | orphan+刪光 named refs+`reflog expire`+`gc --prune=now` 會讓 commit 物件真正消失,結構上與真空 repo 無法區分,本機守門視為無基準版而放行 | CI 兜底:PR/main push 守門以 GitHub 事件的基準 SHA 在 actions/checkout 的乾淨 clone 上重驗(本機物件刪除不影響遠端),基準無法解析亦 fail-closed |
## Mandatory Workflow (Agent SOP)
@@ -155,7 +167,7 @@ Agent **必須**先完成:
提交前 Agent **必須**:
-1. 更新 `docs/dev/002_development_reward_penalty_log.md`
+1. 更新 `docs/dev/002_development_reward_penalty_log.md`(條目累積後於單一 commit 落盤,見 `AGT-LOG-03`)
2. 確認 002 新增內容符合 002 檔頭當前規範;歷史整理僅能寫入精簡索引,不得新增巨型 table
3. 若本次調整 002 格式規範,必須同步更新 002 本體並在同 PR 提供遷移差異
4. 依「獎懲分數計算 SSOT」更新本次分數與累計總分
@@ -188,7 +200,36 @@ Agent **必須**先完成:
- 計算公式:
- `本次分數變化 = reward_count - penalty_count`
- `最新總分 = 前次總分 + 本次分數變化`
-- 每次新增 002 條目時,必須同步更新本次分數變化與累計總分(可放於檔頭摘要行或同批 commit 的 SSOT 文件)。
+- 新增 002 條目時,必須同步更新本次分數變化與累計總分;條目累積後於單一 commit 落盤(`AGT-LOG-03`),檔頭寫該 PR 的聚合淨變化。
+- 檔頭記分行固定格式:`> 本次分數變化:+N(reward a、penalty b、neutral c)|累計總分:+T`;條目 ID 必須以 `reward-` / `penalty-` / `neutral-` 開頭。
+- `pre-commit` 第 6 步由 `scripts/verify-002-log.mjs` 自動守門(issue #608):驗證 `a+b+c` = 本次新增條目數、`N = a - b`、`T` = 前版(HEAD)累計 + `N`、條目四行模板、ID 全檔唯一性與歷史條目不可刪除;staged 刪除整份 002(`git rm`)亦必紅。只有「無基準版」(初始 commit)才跳過總分鏈;**有基準版卻讀不出前版累計總分時 fail-closed**。
+- ID 全檔唯一,但**只對本次造成的重複**擋 commit:無條件掃全檔會讓歷史一旦出現重複,之後每個 commit 都被卡死(即使沒動 002)。判準比照格式檢查的「歷史不回溯」。
+- 刪除比對只採信基準版的解析結果;區段外的獨立 `- ID:` 行(文件範例等)不是條目,移除它不算刪除歷史條目。基準版不可解析時才退回全檔原始文字掃描。
+- **既有條目的欄位不可被掏空**:某欄位在基準版為非空時,修改後不得為空(含刪掉整行、留空值、只剩空白)。日期/原因/解法適用;ID 被掏空則由「歷史條目不可刪除」攔下。歷史上本來就為空的欄位維持豁免,不回溯擋 commit。
+
+##### 為什麼堵「掏空」但不堵「改寫」(此分界為刻意設計,不是漏做)
+
+- **掏空 = 就地刪除**。已堵住 `git rm` 整份刪除後,「保留檔案與 ID、把內容清空」是等效的規避路徑,實質同樣湮滅了 penalty 證據,因此必須擋。
+- **改寫不擋**(同 ID 但描述被換成另一段非空文字)。理由是它與**合法且必要的精確性修正無法機械區分**——更正錯字、修正誤植數字都是同一個操作。守門若連內容改動一併擋下,會封死唯一的更正管道,而歷史條目又不可刪除重寫,將形成死結。
+- 因此判準是「**有沒有從有變成無**」,而不是「內容有沒有變」。非空性可機械判定、誤判風險低;語意品質需要人判斷,交由審查把關。
+- 同理,也**不採用**「回溯驗證所有既有條目的四行完整性」:main 上有 218 筆歷史條目無標準 ID 前綴、3 筆帶額外欄位,全面回溯會讓守門對現存資料直接報錯,而修正歷史條目本身又觸犯不可刪改原則。守門的一貫立場是**對歷史寬鬆、對新增嚴格、對「從有到無」零容忍**。
+- `pre-commit` 第 6 步**無條件執行**,不以 `git diff` 判斷是否觸發:`git mv` 的 `--name-only` 只列新路徑,任何 diff-based 觸發條件都會被它繞過。跳過與否由腳本以 index/HEAD 的存在性決定;開銷主要是 node 啟動,四方觀測跨越一個數量級(p50 54ms~148ms、曾見 max 784ms),故**不記具體數字**——唯一在任何機器上都成立的敘述是「遠低於同一 hook 內的 `pnpm typecheck`,對體感無影響」。
+- 條目區段(`## 條目`)必須唯一:只解析第一個區段,多個等於替後續區段開永久盲區(前置 decoy 抄齊全部 ID 即滿足刪除防護,真區段從此不受檢視)。
+- **基準版不可解析時 fail-closed**:基準版若本身解析失敗(區段重複或缺失),`entries` 會是空集合而讓刪除檢查落入真空——「先讓 tip 變成不可解析、下一個 commit 清空全部歷史」即可兩道閘全綠。故基準版有 `globalErrors` 一律判失敗;並且**僅在該情境下**,刪除比對才退回全檔原始文字掃描 `- ID:` 作第二道保險(基準版可解析時只採信解析結果,見上一條)。
+- CI `Quality Checks` 於 install 前強制同一守門(issue #661):PR 事件以 `--base-ref `(基準版為 `merge-base(base, HEAD)`、待驗版為 PR 最終態);**main push 事件以 `--base-commit ` 兜底**(守門不假設 branch protection 永遠有效),`before` 為全零(分支初建)時跳過。002 未變更時跳過。
+- **已知缺口(由 CI 兜底)**:`git merge` 產生的 merge commit 走 `pre-merge-commit` 而非 `pre-commit`,本 repo 未設前者,故 merge commit 在 hook 層不受守門。**刻意不補**——`git merge origin/main` 併入 main 側 002 條目時,staged vs HEAD 會把它們全數視為新增而誤紅,屬合法工作流。此情境由 PR CI 與 main push CI 覆蓋,與 rebase 的處置一致。**pre-commit 只看單一 commit,攔不到 squash 聚合的記帳錯誤**(逐 commit 各自 +1 皆合法,squash 後檔頭仍寫 +1 但實際淨變化為 +N);本 repo 以 squash 為主要合併方式,故 CI 端才是聚合記帳的真守門。
+- **一個 PR 的 002 條目必須集中在單一 commit**(`AGT-LOG-03`)。pre-commit 以「本 commit 新增條目」對帳檔頭,CI 以「PR 聚合淨變化」對帳檔頭;**在現行兩種語意下**002 分散於多個 commit 必然互斥——逐 commit 檔頭各自正確則 CI 紅(聚合不符),末個 commit 改寫為聚合檔頭則 pre-commit 紅。因此 002 更新一律累積後於單一 commit 落盤,檔頭直接寫 PR 聚合值。
+
+#### `AGT-LOG-03` 已評估但不採用的替代方案(此為設計取捨,非技術必然)
+
+- **替代方案**:讓 pre-commit 也改用聚合語意,即以 `merge-base(, HEAD)` 而非 `HEAD` 當基準版。技術上可行,且能保留「逐 commit 更新 002」的舊寫法(各 commit 都對聚合基準對帳,末態自然正確)。
+- **不採用的理由**:
+ 1. **base 不恆為 main**。本 repo 的長期實驗線(例如 ratewise 2026H2)PR base 指向 experiment 分支;pre-commit 若硬寫 `origin/main`,對這類分支會算出錯誤的 `merge-base` 與 `previousTotal`,產生**假紅或假綠**——而假綠比沒有守門更危險。
+ 2. **本機沒有權威 base 來源**。commit 當下 PR 可能尚未建立;`@{upstream}` 指向自身的遠端追蹤分支而非 base。要正確就得引入設定檔或環境變數,等於為守門新增一個可被設錯的狀態。
+ 3. **CI 已有零猜測的 base**(`github.event.pull_request.base.sha`)。把聚合判斷放在唯一確知 base 的環節,而讓 pre-commit 維持「零外部依賴、只看 index vs HEAD」的確定性。
+- **代價**:SOP 由「逐 commit 更新」改為「累積後單一 commit」。若日後判定保留逐 commit 的價值更高,`--base-ref` 已是現成入口,pre-commit 只需再加一層 base 解析——**必須走顯式設定,不得以 `origin/main` 猜測**。
+- 承上:002 落盤後才收到的審查修正,其 commit **不得再新增 002 條目**(pre-commit 以「本 commit 新增條目 vs 檔頭」對帳,必紅)。補記一律併回同一個 002 commit——002 commit 仍在 tip 時用 `git commit --amend`,否則延到分支 rebase 時 fold 補齊。故實務上**盡量讓 002 commit 留在分支最後**。
+- rebase 解 002 衝突後,`git rebase --continue` 不觸發 pre-commit——必須手動執行 `node scripts/verify-002-log.mjs` 驗證,或事後以 `git commit --amend` 重新觸發守門。
### Phase 5. 推送與合併(Push & Merge Controls)
@@ -205,13 +246,14 @@ Agent **必須**先完成:
- 執行:`npx --no -- commitlint --edit $1`
- 規則來源:`commitlint.config.cjs`
-### `pre-commit`(Husky,實際 5 步驟)
+### `pre-commit`(Husky,實際 6 步驟)
1. `pnpm lint-staged`(JS/TS 透過 `eslint --fix --no-warn-ignored` + `prettier --write`,避免 ignored file 警告誤擋 commit)
2. `pnpm typecheck`
3. `pnpm format`(`prettier --check .`)
4. `node scripts/verify-ssot-sync.mjs`(僅相關檔變更時)
5. `node scripts/verify-version-ssot.mjs`(僅版本相關檔變更時)
+6. `node scripts/verify-002-log.mjs`(**無條件執行**;驗證檔頭記分與新增條目一致、累計總分鏈與條目格式,002 未變更時由腳本自行跳過)
### `pre-push`(Husky,快速必要檢查)
@@ -222,6 +264,53 @@ Agent **必須**先完成:
- E2E / coverage / Lighthouse 由 CI 執行(本地 pre-push 不做完整長時間檢查)
- PR CI E2E 為 smoke 子集(3 核心 spec × desktop);main push 為 sharded 完整套件(見 `docs/dev/039_ci_e2e_speed_optimization.md`)
+### CI `Quality Checks`(002 記分守門,PR 專屬)
+
+- 位置:`.github/workflows/ci.yml` 的 `quality` job,置於 `Install dependencies` 之前(零 npm 依賴、搶先紅燈)
+- PR 事件:`node scripts/verify-002-log.mjs --base-ref "${{ github.event.pull_request.base.sha }}"`;`merge-base(base, HEAD)` 為基準版、PR 最終態為待驗版
+- main push 事件:`--base-commit "${{ github.event.before }}"`;基準為本次 push 前的 main tip,可涵蓋一次推多個 commit,merge commit 的 `before` 即第一父。`before` 為全零(分支初建)時跳過而非誤紅
+- **兩種模式的基準取法不同,不可混用**:`--base-ref` 取 `merge-base(ref, HEAD)`(PR 的 base 分支會前進,需退回分岔點);`--base-commit` 直接取該 commit。main push 若誤用 `--base-ref`,force push 時 `before` 並非 HEAD 的祖先,merge-base 會退到更早的共同祖先,使被改寫掉的條目驗不出來——而那正是此模式的存在理由
+- 兩個 flag **互斥**,同時指定即失敗;靜默取其一會讓誤用得到假綠
+
+#### 失敗行為契約:所有錯誤路徑一律 fail-closed
+
+守門至今被找出的破口有半數屬同一模式——**檢查在錯誤路徑上靜默失效**。故以下情境全部視為驗證失敗,不得放行;新增分支時必須維持此契約:
+
+| 情境 | 行為 |
+| -------------------------------------------------- | ------------------------------------------------ |
+| 基準版解析失敗(區段重複/缺失) | 失敗(該情境另退回全檔原始文字掃描作第二道保險) |
+| `git` 無法執行,或物件存在卻讀不出 | 失敗(「無基準版」須由三層結構探測證明,見下) |
+| 基準版讀不出累計總分 | 失敗 |
+| 待驗版解析失敗、檔頭缺失或格式不符 | 失敗 |
+| base ref/base commit 無法解析 | 失敗 |
+| base ref 與 HEAD 無共同祖先(orphan PR) | 失敗(專用診斷訊息,不與 ref 打錯混淆) |
+| flag 缺值、兩 flag 同時指定 | 失敗 |
+| 002 在基準版存在但待驗版消失(`git rm`/`git mv`) | 失敗 |
+
+僅三種情境放行:002 相對基準未變更、002 在 index 與 HEAD 皆不存在、`before` 為全零(分支初建)。
+
+**`catch` 區塊的判準**:每一個吞掉錯誤的 `catch` 都必須說得出「為什麼這個錯誤可以安全忽略」,說不出來就 rethrow。本守門各 `catch` 的理由記於程式碼註解;`refResolves` 是唯一會吞的一處,但它只回報「ref 是否解析得到」,不代表「無基準版」——**「無基準版」必須由三層結構探測(refs/reflog/object store)證明**(見下)。
+
+**存在性判定不得依賴 git 的 fatal 訊息**:`cat-file -e` 把「物件不存在」表達成 status 128 + 人類可讀訊息,與「repo 不可用」共用同一個離開碼,只能靠比對英文訊息區分。這條路徑連續破了三次(漏訊息種類、依賴英文輸出、又漏第五種),根因是把人類可讀輸出當成 API 契約。
+
+現行做法把「不存在」變成正常回傳值:先 `git rev-parse --git-dir` 確認 repo 可用(僅一次),之後 index 用 `git ls-files -- `、tree 用 `git ls-tree --name-only [ -- `——路徑不存在時輸出空字串且 exit 0,非零離開一律是環境問題並上拋 fail-closed。**新增存在性判定時不得退回訊息比對,測試有結構鎖把關。**
+
+**「無基準版」只能由三層結構探測證明**:`git rev-list -n 1 --all --reflog` 為空(named refs+HEAD+reflog 皆無 commit),且 object store 無任何 commit 型別物件(`git cat-file --batch-all-objects --batch-check` 探測;只在前層為空時執行,正常 repo 永不觸發)。不可用 `rev-parse --verify HEAD` 失敗代替:`git checkout --orphan ` 之後 HEAD 指向尚未存在的分支,verify 一樣失敗,但 repo 的歷史 commit 都還在;也不可只看 named refs——orphan 後 `branch -D` 全部分支+清 `packed-refs` 即可讓 `--all` 為空,而歷史 commit 物件仍在。把這些情境當成無基準版會讓刪除防護與總分鏈整個跳過,而這全是標準 Git 指令即可觸發、不需劫持環境。ref 不解析但 repo 已有 commit(orphan、損毀 symref、基準 ref 失效)一律 fail-closed。
+
+所有 git 子行程收斂在唯一的 `git()` wrapper(帶 `LC_ALL=C`/`LANGUAGE=C` 使輸出決定性),測試斷言全檔只有一個子行程呼叫點——讓「忘記帶 env」在結構上不可能,而非事後字串偵測。
+
+#### 改動守門行為時的固定掃描清單(不需判斷邊界)
+
+漂移在本守門上重複發生過四次,每次都是靠人記得去掃某個檔案。故把範圍與關鍵字都寫死,並由 `scripts/__tests__/verify-002-log.test.ts` 機械強制:
+
+- **固定範圍**(有新檔案描述守門行為就加進來):`scripts/verify-002-log.mjs`、`scripts/__tests__/verify-002-log.test.ts`、`.husky/pre-commit`、`.github/workflows/ci.yml`、`AGENTS.md`、`CLAUDE.md`
+- **固定關鍵字**(現行用語):`verify-002-log`、`002 記分守門`、`--base-ref`、`--base-commit`、`AGT-LOG-`、`fail-closed`
+- **已被取代的措辭**:維護在測試檔的 `SUPERSEDED_PATTERNS`,任一命中即測試紅。**改寫守門行為時,把被取代的舊說法加進該清單**——這是唯一需要人做的動作,其餘由測試代勞
+- 用 `rg --hidden`:預設會跳過 `.husky`/`.github` 等隱藏目錄,本守門的兩次漏掃都源於此
+
+- 共同:002 相對基準未變更時即刻跳過,整檔刪除或改名必紅
+- 定位:堵 `--no-verify`、`core.hooksPath` 改設定、GitHub 網頁端 merge/squash,以及 merge commit 不走 `pre-commit` 等所有繞過 hook 的路徑;**不假設 branch protection 永遠有效**
+
## Commit Format(commitlint SSOT)
```text
@@ -443,10 +532,16 @@ curl -sI https://app.haotool.org/ratewise/og-image.jpg | grep -i "cache-control\
SemVer 決策規則見 `CLAUDE.md` Phase 7。
-每個 PR 完成後 **必須**建立 changeset(AGT-VER-01):
+每個 **有 package 變更**的 PR 完成後 **必須**建立 changeset(AGT-VER-01):
- `pnpm changeset` → 選 bump 類型 → 描述使用者可見影響(禁止描述實作細節)
+**適用界線(changeset 的對象是 package,不是 commit)**:
+
+- **要**:變更落在任一 workspace package 目錄內(`apps/*/**`,含該 app 的 `docs/`、`README.md` 等非程式碼檔)——該 package 確實有變更,需要版本與 CHANGELOG intent
+- **不要**:純 root 層變更(`scripts/`、`.husky/`、`.github/`、root `package.json`/設定檔、root `AGENTS.md`/`CLAUDE.md`/`README.md`、`docs/dev/*`)——沒有任何 package 可 bump,硬補只會產生對使用者無意義的 CHANGELOG 條目
+- 判斷依據為 **變更檔案所屬 package**,不是 commit type;`ci`/`chore` 若動到 `apps/*` 仍要 changeset,`feat` 若只動 root 工具則不用
+
發版指令(AGT-VER-02):
```bash
diff --git a/CLAUDE.md b/CLAUDE.md
index b927a05e7..2fef2fcf8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,15 +83,15 @@ pnpm format:fix # prettier --write .
## Execution SOP(AI 助手執行程序)
-| Phase | 核心動作 |
-| ----------------- | ----------------------------------------------------------------------------------- |
-| **1. Intake** | 確認目標、輸出物、風險等級(commit/push/merge?CI/PWA/版本?) |
-| **2. Context** | 讀最小必要檔案;flow 變更先讀 `package.json` / `.husky/*`;合併前 `gh pr status` |
-| **3. Evidence** | build error / 新工具 / CI 變更 / major 升級 → **先查官方文件**(Context7) |
-| **4. Execution** | 最小必要變更;禁止跨 app 無關修改;保持可回滾;勿刪未追蹤 `.agents/skills/*` |
-| **5. Validation** | 文檔→SSOT 一致;程式碼→typecheck/test/build:ratewise;UI→截圖+console errors |
-| **6. Commit** | 更新 `docs/dev/002...`(對齊當前 SSOT 模板 + 分數變化 + 累計總分)→ commitlint 提交 |
-| **7. Release** | 見下方 Phase 7 版本發布流程 |
+| Phase | 核心動作 |
+| ----------------- | -------------------------------------------------------------------------------------------------------- |
+| **1. Intake** | 確認目標、輸出物、風險等級(commit/push/merge?CI/PWA/版本?) |
+| **2. Context** | 讀最小必要檔案;flow 變更先讀 `package.json` / `.husky/*`;合併前 `gh pr status` |
+| **3. Evidence** | build error / 新工具 / CI 變更 / major 升級 → **先查官方文件**(Context7) |
+| **4. Execution** | 最小必要變更;禁止跨 app 無關修改;保持可回滾;勿刪未追蹤 `.agents/skills/*` |
+| **5. Validation** | 文檔→SSOT 一致;程式碼→typecheck/test/build:ratewise;UI→截圖+console errors |
+| **6. Commit** | 更新 `docs/dev/002...`(對齊當前 SSOT 模板 + 分數變化 + 累計總分;條目集中單一 commit)→ commitlint 提交 |
+| **7. Release** | 見下方 Phase 7 版本發布流程 |
### 002 格式與獎懲分數 SSOT(對齊 `AGENTS.md`)
@@ -104,7 +104,17 @@ pnpm format:fix # prettier --write .
- `neutral = 0`
- `本次分數變化 = reward_count - penalty_count`
- `最新總分 = 前次總分 + 本次分數變化`
-- 每次 commit 前新增 002 紀錄時,必須同步更新「本次分數變化」與「累計總分」。
+- 新增 002 紀錄時,必須同步更新「本次分數變化」與「累計總分」;條目累積後於單一 commit 落盤(`AGT-LOG-03`)。
+- 檔頭記分行固定格式:`> 本次分數變化:+N(reward a、penalty b、neutral c)|累計總分:+T`;條目 ID 必須以 `reward-` / `penalty-` / `neutral-` 開頭。
+- `pre-commit` 第 6 步(`scripts/verify-002-log.mjs`,**無條件執行**,跳過與否由腳本自行判定)自動驗證記分:`a+b+c` = 本次新增條目數、`N = a - b`、`T` = 前版累計 + `N`、四行模板、歷史條目不可刪除(issue #608);staged 刪除整份 002(`git rm`)亦必紅。ID 唯一性只對**本次造成的重複**擋 commit(歷史既有重複不回溯,否則會卡死所有後續 commit);刪除比對只採信基準版解析結果,區段外的獨立 `- ID:` 行不列入。「無基準版」只能由三層結構探測證明——`git rev-list -n 1 --all --reflog` 為空且 object store 無 commit 物件(`checkout --orphan` 會讓 `rev-parse --verify HEAD` 失敗、orphan 後刪光 named refs 會讓 `--all` 為空,但歷史 commit 仍在,誤判即跳過刪除防護);存在性判定用 `ls-files`/`ls-tree`(不存在 = exit 0 空輸出)而非 `cat-file -e` 的 fatal 訊息比對——後者把「不存在」與「repo 不可用」混在同一離開碼、只能比對英文訊息,已連續破三次;所有 git 子行程收斂在唯一帶 `LC_ALL=C` 的 wrapper。**基準版不可解析、或讀不出前版累計總分時一律 fail-closed**(否則先 poison tip 再清空歷史即可全綠);僅在基準版不可解析時,刪除比對才退回全檔原始文字掃描 `- ID:` 作第二道保險。
+- 既有條目的欄位不可被掏空:基準版非空的欄位(日期/原因/解法)改後不得為空。**掏空=就地刪除**故擋;**改寫不擋**,因為它與合法的精確性修正(改錯字、更正數字)無法機械區分,擋下會封死唯一的更正管道。判準是「有沒有從有變成無」,不是「內容有沒有變」;語意品質交由審查把關。詳見 `AGENTS.md` § 為什麼堵「掏空」但不堵「改寫」。
+- pre-commit 第 6 步無條件執行、不以 `git diff` 判斷觸發(`git mv` 的 `--name-only` 只列新路徑會繞過);條目區段 `## 條目` 必須唯一(多個等於替後續區段開永久盲區)。
+- `git merge` 的 merge commit 走 `pre-merge-commit` 而非 `pre-commit`,本 repo 未設前者故 hook 層不覆蓋——刻意不補(會讓 `git merge origin/main` 誤紅),由 CI 兜底。
+- CI `Quality Checks` 於 install 前跑守門(issue #661):PR 事件用 `--base-ref `,基準取 `merge-base(base, HEAD)`;**main push 事件用 `--base-commit `**(全零時跳過),基準**直取該 commit 不走 merge-base**——force push 時 `before` 並非 HEAD 祖先,取 merge-base 會漏驗被改寫的條目。兩個 flag 互斥。守門不假設 branch protection 永遠有效。pre-commit 只看單一 commit,squash 聚合的檔頭記帳錯誤(逐 commit 各自合法、聚合後淨變化不符)只有 CI 端攔得到。
+- **一個 PR 的 002 條目必須集中在單一 commit**(`AGT-LOG-03`),檔頭直接寫 PR 聚合淨變化。在現行兩種語意下,002 分散多個 commit 時 pre-commit(逐 commit 對帳)與 CI(聚合對帳)互斥,無法同時綠燈。
+- 此為設計取捨非技術必然:已評估「pre-commit 也改用 `merge-base(, HEAD)` 聚合語意」(可行、能保留逐 commit 寫法),因 **base 不恆為 main**(實驗線 PR base 指向 experiment 分支,硬寫 `origin/main` 會產生假紅/假綠)、本機無權威 base 來源,而 CI 已有零猜測的 `base.sha` 而不採用。詳見 `AGENTS.md` § `AGT-LOG-03` 已評估但不採用的替代方案。
+- 002 落盤後的審查修正 commit 不得新增條目(pre-commit 必紅);補記併回同一個 002 commit——仍在 tip 用 `git commit --amend`,否則延到 rebase 時 fold。故盡量讓 002 commit 留在分支最後。
+- rebase 解 002 衝突後,`git rebase --continue` 不觸發 pre-commit——必須手動執行 `node scripts/verify-002-log.mjs` 驗證,或事後以 `git commit --amend` 重新觸發守門。
### Phase 7. 版本發布與依賴管理(Release & Dependencies)
@@ -128,8 +138,10 @@ pnpm format:fix # prettier --write .
- 新互動元件(MoneyBox 比較卡、星評 Modal)→ **minor**
- Core Web Vitals 架構性改善(LCP ↓50%+,SSG 預渲染)→ **minor**
-**Changeset 規範**(每個 PR 完成後 MUST 執行 `pnpm changeset`):
+**Changeset 規範**(**有 package 變更**的 PR 完成後 MUST 執行 `pnpm changeset`):
+- 適用界線:changeset 的對象是 package。變更落在 `apps/*/**`(含該 app 的 `docs/`、`README.md`)→ **要**;純 root 層變更(`scripts/`、`.husky/`、`.github/`、root 設定與文件、`docs/dev/*`)→ **不要**,因為沒有任何 package 可 bump
+- 判斷依據是變更檔案所屬 package,不是 commit type
- bump 類型選正確(見上表);描述使用者**看得到**的影響,禁止描述實作細節
- CHANGELOG 由 changeset 自動生成,禁止手動貼入 git log
- commit 數量不等於升版次數;`.changeset/*.md` 是 release intent,`pnpm changeset:version` 才會消化成版本與 CHANGELOG
diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md
index 1f412918c..07519ffaa 100644
--- a/docs/dev/002_development_reward_penalty_log.md
+++ b/docs/dev/002_development_reward_penalty_log.md
@@ -2,7 +2,7 @@
> 版本:outline-v2-ultra
> 原則:每筆只保留日期、ID、原因、解法。
-> 本次分數變化:+13(reward 13、penalty 0、neutral 0)|累計總分:+299
+> 本次分數變化:+20(reward 26、penalty 6、neutral 2)|累計總分:+319
## 新增模板(4 行)
@@ -13,6 +13,176 @@
## 條目(新→舊)
+- 日期:2026-07-27
+- ID:reward-002-log-gate-mutation-coverage-gaps
+- 原因:`require`/`createRequire` 識別字禁令與 GIT_ENV 的 `LANGUAGE` AST 檢查均實際有效,但缺對應 mutation 元測試——把它們從禁用清單或檢查鍵移除,既有 12 條 mutation 仍全綠(Grok 席實測證實),元測試防不住守門自身這三處的退化
+- 解法:對稱補 3 條 mutation(require 注入、createRequire 注入、LANGUAGE 弱化),並以「移除防線後對應 mutation 必紅」完成反向驗證
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-specifier-hardening-boundary
+- 原因:module specifier 正則只匹配裸 `child_process`,帶子路徑的 specifier(如 `node:child_process/promises`)不納入匯入形式檢查;該子路徑在現行 Node 並非內建模組、寫入會 ERR_UNKNOWN_BUILTIN_MODULE 使守門載入失敗(大聲 fail-closed),故非已知繞過,但留著即是永久審查爭點;能力邊界註解亦只點名字串拼接一類
+- 解法:specifier 匹配放寬為路徑段含 `child_process` 者皆檢(防禦性加固、非修補漏洞)並補 mutation 釘住;能力邊界書面出界列明不防類別(字串拼接/template literal/Reflect.get/vm/worker_threads/process.binding),交由 code review 把關
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-refless-orphan-probes
+- 原因:「無基準版」判準只看 `rev-list --all`(named refs+HEAD),orphan 後刪光 named refs(`branch -D`+清 packed-refs,甚至再 expire reflog)會讓 `--all` 回空但歷史 commit 物件仍在,本地守門誤判無基準版而讓掏空 002 exit 0——CI 走 `--base-commit` 同情境必紅,故屬本地假綠殘留
+- 解法:判準升級 refs/reflog/object store 三層結構探測(`--all --reflog` 為空再以 `cat-file --batch-all-objects` 探測 commit 型別物件),臨時 repo 實證兩情境由假綠轉必紅、真空 repo 仍放行並補整合測試;commit 物件被 prune(reflog expire+gc --prune=now)的極端情境本機與真空 repo 結構上無法區分,登記 AGENTS 殘餘風險表由 CI 基準 SHA 兜底
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-ast-alias-eval-env-lock
+- 原因:AST 結構鎖仍有三條間接繞法——變數別名(`const fn = execFileSync`)不被 callee 檢查追蹤、`eval`/`new Function` 可從字串取回 API、wrapper 的 env 檢查用字串 includes 會被註解裡的 `env: GIT_ENV` 字面假陽性通過
+- 解法:檢查抽為 auditGitWrapperStructure 單一函數——綁定識別字只能作 import 綁定或直接呼叫 callee,`eval`/`Function`/`getBuiltinModule`/`require`/`createRequire` 識別字、動態 import 與字串夾帶 API 名全檔禁止,options 的 `env` 屬性與 GIT_ENV 定義本身改 AST PropertyAssignment 驗證;12 條 mutation 元測試釘住每條繞法必紅,防守門自身退化
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-superseded-drift-selfcheck
+- 原因:措辭鎖常數由 SUPERSEDED_PHRASES 改名 SUPERSEDED_PATTERNS 時 AGENTS.md 未同步(漂移偵測器自身發生漂移),且殘餘風險表「只有物件確實不存在才算無基準」的敘述與實作判準(reachable ref tip 是否存在)不同構
+- 解法:AGENTS.md 修正常數名並把舊常數名與舊「無基準」判準敘述加進措辭鎖自我偵測,無基準語意在 AGENTS/CLAUDE/腳本與測試註解全部改寫為與三層探測實作同構,rg 全 repo 掃描確認僅 002 歷史條目保留舊措辭(守門規則本就不回溯歷史)
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-unrelated-histories-diagnosis
+- 原因:orphan/unrelated histories 的 PR 走 `--base-ref` 時 merge-base exit 1 且 stderr 為空,通用訊息「無法解析 merge-base:」尾巴空白,無法與 ref 打錯或 repo 損壞區分
+- 解法:以 merge-base documented exit 1 語意+ref 可解析為判準輸出專用診斷訊息(無共同祖先、fail-closed 行為不變),補 orphan PR 整合測試與 AGENTS 失敗行為契約表
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-orphan-head-fail-closed
+- 原因:存在性判定換成結構化探測後只覆蓋了 path 存在性,「無基準版」的語意仍綁在「HEAD 不是 commit ⇒ null」——`git checkout --orphan ` 之後 HEAD 指向尚未存在的分支使 `rev-parse --verify` 失敗,但 repo 的歷史 commit 都還在,守門卻視為無基準版而跳過刪除防護與總分鏈;實測掏空 002 後 exit 0、penalty 條目消失,且這是標準 Git 指令即可觸發、不需劫持環境
+- 解法:改由 `git rev-list -n 1 --all` 是否為空來證明「無基準版」(只有真的一個 commit 都沒有才回 null),ref 不解析但 repo 已有 commit(orphan/損毀 symref/基準 ref 失效)一律 throw fail-closed;未加任何訊息 pattern,並補回歸鎖與「真空 repo 仍放行」的對照
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-structural-lock-ast
+- 原因:兩道防漂移機制都被證明可繞過——結構鎖用字串偵測,`import { execFileSync as run }` 與 `cp['execFileSync'](` 皆漏接;措辭鎖用字面比對,同一主張只要在關鍵詞之間插入修飾語就躲過(我自己留下的過時註解就是這樣躲過我自己建的鎖)
+- 解法:結構鎖升級為 AST(typescript compiler)——限制 `child_process` 只能具名未改名匯入、禁 namespace/default,再確認所有呼叫落在 `git()` wrapper 內;措辭鎖改 regex 以主張骨架比對。同時在測試註解與 `AGENTS.md` 寫明措辭鎖只擋已知舊主張、無法偵測全新錯誤敘述,真正的防線是鎖行為的結構測試——避免下一任高估它
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-existence-probe-destructured
+- 原因:`cat-file -e` 把「物件不存在」表達成 status 128 + 人類可讀 fatal 訊息,與「repo 不可用」共用同一離開碼,只能靠比對英文訊息區分——這條路徑連續破三次(第十破口漏訊息種類、第十一個依賴英文輸出、第十二個又漏「exists on disk, but not in ']['」使「已有 commit 的 repo 首次引入 002」在 pre-commit 與 CI 雙雙誤擋);每輪修法都是再加一條 pattern,是在把人類可讀輸出當 API 契約的基礎上打地鼠
+- 解法:不加第五條 pattern,改為結構化探測——先 `rev-parse --git-dir` 確認 repo 可用(僅一次),之後 index 用 `ls-files`、tree 用 `ls-tree --name-only`,路徑不存在時輸出空字串且 exit 0,把「不存在」從例外變成正常回傳值,非零離開一律環境問題並 fail-closed;「尚無 commit」由 `rev-parse --verify` 離開碼結構判定。實測六個「不存在」情境有五個直接變成 exit 0 空輸出,全檔零訊息比對,並補結構鎖禁止退回
+
+- 日期:2026-07-27
+- ID:reward-002-log-gate-git-wrapper-convergence
+- 原因:`GIT_ENV` 靠四個呼叫點各自記得帶,結構鎖是字串切片(`split` 後取前 220 字元找 `env: GIT_ENV`)——實測有效但可用雙引號、`spawnSync`、變數間接呼叫或把 `env` 推到 220 字元之後繞過,屬事後偵測而非結構保證
+- 解法:git 子行程收斂為全檔唯一的 `git()` wrapper 集中帶 `GIT_ENV` 與 stdio,結構鎖改斷言「全檔只有一個子行程 API 呼叫且必須在 wrapper 內」,讓「忘記帶 env」在結構上不可能發生;與 #886 用 `acquirePooled` 收斂池取出點是同一個模式
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-lock-git-output-locale
+- 原因:`gitShow` 的四種「物件不存在」判別讀 git stderr 文字,但 git 內建 gettext 翻譯會跟隨呼叫端 locale 而 `execFileSync` 未傳 env 即繼承——實測 macOS Homebrew git 2.55 搭 `LC_ALL=zh_CN.UTF-8` 時 `致命错误:无效的对象名 'HEAD'。` 不 match 任何 pattern,使 `AGENTS.md` 明載支援的「初始 commit」情境由 exit 0 變 exit 1;方向雖是 fail-closed 而非資安退化,但根因是「窮舉四種訊息即完整」這個宣稱的隱含前提(英文輸出)既未驗證也未文件化
+- 解法:四處 git 呼叫(cat-file/show/merge-base/rev-parse)統一傳 `LC_ALL=C`/`LANGUAGE=C` 鎖英文輸出,順帶鎖住訊息穩定性以降低跨 git 版本翻譯字串變動的風險;補結構鎖要求任何 git 呼叫都須帶 env(新增第五處即紅,這是 CI 上真正有效的那道),另補兩案行為對照,並以七情境 × 雙 locale 實跑確認行為一致
+
+- 日期:2026-07-26
+- ID:penalty-002-log-audit-record-corrupted-by-squash-merge
+- 原因:#857 的 squash 合併同時損壞 002 兩處——`penalty-starpuff-invariant-hero-form-blind-spot` 的 `- 日期:` 整行消失只剩三行,檔頭 delta 行寫 `+1(reward 1、penalty 0、neutral 0)` 但該 PR 相對 `b7cd80b6b` 的實際聚合為 30 筆/reward 25、penalty 4、neutral 1、淨 `+21`(累計 286 本身正確,僅 delta 行不符);002 是稽核紀錄,其自身損壞未被任何機制攔下正是本守門存在的理由,而它在上線前最後一刻被它要守的資料示範了一次
+- 解法:於獨立 commit 純修復(補回日期行、delta 行改為對該零新增 commit 精確的 `+0`,累計 286 不動),不靜默改掉而是以本條目留下事故紀錄;`+21` 未寫入檔頭是因為該欄位語意為「本 commit 新增條目的淨變化」而非歷史聚合,寫入會被守門判為與新增數不符(實測必紅)且下個 commit 即遭覆寫,故正確聚合改記於此
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-git-env-failure-fail-closed
+- 原因:`gitShow` 對非 `ENOENT` 的錯誤一律 `return null`,而 git 用同一個 status 128 同時表示「路徑不存在」與「repo 不可用」——實測壞 git stub(status 1)、`GIT_DIR` 指向無效路徑、`.git` chmod 000 三種環境失敗都讓守門 exit 0,證偽了上一輪「null 只可能發生在物件確實不存在」的宣稱;CI 因 `merge-base` 先失敗而倖免,但 SOP 明文要求 rebase 後手動直跑,正是受害路徑
+- 解法:改以 stderr 區分,窮舉實測四種合法「不存在」訊息(指定 tree 無此路徑/index 與磁碟皆無/`exists on disk, but not in the index`/`invalid object name 'HEAD'`)才回 null,其餘一律上拋 fail-closed——複審建議的 regex 只含 `does not exist`,會誤擋「尚無 commit」的初始 commit 路徑,故改用逐條列出的顯式清單;補 3 案回歸並各自紅綠驗證
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-catch-block-audit
+- 原因:這道守門的第七、九、十個破口都出在同一處——錯誤處理路徑 fail-open;上一輪雖掃了 13 條錯誤路徑,但檢查的是「哪些情況會 early return」,沒檢查「哪些例外會被吞成正常值」
+- 解法:改以 `catch` 區塊為獨立一類逐一稽核四處——`gitShow` 收斂為只忽略窮舉過的四種訊息;`isDirectRun` 原本吞掉 realpath 例外並退回字面比較、比不中即靜默不執行 `main()` 而 exit 0,改為不吞並由統一入口 fail-closed(執行期無法穩定構造故以結構鎖把關);`merge-base` 與 `rev-parse` 兩處雖已 exit 1 但丟棄 git 原始訊息會讓「ref 打錯」與「repo 壞掉」無法分辨,改為帶出 stderr。判準寫入 `AGENTS.md`:吞掉的錯誤說不出可安全忽略的理由就 rethrow
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-drift-check-mechanized
+- 原因:文件與實作漂移在本守門上重複四次,前一輪的預防寫成「涉及守門行為的變更先跑全 repo 掃描」,但「什麼算涉及守門行為」是判斷題(改 commitlint 註解算不算?改表格算不算?),下一任無法機械執行;且關鍵字集合只含現行用語,漏掉行為同義詞與已被取代的措辭,本輪 `AGENTS.md:217`/`CLAUDE.md:109`/腳本 `:204` 三處矛盾正是這樣漏掉的
+- 解法:把判斷題改成清單題——固定六個檔案的掃描範圍與 `SUPERSEDED_PHRASES` 已取代措辭表,由 `verify-002-log.test.ts` 機械強制任一命中即紅(測試檔自身在範圍內,故剔除清單宣告區段避免自我命中);唯一需要人做的動作是改寫行為時把舊說法加進清單。另記錄 `rg` 預設跳過隱藏目錄,是前兩次漏掃 `.husky`/`.github` 的根因
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-gitshow-contract-closed
+- 原因:`gitShow` 把所有 git 失敗都當成「物件不存在」而回 null,使「無基準版」與「讀不到基準版」無法區分——實測把 git 移出 PATH 後整道守門靜默 exit 0;複審席另構造攻擊證明「實檔 fail-closed」只是現況檔頭碰巧不合的巧合而非契約:重寫檔頭對齊 341 筆 compliant 條目即可 GREEN 並寫入累計總分 +999999
+- 解法:改為先 `git cat-file -e` 判存在性、存在卻讀不出即向上拋,直跑入口統一捕捉為 fail-closed;驗證 git 缺席時由 exit 0 轉為 exit 1,且 341 筆攻擊在有基準版時必紅(無基準版仍放行屬設計允許,因為沒有可對帳的歷史)
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-check-scope-corrections
+- 原因:兩項檢查範圍過寬——ID 唯一性無條件掃全檔,歷史一旦出現重複之後每個 commit 都會被卡死(即使沒動 002,現有 559 筆零重複故為休眠缺陷);刪除比對聯集全檔原始文字掃描,使區段外的獨立 `- ID:` 行(文件範例)被移除時誤報刪除歷史條目
+- 解法:唯一性改為只對「本次造成的重複」擋,比照格式檢查的歷史不回溯;刪除比對改為基準版可解析時只採信解析結果、不可解析時才退回全檔掃描(CASCADE 防護不受影響);`headContent` 判斷統一為 `!= null` 使空字串基準走解析 fail-closed
+
+- 日期:2026-07-26
+- ID:penalty-002-gate-comment-drift-third-occurrence
+- 原因:以「消除文件與實作漂移」為主題的 PR,在自己的程式碼註解裡第三次同型漂移——腳本檔頭仍寫「基準版無法解析時跳過」(正是本 PR 才修掉的第八破口)、`.husky/pre-commit` 仍寫「約 50ms」;前兩次分別是 `CLAUDE.md:111` 與同一修正 commit 內的另一知識點,顯示上輪把逐句清單範圍定為「兩份治理文件」仍然太窄
+- 解法:範圍改為 grep 全 repo(含隱藏目錄,避免 rg 預設跳過 `.husky`/`.github`)找出所有命中檔案再逐句對照,本輪掃出 6 個描述本守門的檔案並修正 8 處。惟「涉及守門行為」仍是判斷題,下一任無法機械執行——後續已改為固定範圍清單 × `SUPERSEDED_PHRASES` 措辭表並由測試強制,見同日 `reward-002-log-gate-drift-check-mechanized`
+
+- 日期:2026-07-26
+- ID:neutral-002-gate-overhead-figure-withdrawn
+- 原因:hook 開銷四方觀測跨越一個數量級(複審席 p50 54ms、另一席 p50 148ms 曾見 max 784ms、本機 p50 161ms/max 350ms、前輪 mean 109ms),文件寫「p50 約 120–160ms」這種看似精確的雙位數區間反而誤導,複審席的實測就落在區間外
+- 解法:放棄具體數字,只保留「遠低於同一 hook 內的 `pnpm typecheck`,對體感無影響」這個在任何機器上都成立的相對比較——該數字的用途本來就只是說明不影響體感
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-cascade-fail-closed
+- 原因:`validate002` 對基準版只取 `parseEntries().entries` 而丟棄 `globalErrors`,基準版本身不可解析(多個「## 條目」區段或區段被移除)時 `headEntryIds` 為空集合,使刪除檢查落入真空——攻擊鏈為「先讓遠端 tip 變成不可解析(`--no-verify`/admin 強制合併/CI 失效),下一個清理 commit 即可清空全部歷史 ID」,pre-commit 與 CI 雙雙綠燈;前六個破口都是單點繞過,這一個讓整道防線一次失明,且正好命中守門存在的唯一理由
+- 解法:雙保險——基準版有 `globalErrors` 即 fail-closed(無法確認歷史完整性就不放行),並僅在該情境退回全檔原始文字掃描 `- ID:` 作第二道保險(基準版可解析時只採信解析結果)。raw 掃描初版使「插入 `## ` 標題截斷」「條目搬到區段之前」兩條既有防線失效(被隱藏的條目仍在 raw 集合故不算刪除),由繞過路徑排查腳本當場抓到,補 hiddenIds 檢查收斂;共補 5 案回歸鎖(兩種 poison+poison tip 後 wipe 的 E2E+兩種移出解析範圍),各自以拔掉防護驗證紅→綠
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-error-path-fail-closed-audit
+- 原因:CASCADE 屬「錯誤處理路徑本身沒有 fail-closed」的模式,同族可能不只一處;實測發現第八條——基準版讀不出累計總分時 `previousTotal` 為 null 而靜默跳過總分鏈驗證,可寫入任意累計總分而不被察覺;另 `--base-ref` 與 `--base-commit` 同時傳入時參數迴圈先匹配前者,使誤用得到假綠
+- 解法:總分鏈改為僅「無基準版」(初始 commit)才跳過、讀不出即失敗;兩 flag 改互斥。另對腳本全部錯誤與例外路徑做掃描(13 條 CLI/解析/git 情境實跑),確認僅「相對基準未變更」「index 與 HEAD 皆無 002」「`before` 全零」三種放行,其餘一律 fail-closed,並把此契約以表格寫入 `AGENTS.md` 供新增分支時遵循
+
+- 日期:2026-07-26
+- ID:penalty-002-gate-doc-audit-not-internalized
+- 原因:上一輪才自陳「改 hook 時漏同步 `CLAUDE.md`」並記為 penalty,同一個修正 commit(`d93cbef7e`)裡卻對另一個知識點重犯——`AGENTS.md` 的 `--base-commit` 語意被正確更新且新增「兩種模式不可混用」,`CLAUDE.md` 對應那句完全沒被觸碰;抓到模式一次不等於內化成覆核習慣
+- 解法:改為以「列舉兩份文件中提及此守門的每一句、逐句對照實作」取代單點修補,本輪掃出並修正 12 處(含 `AGT-LOG-01` 與 `AGT-LOG-03` 字面衝突、Phase 4 步驟 1、開銷數字失準等既有問題);往後涉及守門行為的變更一律先做這份逐句清單再動手
+
+- 日期:2026-07-26
+- ID:neutral-002-gate-residual-risk-documented
+- 原因:語意掏空/內容對調(ID 與非空性都保留、只改敘述)可洗白 penalty 敘事,這是刻意不擋的設計取捨(擋了會封死合法的精確性修正),但控制矩陣未標示,稽核者可能誤以為守門全包
+- 解法:於 `AGENTS.md` 新增「已知殘餘風險(非自動化控制,不列入上表)」獨立小節明列此項並歸屬人工審查責任,指向「為什麼堵掏空但不堵改寫」說明邊界;刻意不放進控制矩陣,避免只掃表格的稽核者因「有編號=有控制」的視覺慣性誤判覆蓋範圍
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-push-base-and-header-sign
+- 原因:main push 兜底沿用 `--base-ref` 的 merge-base 取法,但 force push 時 `before` 並非 HEAD 的祖先,基準會退回更早的共同祖先——`before` 與祖先之間新增的 penalty 條目在改寫後消失也驗不出來,等於這道專為湮滅情境設的兜底對湮滅本身無效;另檔頭嚴格解析的 `[+-]?` 讓「本次分數變化:1」「累計總分:171」通過,與文件所定的固定 `+N`/`+T` 不符
+- 解法:新增 `--base-commit` 直取該 commit 作基準(PR 仍用 `--base-ref` 走 merge-base,因 base 分支會前進需退回分岔點),ci.yml push 步驟改用之並於 `AGENTS.md` 明載兩者不可混用;檔頭正負號改為強制存在(負值仍可解析)。以臨時 repo 重現 force push 丟棄 `penalty-evidence` 的情境,驗證 merge-base 模式綠燈而 `--base-commit` 必紅
+
+- 日期:2026-07-26
+- ID:penalty-002-gate-doc-impl-drift-claude-md
+- 原因:把 pre-commit 第 6 步改為無條件執行時只同步了 `AGENTS.md`,`CLAUDE.md` 仍寫「僅 002 檔變更時執行」——維護者依該句把 hook 改回條件式即會重新引入 `git mv` 繞過缺口;本 PR 的主題正是消除文件與實作漂移,卻在同一份 PR 內自製一處
+- 解法:同步 `CLAUDE.md` 措辭為無條件執行,並全文掃描確認無其他殘留;往後改動 hook 行為時,兩份操作 SSOT 必須在同一 commit 內一起改
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-landed-on-main
+- 原因:`AGENTS.md`/`CLAUDE.md` 上游分支與各代理載入的規則長期宣稱 pre-commit 有第 6 步 002 記分守門(issue #608)與 CI 端強制(#661),但 `3321f3e34`/`bcd52c15f` 皆非 `origin/main` 祖先、`scripts/verify-002-log.mjs` 從未在 main 出現——漂移的真實形狀是代理照著一道不存在的守門在工作(載入的是本機 checkout 被修改過的規則),任何讀 main 的人都不會發現;記分正確性全靠人工驗算,squash 聚合檔頭錯誤(#857 檔頭寫 +1、實際淨 +9)無任何自動攔截
+- 解法:將腳本與 hook 第 6 步移植進 main 並對齊 main 現況的 5 步版本,另補 `ci.yml` PR 專屬 `--base-ref` 步驟置於 install 前;七種故障注入(計數不符/總分斷鏈/ID 重複/刪歷史條目/缺行/日期格式/ID 前綴)全數 exit 1,實測 #857 head CI 端必紅而 #880 綠,rebase 後對合併態 main 的 544 條目解析零錯誤、無重複 ID
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-glued-block-parsing
+- 原因:來源版解析器只用空行切分條目,但歷史 002 有 2 處漏空行使多筆黏成一塊(8 行 2 筆、12 行 3 筆)——`block.find` 只取首個 ID,使 3 筆條目(`reward-rw-theme-ssot-drift-convergence`、`reward-starpuff-v17-gamescene-strangler-debt-train`、`neutral-starpuff-t2a-changeset-release-intent`)對 ID 唯一性與「歷史條目不可刪除」防護完全隱形,可被靜默刪除而不觸發守門
+- 解法:解析器補「- 日期:」為次要邊界(空行仍為主要邊界),納管條目由 515 升為 518(等於區段內全部 ID 行);另 3 個超過 4 行的區塊實為單一條目帶 `content_type`/`topics` 額外欄位、非黏合,其格式錯誤仍只對新增條目生效不回溯擋 commit;補 2 案回歸鎖並以拔掉邊界驗證紅→綠
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-single-commit-constraint
+- 原因:pre-commit 以「本 commit 新增條目」對帳檔頭、CI 以「PR 聚合淨變化」對帳檔頭,兩種語意在 002 分散於多個 commit 時必然互斥,但既有 SOP 只寫「每次 commit 前更新 002」未涵蓋此衝突——照舊做法寫作將無法讓兩道閘同時綠燈
+- 解法:以臨時 repo 三情境實證(逐 commit 各自正確→CI 紅、末個 commit 改寫聚合檔頭→pre-commit 紅、單一 002 commit 承載全部條目→雙綠),新增 `AGT-LOG-03` 控制項並於 `AGENTS.md`/`CLAUDE.md` 明載;審查席主張此為過早收斂並自建 `merge-base(main, HEAD)` 版 pre-commit 反證,故補寫排除理由——base 不恆為 main(實驗線 PR base 指向 experiment 分支,硬寫 `origin/main` 會產生假綠)、本機無權威 base 來源,取捨非技術必然
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-content-emptiness-bypasses
+- 原因:條目檢查只比對行前綴不看內容,衍生三條同族繞過——`- ID:` 留空使 `entry.id` 為空字串而被 `newEntries` 的 truthy 篩選排除(同時繞過前綴、唯一性、計數與總分檢查)、`- 原因:`/`- 解法:` 留空讓缺 root cause 的紀錄過關、既有條目欄位被掏空(留空值或整行刪除)等同就地刪除卻只受 ID 層刪除防護檢視
+- 解法:三者收斂為同一組非空性檢查——`parseEntries` 抽出 fields map 供內容檢查與跨版本比對共用,空白 ID 視同缺 ID,內容欄位 trim 後判空,並新增「基準版非空的欄位改後不得為空」;判準定為「有沒有從有變成無」而非「內容有沒有變」,故改寫不擋(與合法的精確性修正無法機械區分),分界寫入 `AGENTS.md` 避免被誤認為漏做;補 9 案回歸鎖,正向對照採本 PR 真實的數字更正 diff
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-trigger-surface-bypasses
+- 原因:守門的判定正確不代表會被叫起來——`.husky/pre-commit` 以 `git diff --cached --name-only` 判斷觸發,`git mv` 的 rename 偵測只列新路徑故完全不設防;`ci.yml` 的守門步驟帶 `if: github.event_name == 'pull_request'`,main 直推連 CI 兜底都沒有;`parseEntries` 以 `findIndex` 只取第一個「## 條目」,前置 decoy 區段抄齊全部 ID 即可讓真區段永久落在視野外
+- 解法:hook 改無條件執行(觸發判斷收斂為單一 SSOT,不依賴 diff 呈現方式,002 未變更時約 50ms)、CI 補 main push 事件以 `github.event.before` 為基準(涵蓋一次推多個 commit,全零 SHA 跳過)、條目區段改為必須唯一;另實跑 17 條繞過路徑排查(含 symlink 化、homoglyph 前綴、標題截斷、行首縮排、改名並同步改 `LOG_PATH`)確認其餘皆已擋下,並記錄 merge commit 走 `pre-merge-commit` 故 hook 層不覆蓋為刻意不補(補了會使 `git merge origin/main` 誤紅)
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-direct-run-and-locator
+- 原因:`isDirectRun` 以 `process.argv[1]` 字面比對 `import.meta.url`,macOS `/tmp` 為 `/private/tmp` 的 symlink 使絕對路徑呼叫時兩側不等,`main()` 不執行卻 exit 0 形成靜默假成功;另 `parseEntries` 的格式錯誤訊息一律引用區塊首行(多為日期),在 500 筆以上的檔案裡無法指出是哪一筆
+- 解法:比對前先 `realpathSync`(失敗則退回原比較不拋錯),並以顯式建立 symlink 的回歸測試鎖住(不依賴平台 `/tmp` 行為故 Linux CI 同樣有效);錯誤訊息改以條目 ID 為定位字串、取不到才退回首行
+
+- 日期:2026-07-26
+- ID:reward-002-log-gate-changeset-scope-boundary
+- 原因:`AGT-VER-01` 寫「每個 PR/功能必須建立 changeset」未區分變更範圍,導致審查對零 app 原始碼變更的純 root 工具 PR 也要求補 changeset——但 changesets 操作的對象是 package,此類 PR 沒有任何 package 可 bump,硬補只會產生對使用者無意義的 CHANGELOG 條目
+- 解法:控制矩陣與 Phase 7 補上適用界線——變更落在 `apps/*/**`(含該 app 的 docs/README)要建立、純 root 層(`scripts/`、`.husky/`、`.github/`、root 設定與文件、`docs/dev/*`)則否,並明載判斷依據為變更檔案所屬 package 而非 commit type
+
+- 日期:2026-07-26
+- ID:penalty-002-log-gate-overstated-bypass-scale
+- 原因:撰寫黏塊解析修正的 002 條目與 PR 描述時,把「5 個超過 4 行的區塊」直接當成「5 處漏空行、6 筆條目隱形」,未逐一分辨其中 3 個其實是單一條目帶額外欄位;實際為 2 處黏合、3 筆隱形,數字誇大近一倍且由審查席實跑複驗才發現
+- 解法:以新舊解析器對 main 真實檔逐一比對複驗(舊 515 區塊/新 518 條目/delta 3),更正 002 條目本體、腳本註解與 PR 描述三處,並在 PR 註明原 commit message 的舊數字待 rebase 時 amend
+
+- 日期:2026-07-26
+- ID:penalty-002-log-gate-git-env-leak-authorship
+- 原因:做繞過路徑排查時為隔離臨時 repo 而在持久 shell 內 `export GIT_AUTHOR_NAME`/`GIT_CONFIG_GLOBAL` 等變數且未還原,洩漏到後續在 worktree 的 commit,使其作者被寫成測試身分 `t `,同批 `GIT_CONFIG_GLOBAL=/dev/null` 亦遮蔽 credential helper 導致 push 認證失敗
+- 解法:改以 push 失敗為訊號回查環境,清空全部 `GIT_` 變數後 `--amend --reset-author` 修正並驗證全分支作者一致;該 commit 未曾推上遠端。往後隔離用環境變數一律限定在單次 shell 呼叫內,不寫入持久 session
+
- 日期:2026-07-27
- ID:reward-starpuff-v21w1-framehook-mutation-lock
- 原因:Grok MEDIUM——尺寸回歸鎖的替身 scene.events 為 vi.fn(),PRE/POST_UPDATE 幀鉤從未觸發,移除 wearTexture 的 vscale.rebase 單測仍全綠(產線下一幀被舊基準沖掉)=對 rebase 假信心
diff --git a/package.json b/package.json
index 47e686350..749c89f25 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,7 @@
"format:fix": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r --workspace-concurrency 2 test && pnpm test:root",
- "test:root": "vitest run scripts/__tests__/lighthouse-production.test.ts scripts/__tests__/fetch-moneybox-rates.test.ts scripts/__tests__/fetch-taiwan-bank-rates.test.ts scripts/__tests__/build-commit-sha.test.ts",
+ "test:root": "vitest run scripts/__tests__/lighthouse-production.test.ts scripts/__tests__/fetch-moneybox-rates.test.ts scripts/__tests__/fetch-taiwan-bank-rates.test.ts scripts/__tests__/build-commit-sha.test.ts scripts/__tests__/verify-002-log.test.ts",
"test:unit": "pnpm -r test && pnpm test:root",
"test:integration": "pnpm --filter @app/ratewise exec vitest run integration",
"test:e2e": "pnpm --filter @app/ratewise exec playwright test && pnpm --filter @app/nihonname exec playwright test",
@@ -83,6 +83,7 @@
"@eslint/js": "^9.39.5",
"@lhci/cli": "^0.15.1",
"@playwright/test": "^1.61.1",
+ "@types/node": "^24.13.3",
"@vitejs/plugin-react": "^6.0.3",
"depcheck": "^1.4.7",
"eslint": "^9.39.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 59523ebf1..b5b30ac8d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,10 +69,10 @@ importers:
version: 0.6.0
'@changesets/cli':
specifier: ^2.31.1
- version: 2.31.1(@types/node@26.1.1)
+ version: 2.31.1(@types/node@24.13.3)
'@commitlint/cli':
specifier: ^21.2.1
- version: 21.2.1(@types/node@26.1.1)(conventional-commits-parser@7.1.0)(typescript@5.9.3)
+ version: 21.2.1(@types/node@24.13.3)(conventional-commits-parser@7.1.0)(typescript@5.9.3)
'@commitlint/config-conventional':
specifier: ^20.5.3
version: 20.5.3
@@ -85,9 +85,12 @@ importers:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
+ '@types/node':
+ specifier: ^24.13.3
+ version: 24.13.3
'@vitejs/plugin-react':
specifier: ^6.0.3
- version: 6.0.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ version: 6.0.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
depcheck:
specifier: ^1.4.7
version: 1.4.7
@@ -114,7 +117,7 @@ importers:
version: 27.4.0
knip:
specifier: ^5.88.1
- version: 5.88.1(@types/node@26.1.1)(typescript@5.9.3)
+ version: 5.88.1(@types/node@24.13.3)(typescript@5.9.3)
lighthouse:
specifier: ^13.4.0
version: 13.4.0(yauzl@2.10.0)
@@ -135,13 +138,13 @@ importers:
version: 8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
unlighthouse:
specifier: ^0.17.10
- version: 0.17.10(bfcyot2w7kjgddboygz5cmun7q)
+ version: 0.17.10(hycydusuejmsskhakc3b6z7pb4)
vite:
specifier: '>=8.0.10'
- version: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
+ version: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
vitest:
specifier: ^4.1.10
- version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
xml2js:
specifier: ^0.6.2
version: 0.6.2
@@ -3897,9 +3900,6 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
- '@types/node@26.1.1':
- resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
-
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -8430,9 +8430,6 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
- undici-types@8.3.0:
- resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
-
unhead@2.1.15:
resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==}
@@ -9962,7 +9959,7 @@ snapshots:
transitivePeerDependencies:
- encoding
- '@changesets/cli@2.31.1(@types/node@26.1.1)':
+ '@changesets/cli@2.31.1(@types/node@24.13.3)':
dependencies:
'@changesets/apply-release-plan': 7.1.1
'@changesets/assemble-release-plan': 6.0.10
@@ -9978,7 +9975,7 @@ snapshots:
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@changesets/write': 0.4.0
- '@inquirer/external-editor': 1.0.3(@types/node@26.1.1)
+ '@inquirer/external-editor': 1.0.3(@types/node@24.13.3)
'@manypkg/get-packages': 1.1.3
ansi-colors: 4.1.3
enquirer: 2.4.1
@@ -10095,12 +10092,12 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
- '@commitlint/cli@21.2.1(@types/node@26.1.1)(conventional-commits-parser@7.1.0)(typescript@5.9.3)':
+ '@commitlint/cli@21.2.1(@types/node@24.13.3)(conventional-commits-parser@7.1.0)(typescript@5.9.3)':
dependencies:
'@commitlint/config-conventional': 21.2.0
'@commitlint/format': 21.2.0
'@commitlint/lint': 21.2.0
- '@commitlint/load': 21.2.0(@types/node@26.1.1)(typescript@5.9.3)
+ '@commitlint/load': 21.2.0(@types/node@24.13.3)(typescript@5.9.3)
'@commitlint/read': 21.2.1(conventional-commits-parser@7.1.0)
'@commitlint/types': 21.2.0
tinyexec: 1.2.4
@@ -10150,14 +10147,14 @@ snapshots:
'@commitlint/rules': 21.2.0
'@commitlint/types': 21.2.0
- '@commitlint/load@21.2.0(@types/node@26.1.1)(typescript@5.9.3)':
+ '@commitlint/load@21.2.0(@types/node@24.13.3)(typescript@5.9.3)':
dependencies:
'@commitlint/config-validator': 21.2.0
'@commitlint/execute-rule': 21.0.1
'@commitlint/resolve-extends': 21.2.0
'@commitlint/types': 21.2.0
cosmiconfig: 9.0.2(typescript@5.9.3)
- cosmiconfig-typescript-loader: 6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3)
+ cosmiconfig-typescript-loader: 6.3.0(@types/node@24.13.3)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3)
es-toolkit: 1.49.0
is-plain-obj: 4.1.0
picocolors: 1.1.1
@@ -10712,12 +10709,12 @@ snapshots:
'@img/sharp-win32-x64@0.35.3':
optional: true
- '@inquirer/external-editor@1.0.3(@types/node@26.1.1)':
+ '@inquirer/external-editor@1.0.3(@types/node@24.13.3)':
dependencies:
chardet: 2.2.0
iconv-lite: 0.7.3
optionalDependencies:
- '@types/node': 26.1.1
+ '@types/node': 24.13.3
'@internationalized/date@3.12.2':
dependencies:
@@ -10839,21 +10836,21 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1
- '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
+ '@nuxt/devtools-kit@3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
dependencies:
'@nuxt/kit': 4.4.8(magicast@0.5.3)
execa: 8.0.1
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
+ vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
transitivePeerDependencies:
- magicast
- '@nuxt/fonts@0.14.0(esbuild@0.27.7)(idb-keyval@6.3.0)(magicast@0.5.3)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
+ '@nuxt/fonts@0.14.0(esbuild@0.27.7)(idb-keyval@6.3.0)(magicast@0.5.3)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
dependencies:
- '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
'@nuxt/kit': 4.4.8(magicast@0.5.3)
consola: 3.4.2
defu: 6.1.7
- fontless: 0.2.1(idb-keyval@6.3.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ fontless: 0.2.1(idb-keyval@6.3.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
h3: 1.15.11
magic-regexp: 0.10.0
ofetch: 1.5.1
@@ -10862,7 +10859,7 @@ snapshots:
tinyglobby: 0.2.17
ufo: 1.6.4
unifont: 0.7.4
- unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
unstorage: 1.17.5(idb-keyval@6.3.0)
transitivePeerDependencies:
- '@azure/app-configuration'
@@ -10895,13 +10892,13 @@ snapshots:
- vite
- webpack
- '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))':
+ '@nuxt/icon@2.3.1(magicast@0.5.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))':
dependencies:
'@iconify/collections': 1.0.710
'@iconify/types': 2.0.0
'@iconify/utils': 3.1.4
'@iconify/vue': 5.0.1(vue@3.5.40(typescript@5.9.3))
- '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ '@nuxt/devtools-kit': 3.2.4(magicast@0.5.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
'@nuxt/kit': 4.4.8(magicast@0.5.3)
consola: 3.4.2
local-pkg: 1.2.1
@@ -10949,18 +10946,18 @@ snapshots:
pkg-types: 2.3.1
std-env: 4.2.0
- '@nuxt/ui@4.10.0(i33ipeezn5m7hhohhqbxlgpqt4)':
+ '@nuxt/ui@4.10.0(7wyh3qqxx2s5yh5assbfstrkce)':
dependencies:
'@floating-ui/dom': 1.8.0
'@iconify/vue': 5.0.1(vue@3.5.40(typescript@5.9.3))
- '@nuxt/fonts': 0.14.0(esbuild@0.27.7)(idb-keyval@6.3.0)(magicast@0.5.3)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
- '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))
+ '@nuxt/fonts': 0.14.0(esbuild@0.27.7)(idb-keyval@6.3.0)(magicast@0.5.3)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ '@nuxt/icon': 2.3.1(magicast@0.5.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))
'@nuxt/kit': 4.4.8(magicast@0.5.3)
'@nuxt/schema': 4.4.8
'@nuxtjs/color-mode': 4.0.1(magicast@0.5.3)
'@standard-schema/spec': 1.1.0
'@tailwindcss/postcss': 4.3.3
- '@tailwindcss/vite': 4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ '@tailwindcss/vite': 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
'@tanstack/vue-table': 8.21.3(vue@3.5.40(typescript@5.9.3))
'@tanstack/vue-virtual': 3.13.32(vue@3.5.40(typescript@5.9.3))
'@tiptap/core': 3.28.0(@tiptap/pm@3.28.0)
@@ -11010,9 +11007,9 @@ snapshots:
tinyglobby: 0.2.17
typescript: 5.9.3
ufo: 1.6.4
- unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
unplugin-auto-import: 21.0.0(@nuxt/kit@4.4.8(magicast@0.5.3))(@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3)))
- unplugin-vue-components: 32.1.0(@nuxt/kit@4.4.8(magicast@0.5.3))(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))
+ unplugin-vue-components: 32.1.0(@nuxt/kit@4.4.8(magicast@0.5.3))(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3))
vaul-vue: 0.4.1(reka-ui@2.10.1(vue@3.5.40(typescript@5.9.3)))(vue@3.5.40(typescript@5.9.3))
vue-component-type-helpers: 3.3.7
optionalDependencies:
@@ -12011,13 +12008,6 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
- '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
- dependencies:
- '@tailwindcss/node': 4.3.3
- '@tailwindcss/oxide': 4.3.3
- tailwindcss: 4.3.3
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
-
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.17.4': {}
@@ -12510,10 +12500,6 @@ snapshots:
dependencies:
undici-types: 7.18.2
- '@types/node@26.1.1':
- dependencies:
- undici-types: 8.3.0
-
'@types/normalize-package-data@2.4.4': {}
'@types/parse-json@4.0.2': {}
@@ -12554,7 +12540,7 @@ snapshots:
'@types/yauzl@2.10.3':
dependencies:
- '@types/node': 26.1.1
+ '@types/node': 24.13.3
optional: true
'@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
@@ -12654,10 +12640,10 @@ snapshots:
unhead: 2.1.15
vue: 3.5.40(typescript@5.9.3)
- '@unlighthouse/cli@0.17.10(iirgd723jvuotz672lcdfu2o2y)':
+ '@unlighthouse/cli@0.17.10(c4pvbmjyf4wx7hi55ebbc765vi)':
dependencies:
'@lhci/utils': 0.15.1
- '@unlighthouse/client': 0.17.10(gvvclkv7n2valjkdmvojaermni)
+ '@unlighthouse/client': 0.17.10(dqlmmiwrmze33outlrhly64oyy)
'@unlighthouse/core': 0.17.10(magicast@0.5.3)(puppeteer@24.32.0(typescript@5.9.3))(yauzl@2.10.0)
'@unlighthouse/server': 0.17.10(magicast@0.5.3)(puppeteer@24.32.0(typescript@5.9.3))(yauzl@2.10.0)
cac: 7.0.0
@@ -12755,10 +12741,10 @@ snapshots:
- yup
- zod
- '@unlighthouse/client@0.17.10(gvvclkv7n2valjkdmvojaermni)':
+ '@unlighthouse/client@0.17.10(dqlmmiwrmze33outlrhly64oyy)':
dependencies:
- '@nuxt/ui': 4.10.0(i33ipeezn5m7hhohhqbxlgpqt4)
- '@tailwindcss/vite': 4.3.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ '@nuxt/ui': 4.10.0(7wyh3qqxx2s5yh5assbfstrkce)
+ '@tailwindcss/vite': 4.3.3(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
defu: 6.1.7
fuse.js: 7.5.0
lightweight-charts: 5.2.0
@@ -12910,11 +12896,6 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
- '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
- dependencies:
- '@rolldown/pluginutils': 1.0.1
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
-
'@vitest/coverage-v8@4.1.10(vitest@4.1.10)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
@@ -12954,14 +12935,6 @@ snapshots:
optionalDependencies:
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
- '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))':
- dependencies:
- '@vitest/spy': 4.1.10
- estree-walker: 3.0.3
- magic-string: 0.30.21
- optionalDependencies:
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
-
'@vitest/pretty-format@4.1.10':
dependencies:
tinyrainbow: 3.1.0
@@ -13805,9 +13778,9 @@ snapshots:
dependencies:
browserslist: 4.28.6
- cosmiconfig-typescript-loader@6.3.0(@types/node@26.1.1)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3):
+ cosmiconfig-typescript-loader@6.3.0(@types/node@24.13.3)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3):
dependencies:
- '@types/node': 26.1.1
+ '@types/node': 24.13.3
cosmiconfig: 9.0.2(typescript@5.9.3)
jiti: 2.6.1
typescript: 5.9.3
@@ -14689,7 +14662,7 @@ snapshots:
dependencies:
tiny-inflate: 1.0.3
- fontless@0.2.1(idb-keyval@6.3.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)):
+ fontless@0.2.1(idb-keyval@6.3.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)):
dependencies:
consola: 3.4.2
css-tree: 3.2.1
@@ -14705,7 +14678,7 @@ snapshots:
unifont: 0.7.4
unstorage: 1.17.5(idb-keyval@6.3.0)
optionalDependencies:
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
+ vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
@@ -15573,10 +15546,10 @@ snapshots:
klona@2.0.6: {}
- knip@5.88.1(@types/node@26.1.1)(typescript@5.9.3):
+ knip@5.88.1(@types/node@24.13.3)(typescript@5.9.3):
dependencies:
'@nodelib/fs.walk': 1.2.8
- '@types/node': 26.1.1
+ '@types/node': 24.13.3
fast-glob: 3.3.3
formatly: 0.3.0
jiti: 2.7.0
@@ -17926,8 +17899,6 @@ snapshots:
undici-types@7.18.2: {}
- undici-types@8.3.0: {}
-
unhead@2.1.15:
dependencies:
hookable: 6.1.1
@@ -17976,10 +17947,10 @@ snapshots:
universalify@2.0.1: {}
- unlighthouse@0.17.10(bfcyot2w7kjgddboygz5cmun7q):
+ unlighthouse@0.17.10(hycydusuejmsskhakc3b6z7pb4):
dependencies:
- '@unlighthouse/cli': 0.17.10(iirgd723jvuotz672lcdfu2o2y)
- '@unlighthouse/client': 0.17.10(gvvclkv7n2valjkdmvojaermni)
+ '@unlighthouse/cli': 0.17.10(c4pvbmjyf4wx7hi55ebbc765vi)
+ '@unlighthouse/client': 0.17.10(dqlmmiwrmze33outlrhly64oyy)
'@unlighthouse/core': 0.17.10(magicast@0.5.3)(puppeteer@24.32.0(typescript@5.9.3))(yauzl@2.10.0)
optionalDependencies:
puppeteer: 24.32.0(typescript@5.9.3)
@@ -18089,7 +18060,7 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.5
- unplugin-vue-components@32.1.0(@nuxt/kit@4.4.8(magicast@0.5.3))(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3)):
+ unplugin-vue-components@32.1.0(@nuxt/kit@4.4.8(magicast@0.5.3))(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))(vue@3.5.40(typescript@5.9.3)):
dependencies:
chokidar: 5.0.0
local-pkg: 1.2.1
@@ -18098,7 +18069,7 @@ snapshots:
obug: 2.1.4
picomatch: 4.0.5
tinyglobby: 0.2.17
- unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
+ unplugin: 3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
unplugin-utils: 0.3.2
vue: 3.5.40(typescript@5.9.3)
optionalDependencies:
@@ -18121,7 +18092,7 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
- unplugin@3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)):
+ unplugin@3.3.0(esbuild@0.27.7)(rolldown@1.1.5)(rollup@4.59.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)):
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.5
@@ -18130,7 +18101,7 @@ snapshots:
esbuild: 0.27.7
rolldown: 1.1.5
rollup: 4.59.0
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
+ vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
unstorage@1.17.5(idb-keyval@6.3.0):
dependencies:
@@ -18364,21 +18335,6 @@ snapshots:
terser: 5.49.0
yaml: 2.8.3
- vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3):
- dependencies:
- lightningcss: 1.32.0
- picomatch: 4.0.5
- postcss: 8.5.19
- rolldown: 1.1.5
- tinyglobby: 0.2.17
- optionalDependencies:
- '@types/node': 26.1.1
- esbuild: 0.27.7
- fsevents: 2.3.3
- jiti: 2.7.0
- terser: 5.49.0
- yaml: 2.8.3
-
vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@1.21.7)(terser@5.49.0)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.10
@@ -18439,36 +18395,6 @@ snapshots:
transitivePeerDependencies:
- msw
- vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@27.4.0)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)):
- dependencies:
- '@vitest/expect': 4.1.10
- '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3))
- '@vitest/pretty-format': 4.1.10
- '@vitest/runner': 4.1.10
- '@vitest/snapshot': 4.1.10
- '@vitest/spy': 4.1.10
- '@vitest/utils': 4.1.10
- es-module-lexer: 2.3.1
- expect-type: 1.4.0
- magic-string: 0.30.21
- obug: 2.1.4
- pathe: 2.0.3
- picomatch: 4.0.4
- std-env: 4.2.0
- tinybench: 2.9.0
- tinyexec: 1.2.4
- tinyglobby: 0.2.17
- tinyrainbow: 3.1.0
- vite: 8.1.5(@types/node@26.1.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.49.0)(yaml@2.8.3)
- why-is-node-running: 2.3.0
- optionalDependencies:
- '@opentelemetry/api': 1.9.1
- '@types/node': 26.1.1
- '@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
- jsdom: 27.4.0
- transitivePeerDependencies:
- - msw
-
void-elements@3.1.0: {}
vue-component-type-helpers@3.3.7: {}
diff --git a/scripts/__tests__/lighthouse-production.test.ts b/scripts/__tests__/lighthouse-production.test.ts
index 33d2c5291..063de0c91 100644
--- a/scripts/__tests__/lighthouse-production.test.ts
+++ b/scripts/__tests__/lighthouse-production.test.ts
@@ -48,9 +48,10 @@ describe('lighthouse-production SSOT controls', () => {
it('keeps refreshed summary fields after spreading an existing baseline', () => {
const source = readFileSync(SCRIPT_PATH, 'utf8');
- const existingBaselineWrite = source.match(
- /JSON\.stringify\(\s*\{\s*\.\.\.baseline[\s\S]*?paths: summary\.paths,[\s\S]*?\}\s*,\s*null,\s*2,\s*\)/,
- );
+ const existingBaselineWrite =
+ /JSON\.stringify\(\s*\{\s*\.\.\.baseline[\s\S]*?paths: summary\.paths,[\s\S]*?\}\s*,\s*null,\s*2,\s*\)/.exec(
+ source,
+ );
expect(existingBaselineWrite).not.toBeNull();
const existingBaselineWriteBlock = existingBaselineWrite?.[0];
diff --git a/scripts/__tests__/verify-002-log.test.ts b/scripts/__tests__/verify-002-log.test.ts
new file mode 100644
index 000000000..1092f5a65
--- /dev/null
+++ b/scripts/__tests__/verify-002-log.test.ts
@@ -0,0 +1,1544 @@
+import { execFileSync } from 'node:child_process';
+import {
+ chmodSync,
+ copyFileSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import ts from 'typescript';
+import { afterEach, describe, expect, it } from 'vitest';
+import {
+ LOG_PATH as LOG_PATH_UNTYPED,
+ parseEntries,
+ parsePreviousTotal,
+ parseStrictHeader,
+ validate002,
+} from '../verify-002-log.mjs';
+
+// .mjs 無型別宣告,正規化為 string 供路徑參數使用(維持 SSOT、不複製字面值)。
+const LOG_PATH = String(LOG_PATH_UNTYPED);
+
+function buildLog({
+ header,
+ entries,
+}: {
+ header: string;
+ entries: { date?: string; id: string; reason?: string; fix?: string }[];
+}) {
+ const blocks = entries.map(
+ (entry) =>
+ `- 日期:${entry.date ?? '2026-07-07'}\n- ID:${entry.id}\n- 原因:${entry.reason ?? '原因'}\n- 解法:${entry.fix ?? '解法'}`,
+ );
+ return [
+ '# 開發獎懲與決策記錄(超短版)',
+ '',
+ '> 版本:outline-v2-ultra',
+ '> 原則:每筆只保留日期、ID、原因、解法。',
+ header,
+ '',
+ '## 新增模板(4 行)',
+ '',
+ '- 日期:YYYY-MM-DD',
+ '- ID:<唯一識別>',
+ '- 原因:<一句話 root cause>',
+ '- 解法:<一句話修正>',
+ '',
+ '## 條目(新→舊)',
+ '',
+ ...blocks.flatMap((block) => [block, '']),
+ ].join('\n');
+}
+
+const HEAD_CONTENT = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }],
+});
+
+describe('parseStrictHeader / parsePreviousTotal', () => {
+ it('解析標準檔頭記分行', () => {
+ expect(
+ parseStrictHeader('> 本次分數變化:+2(reward 3、penalty 1、neutral 0)|累計總分:+172'),
+ ).toEqual({
+ line: '> 本次分數變化:+2(reward 3、penalty 1、neutral 0)|累計總分:+172',
+ delta: 2,
+ reward: 3,
+ penalty: 1,
+ neutral: 0,
+ total: 172,
+ });
+ });
+
+ // 文件所定格式為 `+N`/`+T`;省略加號會讓非標準檔頭永久寫進稽核記錄。
+ it.each([
+ ['分數變化缺正號', '> 本次分數變化:2(reward 3、penalty 1、neutral 0)|累計總分:+172'],
+ ['累計總分缺正號', '> 本次分數變化:+2(reward 3、penalty 1、neutral 0)|累計總分:172'],
+ ])('%s時嚴格檔頭解析失敗', (_label, line) => {
+ expect(parseStrictHeader(line)).toBeNull();
+ });
+
+ it('負值檔頭仍可解析(負號即為顯式符號)', () => {
+ expect(
+ parseStrictHeader('> 本次分數變化:-1(reward 0、penalty 1、neutral 0)|累計總分:-3')
+ ?.delta,
+ ).toBe(-1);
+ });
+
+ it('歷史自由格式檔頭仍可取出累計總分(前版相容)', () => {
+ expect(
+ parsePreviousTotal(
+ '> 本次分數變化:+5(雙線合併:本線 reward 4+上游 reward 1)|累計總分:+170',
+ ),
+ ).toBe(170);
+ expect(
+ parseStrictHeader(
+ '> 本次分數變化:+5(雙線合併:本線 reward 4+上游 reward 1)|累計總分:+170',
+ ),
+ ).toBeNull();
+ });
+});
+
+describe('parseEntries', () => {
+ it('解析四行模板條目與 ID', () => {
+ const { entries, globalErrors } = parseEntries(HEAD_CONTENT);
+ expect(globalErrors).toEqual([]);
+ expect(entries.map((entry) => entry.id)).toEqual(['reward-existing-entry']);
+ expect(entries[0]?.errors).toEqual([]);
+ });
+
+ it('缺少條目區段時回報全域錯誤', () => {
+ const { globalErrors } = parseEntries('# 空文件');
+ expect(globalErrors).toEqual(['找不到「## 條目」區段']);
+ });
+
+ // 只解析第一個區段,故多個「## 條目」等於替後續區段開一個永久盲區:
+ // 前置 decoy 抄齊全部 ID 即可滿足刪除防護,真區段從此不受檢視。
+ it('多個「## 條目」區段時回報全域錯誤(decoy 區段盲區)', () => {
+ const decoyed = HEAD_CONTENT.replace(
+ '## 條目(新→舊)',
+ '## 條目(索引)\n\n- 日期:2026-07-07\n- ID:reward-existing-entry\n- 原因:原因\n- 解法:解法\n\n## 條目(新→舊)',
+ );
+ const { globalErrors } = parseEntries(decoyed);
+ expect(globalErrors).toEqual(['「## 條目」區段必須唯一(找到 2 個)']);
+ });
+
+ // 歷史檔有數處漏空行使多筆黏成一塊;僅靠空行切分會讓後續條目 ID 隱形。
+ it('漏空行黏成一塊時仍以「- 日期:」逐筆切分', () => {
+ const glued = HEAD_CONTENT.replace(
+ '- 日期:2026-07-07\n- ID:reward-existing-entry\n- 原因:原因\n- 解法:解法',
+ '- 日期:2026-07-07\n- ID:reward-existing-entry\n- 原因:原因\n- 解法:解法\n' +
+ '- 日期:2026-07-07\n- ID:reward-glued-entry\n- 原因:原因\n- 解法:解法',
+ );
+ const { entries } = parseEntries(glued);
+ expect(entries.map((entry) => entry.id)).toEqual([
+ 'reward-existing-entry',
+ 'reward-glued-entry',
+ ]);
+ expect(entries.every((entry) => entry.errors.length === 0)).toBe(true);
+ });
+});
+
+describe('validate002', () => {
+ it('正確案例:計數、分數變化、總分鏈全部一致', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ });
+ expect(validate002({ stagedContent: staged, headContent: HEAD_CONTENT }).errors).toEqual([]);
+ });
+
+ it('計數不符:檔頭寫 reward 2 但只新增 1 筆', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+2(reward 2、penalty 0、neutral 0)|累計總分:+172',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('不符'))).toBe(true);
+ });
+
+ it('分數變化算式錯誤:reward 1、penalty 1 應為 0', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 1、neutral 0)|累計總分:+171',
+ entries: [
+ { id: 'reward-new-entry' },
+ { id: 'penalty-new-entry' },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('本次分數變化應為 0'))).toBe(true);
+ });
+
+ it('總分斷鏈:前版 +170 加 +1 應為 +171 而非 +175', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+175',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('累計總分斷鏈'))).toBe(true);
+ });
+
+ it('ID 重複:新增條目沿用既有 ID', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-existing-entry' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('ID 重複'))).toBe(true);
+ });
+
+ it('格式錯行:新增條目缺「解法」行', () => {
+ const staged = [
+ HEAD_CONTENT.replace(
+ '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ ).replace(
+ '## 條目(新→舊)\n',
+ '## 條目(新→舊)\n\n- 日期:2026-07-07\n- ID:reward-broken-entry\n- 原因:只有三行\n',
+ ),
+ ].join('');
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('條目行數應為 4 行'))).toBe(true);
+ });
+
+ it('日期格式錯誤:非 YYYY-MM-DD', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry', date: '2026/07/07' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('日期格式應為 YYYY-MM-DD'))).toBe(true);
+ });
+
+ // 空白 ID 若留成空字串,會被 newEntries 的 truthy 篩選排除,
+ // 使該筆同時繞過前綴、唯一性、計數與總分檢查(檔頭不動即全綠)。
+ it.each([
+ ['空字串', ''],
+ ['僅空白', ' '],
+ ])('新增條目 ID 為%s時視同缺少 ID 並擋下', (_label, id) => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('條目 ID 不可為空'))).toBe(true);
+ });
+
+ // 只檢查行前綴會讓「- 原因:」/「- 解法:」的空值通過,
+ // 使缺 root cause 或 resolution 的紀錄同時通過本地與 CI 守門。
+ it.each([
+ ['原因', { reason: ' ', fix: '解法' }, '- 原因:'],
+ ['解法', { reason: '原因', fix: '' }, '- 解法:'],
+ ])('新增條目的%s欄位為空時擋下', (_label, fields, prefix) => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry', ...fields }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes(`「${prefix}」不可為空`))).toBe(true);
+ });
+
+ it('新增條目 ID 前綴不合法時擋下', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'bonus-new-entry' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: HEAD_CONTENT });
+ expect(errors.some((message) => message.includes('reward-/penalty-/neutral-'))).toBe(true);
+ });
+
+ it('初始 commit(無 HEAD 版本)跳過總分鏈但仍驗計數', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-first-entry' }],
+ });
+ expect(validate002({ stagedContent: staged, headContent: null }).errors).toEqual([]);
+ });
+
+ it('歷史條目格式問題不回溯擋 commit', () => {
+ const headWithLegacy = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-legacy-entry', date: '2026/1/1' }],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-legacy-entry', date: '2026/1/1' }],
+ });
+ expect(validate002({ stagedContent: staged, headContent: headWithLegacy }).errors).toEqual([]);
+ });
+
+ it('無新增條目且檔頭未動(如 typo 修正)不驗記分', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry', reason: '修正錯字' }],
+ });
+ expect(validate002({ stagedContent: staged, headContent: HEAD_CONTENT }).errors).toEqual([]);
+ });
+
+ it('靜默刪除歷史條目(檔頭未動)必須被擋下', () => {
+ const head = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }, { id: 'penalty-old-incident' }],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(
+ errors.some(
+ (message) =>
+ message.includes('歷史條目不可刪除') && message.includes('penalty-old-incident'),
+ ),
+ ).toBe(true);
+ });
+
+ it('刪除非標準前綴的歷史條目同樣被擋下(⊆ 檢查不限標準前綴)', () => {
+ const head = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }, { id: 'legacy-2026-incident-log' }],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(
+ errors.some(
+ (message) =>
+ message.includes('歷史條目不可刪除') && message.includes('legacy-2026-incident-log'),
+ ),
+ ).toBe(true);
+ });
+
+ it('刪除黏在同一塊(漏空行)中的歷史條目同樣被擋下', () => {
+ const glue = (content: string) =>
+ content.replace(
+ '- 日期:2026-07-07\n- ID:reward-existing-entry\n- 原因:原因\n- 解法:解法\n\n',
+ '- 日期:2026-07-07\n- ID:reward-existing-entry\n- 原因:原因\n- 解法:解法\n',
+ );
+ const head = glue(
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }, { id: 'penalty-glued-incident' }],
+ }),
+ );
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(
+ errors.some(
+ (message) =>
+ message.includes('歷史條目不可刪除') && message.includes('penalty-glued-incident'),
+ ),
+ ).toBe(true);
+ });
+
+ // 掏空既有條目 = 就地刪除,是刪除防護的等效規避路徑(保留檔案與 ID、把內容清空)。
+ it.each([
+ ['原因', { reason: '' }, '- 原因:'],
+ ['解法', { fix: ' ' }, '- 解法:'],
+ ['日期', { date: '' }, '- 日期:'],
+ ])('掏空既有條目的%s欄位必須被擋下', (_label, emptied, prefix) => {
+ const head = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [
+ { id: 'penalty-old-incident', reason: '原始原因', fix: '原始解法' },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [
+ { id: 'penalty-old-incident', reason: '原始原因', fix: '原始解法', ...emptied },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(
+ errors.some(
+ (message) =>
+ message.includes(`「${prefix}」原有內容不可清空`) &&
+ message.includes('penalty-old-incident'),
+ ),
+ ).toBe(true);
+ });
+
+ // 整行刪除(既有四行條目縮成只剩日期與 ID)與留空值同屬掏空,
+ // 不得因「歷史條目格式錯誤不回溯」而被略過。
+ it('既有條目被刪成只剩日期與 ID 必須被擋下', () => {
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const head = buildLog({
+ header,
+ entries: [
+ { id: 'penalty-old-incident', reason: '真實事故根因', fix: '真實修法' },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ const staged = head.replace('- 原因:真實事故根因\n- 解法:真實修法\n', '');
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(errors.some((message) => message.includes('「- 原因:」原有內容不可清空'))).toBe(true);
+ expect(errors.some((message) => message.includes('「- 解法:」原有內容不可清空'))).toBe(true);
+ });
+
+ // 正向對照採本 PR 真實發生的精確性修正(002 破口規模數字由 5 處/6 筆更正為 2 處/3 筆):
+ // 判準是「有沒有從有變成無」,內容改動本身不受限。
+ it('精確性修正(非空改為另一個非空)不受掏空防護影響', () => {
+ const before = {
+ id: 'reward-002-log-gate-glued-block-parsing',
+ reason:
+ '來源版解析器只用空行切分條目,但歷史 002 有 5 處漏空行使多筆黏成一塊——`block.find` 只取首個 ID,使 6 筆條目對唯一性與刪除防護隱形',
+ fix: '解析器補「- 日期:」為次要邊界(空行仍為主要邊界),納管條目由 513 升為 518',
+ };
+ const after = {
+ id: before.id,
+ reason:
+ '來源版解析器只用空行切分條目,但歷史 002 有 2 處漏空行使多筆黏成一塊(8 行 2 筆、12 行 3 筆)——`block.find` 只取首個 ID,使 3 筆條目對唯一性與刪除防護隱形',
+ fix: '解析器補「- 日期:」為次要邊界(空行仍為主要邊界),納管條目由 515 升為 518',
+ };
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const head = buildLog({ header, entries: [before, { id: 'reward-existing-entry' }] });
+ const staged = buildLog({ header, entries: [after, { id: 'reward-existing-entry' }] });
+ expect(validate002({ stagedContent: staged, headContent: head }).errors).toEqual([]);
+ });
+
+ // CASCADE:基準版本身不可解析時 parseEntries 回傳空 entries,會讓刪除檢查落入真空。
+ // 攻擊鏈為「先讓 tip 變成不可解析 → 下一個 commit 清空全部歷史 ID」,兩道閘皆綠。
+ it.each([
+ [
+ '多個「## 條目」區段',
+ '## 條目(索引)\n\n- 日期:2026-07-07\n- ID:penalty-evidence\n- 原因:原因\n- 解法:解法\n\n## 條目(新→舊)',
+ '區段必須唯一',
+ ],
+ ['區段標題被移除', '(區段標題被移除)', '找不到'],
+ ])('基準版因%s而不可解析時,清空歷史必紅(CASCADE)', (_label, poison, expectedReason) => {
+ const good = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'penalty-evidence' }, { id: 'reward-existing-entry' }],
+ });
+ const poisonedHead = good.replace('## 條目(新→舊)', poison);
+ const wiped = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-clean-slate' }],
+ });
+
+ const { errors } = validate002({ stagedContent: wiped, headContent: poisonedHead });
+ // 第一道:基準版不可解析即 fail-closed。
+ expect(
+ errors.some(
+ (message) => message.includes('基準版 002 無法解析') && message.includes(expectedReason),
+ ),
+ ).toBe(true);
+ // 第二道:原始文字掃描仍抓得到被清掉的 ID(不依賴解析成功)。
+ expect(
+ errors.some(
+ (message) => message.includes('歷史條目不可刪除') && message.includes('penalty-evidence'),
+ ),
+ ).toBe(true);
+ });
+
+ // 刪除比對加入原始文字掃描後,被「移出解析範圍」的條目仍會出現在 raw 集合,
+ // 若只比對刪除就會讓「插入 `## ` 標題截斷」「搬到區段之前」這兩條路徑復活。
+ const EVIDENCE_BLOCK = '- 日期:2026-07-07\n- ID:penalty-evidence\n- 原因:原因\n- 解法:解法';
+
+ it.each([
+ // 在目標條目之前插入 `## ` 標題,使解析在該處中止。
+ [
+ '被「## 」標題截斷',
+ (log: string) => log.replace(EVIDENCE_BLOCK, `## 附錄\n\n${EVIDENCE_BLOCK}`),
+ ],
+ // 搬到「## 條目」區段之前(先移除原位置再插入,避免二次命中)。
+ [
+ '被搬到「## 條目」之前',
+ (log: string) =>
+ log
+ .replace(`${EVIDENCE_BLOCK}\n`, '')
+ .replace('## 條目(新→舊)\n', `${EVIDENCE_BLOCK}\n\n## 條目(新→舊)\n`),
+ ],
+ ])('既有條目%s而移出解析範圍時必紅', (_label, mutate) => {
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const head = buildLog({
+ header,
+ entries: [{ id: 'reward-existing-entry' }, { id: 'penalty-evidence' }],
+ });
+ const staged = mutate(head);
+ expect(staged).not.toBe(head);
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(
+ errors.some(
+ (message) => message.includes('不可移出') && message.includes('penalty-evidence'),
+ ),
+ ).toBe(true);
+ });
+
+ // 區段外的獨立 `- ID:` 行(文件範例等)不是條目;併入刪除比對會誤傷合法的文件改寫。
+ it('移除「## 條目」區段外的獨立 ID 行不算刪除歷史條目', () => {
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const base = buildLog({ header, entries: [{ id: 'reward-existing-entry' }] });
+ const head = base.replace(
+ '## 條目(新→舊)',
+ '範例:\n- ID:penalty-ghost\n\n## 條目(新→舊)',
+ );
+ expect(validate002({ stagedContent: base, headContent: head }).errors).toEqual([]);
+ });
+
+ // 唯一性若無條件掃全檔,歷史一旦出現重複,之後每個 commit 都會被卡死(即使沒動 002)。
+ it('歷史上已存在的重複 ID 不回溯擋 commit', () => {
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const withDuplicate = buildLog({
+ header,
+ entries: [{ id: 'reward-dup' }, { id: 'reward-dup' }, { id: 'reward-existing-entry' }],
+ });
+ // 完全不動 002。
+ expect(
+ validate002({ stagedContent: withDuplicate, headContent: withDuplicate }).errors,
+ ).toEqual([]);
+ // 正常 append,歷史重複維持原樣。
+ const appended = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [
+ { id: 'reward-new-entry' },
+ { id: 'reward-dup' },
+ { id: 'reward-dup' },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ expect(validate002({ stagedContent: appended, headContent: withDuplicate }).errors).toEqual([]);
+ });
+
+ it('本次造成的重複 ID 仍必須擋下', () => {
+ const header = '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170';
+ const head = buildLog({
+ header,
+ entries: [{ id: 'reward-dup' }, { id: 'reward-dup' }, { id: 'reward-existing-entry' }],
+ });
+ // 重複度由 2 增為 3。
+ const staged = buildLog({
+ header,
+ entries: [
+ { id: 'reward-dup' },
+ { id: 'reward-dup' },
+ { id: 'reward-dup' },
+ { id: 'reward-existing-entry' },
+ ],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: head });
+ expect(errors.some((message) => message.includes('ID 重複:「reward-dup」'))).toBe(true);
+ });
+
+ it('空字串基準版走解析 fail-closed,不得當成「無基準版」', () => {
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+9999',
+ entries: [{ id: 'reward-new-entry' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: '' });
+ expect(errors.some((message) => message.includes('基準版 002 無法解析'))).toBe(true);
+ });
+
+ it('基準版讀不出累計總分時 fail-closed(不得靜默跳過總分鏈)', () => {
+ const headWithoutHeader = buildLog({
+ header: '> 版本:outline-v2-ultra',
+ entries: [{ id: 'penalty-evidence' }],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+9999',
+ entries: [{ id: 'reward-new-entry' }, { id: 'penalty-evidence' }],
+ });
+ const { errors } = validate002({ stagedContent: staged, headContent: headWithoutHeader });
+ expect(errors.some((message) => message.includes('基準版檔頭讀不出累計總分'))).toBe(true);
+ });
+
+ it('正常 append(歷史條目完整保留,含非標準前綴)不受刪除防護影響', () => {
+ const head = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-existing-entry' }, { id: 'legacy-2026-incident-log' }],
+ });
+ const staged = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [
+ { id: 'reward-new-entry' },
+ { id: 'reward-existing-entry' },
+ { id: 'legacy-2026-incident-log' },
+ ],
+ });
+ expect(validate002({ stagedContent: staged, headContent: head }).errors).toEqual([]);
+ });
+});
+
+describe('git 整合(pre-commit staged 語意/--base-ref CI 語意 issue #661)', () => {
+ const SCRIPT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'verify-002-log.mjs');
+ // 隔離全域/系統 git config(gpgsign、hooksPath 等),身分改由環境變數提供。
+ // 先剝除繼承的 GIT_* 變數:hook 環境(pre-push 跑 vitest)會注入 GIT_DIR/GIT_INDEX_FILE,
+ // 子行程若繼承會把臨時 repo 的 git 操作導向父 repo。
+ const GIT_ENV = {
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))),
+ GIT_CONFIG_GLOBAL: '/dev/null',
+ GIT_CONFIG_SYSTEM: '/dev/null',
+ GIT_AUTHOR_NAME: 'vitest',
+ GIT_AUTHOR_EMAIL: 'vitest@example.com',
+ GIT_COMMITTER_NAME: 'vitest',
+ GIT_COMMITTER_EMAIL: 'vitest@example.com',
+ };
+ const repos: string[] = [];
+
+ afterEach(() => {
+ for (const repo of repos.splice(0)) {
+ rmSync(repo, { recursive: true, force: true });
+ }
+ });
+
+ function git(cwd: string, ...args: string[]) {
+ // stderr 收進 pipe,避免 checkout 等訊息污染測試輸出。
+ return execFileSync('git', args, {
+ cwd,
+ env: GIT_ENV,
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ }
+
+ function commitLog(repo: string, content: string, message: string) {
+ writeFileSync(join(repo, LOG_PATH), content);
+ git(repo, 'add', '--all');
+ git(repo, 'commit', '-m', message);
+ }
+
+ // 建立 main 上有基準 002 的臨時 repo,並切到 pr 分支模擬 PR HEAD。
+ function setupRepo() {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ mkdirSync(join(repo, dirname(LOG_PATH)), { recursive: true });
+ commitLog(repo, HEAD_CONTENT, 'init');
+ git(repo, 'checkout', '-b', 'pr');
+ return repo;
+ }
+
+ function runScript(repo: string, scriptPath: string, ...args: string[]) {
+ try {
+ const stdout = execFileSync('node', [scriptPath, ...args], {
+ cwd: repo,
+ env: GIT_ENV,
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return { status: 0, output: stdout };
+ } catch (error) {
+ const failed = error as { status?: number | null; stdout?: string; stderr?: string };
+ return {
+ status: failed.status ?? 1,
+ output: `${failed.stdout ?? ''}${failed.stderr ?? ''}`,
+ };
+ }
+ }
+
+ function runGuard(repo: string, ...args: string[]) {
+ return runScript(repo, SCRIPT_PATH, ...args);
+ }
+
+ it('構造計數不符的 PR 最終態必紅', () => {
+ const repo = setupRepo();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+2(reward 2、penalty 0、neutral 0)|累計總分:+172',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ }),
+ 'bad count',
+ );
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(1);
+ expect(output).toContain('不符');
+ });
+
+ it('正確 append 的 PR 綠燈', () => {
+ const repo = setupRepo();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ }),
+ 'good append',
+ );
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+
+ it('002 未變更的 PR 跳過(零額外負擔)', () => {
+ const repo = setupRepo();
+ writeFileSync(join(repo, 'other.txt'), 'unrelated');
+ git(repo, 'add', '--all');
+ git(repo, 'commit', '-m', 'unrelated');
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(0);
+ expect(output).toContain('跳過');
+ });
+
+ it('多 commit PR 只驗最終態 vs merge-base(中間態不一致不擋)', () => {
+ const repo = setupRepo();
+ // 中間 commit:新增條目但檔頭未同步(單看此 commit 會紅)。
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'reward-step-one' }, { id: 'reward-existing-entry' }],
+ }),
+ 'step 1',
+ );
+ // 最終 commit:整體對帳一致(新增 2 筆、+2、總分 172)。
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+2(reward 2、penalty 0、neutral 0)|累計總分:+172',
+ entries: [
+ { id: 'reward-step-two' },
+ { id: 'reward-step-one' },
+ { id: 'reward-existing-entry' },
+ ],
+ }),
+ 'step 2',
+ );
+ const { status } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(0);
+ });
+
+ it('base 分支前進後仍以 merge-base 為基準(非 base tip)', () => {
+ const repo = setupRepo();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-from-pr' }, { id: 'reward-existing-entry' }],
+ }),
+ 'pr entry',
+ );
+ git(repo, 'checkout', 'main');
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-from-main' }, { id: 'reward-existing-entry' }],
+ }),
+ 'main entry',
+ );
+ git(repo, 'checkout', 'pr');
+ const { status } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(0);
+ });
+
+ // main push 兜底若取 merge-base,force push 時 before 並非 HEAD 的祖先,
+ // 基準會退到更早的共同祖先,使被改寫掉的 penalty 條目驗不出來。
+ it('--base-commit:force push 改寫掉基準與祖先之間的條目必紅', () => {
+ const repo = setupRepo();
+ const ancestor = git(repo, 'rev-parse', 'HEAD').trim();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:-1(reward 0、penalty 1、neutral 0)|累計總分:+169',
+ entries: [{ id: 'penalty-evidence' }, { id: 'reward-existing-entry' }],
+ }),
+ 'add penalty evidence',
+ );
+ const before = git(repo, 'rev-parse', 'HEAD').trim();
+
+ // 改寫歷史:回到 ancestor 後另起一條不含 penalty-evidence 的線。
+ git(repo, 'reset', '--hard', ancestor);
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-rewritten' }, { id: 'reward-existing-entry' }],
+ }),
+ 'force-pushed rewrite',
+ );
+
+ // merge-base 模式會退回 ancestor 而看不見 penalty-evidence。
+ expect(runGuard(repo, '--base-ref', before).status).toBe(0);
+ // 直接以 before 為基準才驗得出刪除。
+ const { status, output } = runGuard(repo, '--base-commit', before);
+ expect(status).toBe(1);
+ expect(output).toContain('penalty-evidence');
+ });
+
+ // 兩 flag 基準取法不同,靜默取其一會讓誤用得到假綠(原實作 --base-ref 永遠勝出)。
+ it('--base-ref 與 --base-commit 同時指定時互斥失敗', () => {
+ const repo = setupRepo();
+ const { status, output } = runGuard(repo, '--base-ref', 'main', '--base-commit', 'main');
+ expect(status).toBe(1);
+ expect(output).toContain('互斥');
+ });
+
+ // E2E:poison tip 後 wipe,pre-commit(staged vs HEAD)也必須紅。
+ it('pre-commit:基準 HEAD 被 poison 後清空歷史必紅(CASCADE E2E)', () => {
+ const repo = setupRepo();
+ const good = buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'penalty-evidence' }, { id: 'reward-existing-entry' }],
+ });
+ commitLog(
+ repo,
+ good.replace('## 條目(新→舊)', '## 條目(索引)\n\n## 條目(新→舊)'),
+ 'poison',
+ );
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-clean-slate' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('penalty-evidence');
+ });
+
+ function runIsolated(repo: string, env: NodeJS.ProcessEnv) {
+ try {
+ const stdout = execFileSync(process.execPath, [SCRIPT_PATH], {
+ cwd: repo,
+ env,
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ return { status: 0, output: stdout };
+ } catch (error) {
+ const failed = error as { status?: number | null; stdout?: string; stderr?: string };
+ return { status: failed.status ?? 1, output: `${failed.stdout ?? ''}${failed.stderr ?? ''}` };
+ }
+ }
+
+ // git 對「物件不存在」與「repo 不可用」都回 status 128;若不靠 stderr 區分,
+ // 環境失敗會被誤讀成「檔案不存在」而讓整道守門靜默跳過 exit 0。
+ it('git 不可執行(PATH 無 git)時 fail-closed', () => {
+ const repo = setupRepo();
+ const isolated = mkdtempSync(join(tmpdir(), 'verify-002-nogit-'));
+ repos.push(isolated);
+ mkdirSync(join(isolated, 'bin'));
+ symlinkSync(process.execPath, join(isolated, 'bin', 'node'));
+
+ const { status, output } = runIsolated(repo, { ...GIT_ENV, PATH: join(isolated, 'bin') });
+ expect(status).toBe(1);
+ expect(output).toContain('執行期例外');
+ });
+
+ it('git 存在但永遠失敗(壞 stub,status=1)時 fail-closed', () => {
+ const repo = setupRepo();
+ const isolated = mkdtempSync(join(tmpdir(), 'verify-002-stub-'));
+ repos.push(isolated);
+ mkdirSync(join(isolated, 'bin'));
+ symlinkSync(process.execPath, join(isolated, 'bin', 'node'));
+ writeFileSync(join(isolated, 'bin', 'git'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
+
+ const { status, output } = runIsolated(repo, { ...GIT_ENV, PATH: join(isolated, 'bin') });
+ expect(status).toBe(1);
+ expect(output).toContain('執行期例外');
+ });
+
+ it('GIT_DIR 指向無效路徑(not a git repository)時 fail-closed', () => {
+ const repo = setupRepo();
+ const { status, output } = runIsolated(repo, {
+ ...GIT_ENV,
+ GIT_DIR: join(tmpdir(), 'verify-002-no-such-git-dir'),
+ });
+ expect(status).toBe(1);
+ expect(output).toContain('not a git repository');
+ });
+
+ it('.git 不可讀(權限)時 fail-closed', () => {
+ const repo = setupRepo();
+ chmodSync(join(repo, '.git'), 0o000);
+ try {
+ const { status, output } = runIsolated(repo, GIT_ENV);
+ expect(status).toBe(1);
+ expect(output).toContain('not a git repository');
+ } finally {
+ // 還原權限,否則 afterEach 的 rmSync 清不掉臨時 repo。
+ chmodSync(join(repo, '.git'), 0o755);
+ }
+ });
+
+ // stderr 文字判別依賴英文輸出,而 git 內建 gettext 翻譯會跟隨呼叫端 locale。
+ // 這條是 CI 上真正有效的回歸鎖:新增第五處 git 呼叫時若忘了帶 env 就會紅。
+ // 舊版逐一檢查每個呼叫點是否帶 env,是字串偵測、可用雙引號/spawnSync/間接呼叫繞過。
+ // 改為結構收斂:全檔只允許一個子行程呼叫點,且必須在帶 GIT_ENV 的 git() wrapper 內,
+ // 讓「忘記帶 env」不可能發生而非事後偵測。
+ // 字串偵測可被 `import { execFileSync as run }`、`cp['execFileSync'](` 繞過,
+ // 故走 AST:限制 child_process 的匯入形式,並確認所有呼叫都落在 git() wrapper 內。
+ // 已知且被堵住的間接繞法(各有 mutation 元測試釘住,不得退化):
+ // - 變數別名(const fn = execFileSync):綁定識別字只能作 import 綁定或直接呼叫 callee
+ // - eval/new Function/getBuiltinModule/require/createRequire:識別字全檔禁止
+ // - 動態 import:ImportKeyword 呼叫全檔禁止
+ // - 字串夾帶(cp['execFileSync']、eval('execFileSync')):字串字面量不得含子行程 API 名
+ // - env 只留在註解:options 的 env 屬性以 AST PropertyAssignment 驗證,不受註解字面影響
+ // 能力邊界(書面出界;以下類別均不在本鎖防線內,一律交由 code review 把關):
+ // - 字串拼接/template literal 組名('execFile'+'Sync'、`${'child_process'}`)
+ // - Reflect.get(globalThis, …) 等反射取用
+ // - node:vm 動態執行、worker_threads 旁路執行、process.binding 底層綁定
+ // 這些同屬蓄意逃逸,防禦成本遠高於收益且窮舉不完;
+ // 本鎖的目標是讓「無意漂移」與「直觀規避」在結構上必紅。
+ function auditGitWrapperStructure(source: string): string[] {
+ const violations: string[] = [];
+ const sourceFile = ts.createSourceFile(
+ 'verify-002-log.mjs',
+ source,
+ ts.ScriptTarget.ESNext,
+ true,
+ ts.ScriptKind.JS,
+ );
+
+ // 1) child_process 只能具名且未改名匯入;namespace/default 匯入一律禁止——
+ // `cp['execFileSync'](…)` 這類存取無法靠名稱追蹤。
+ // specifier 匹配放寬為「路徑段含 child_process 者皆納入檢查」(含帶子路徑形式,
+ // 如 node:child_process/promises)。誠實標註:該子路徑在現行 Node 並不是內建
+ // 模組(寫入會 ERR_UNKNOWN_BUILTIN_MODULE 使守門載入失敗、大聲 fail-closed),
+ // 放寬屬防禦性加固而非修補已知繞過。
+ const boundNames = new Set();
+ for (const statement of sourceFile.statements) {
+ if (!ts.isImportDeclaration(statement)) continue;
+ if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
+ if (!/(^|[:/])child_process(\/|$)/.test(statement.moduleSpecifier.text)) continue;
+
+ if (statement.importClause?.name) violations.push('child_process 禁止 default 匯入');
+ const bindings = statement.importClause?.namedBindings;
+ if (!bindings || !ts.isNamedImports(bindings)) {
+ violations.push('child_process 只能具名匯入(禁止 namespace 匯入)');
+ continue;
+ }
+ for (const element of bindings.elements) {
+ if (element.propertyName) {
+ violations.push(`child_process 匯入不得改名:${element.name.text}`);
+ }
+ boundNames.add(element.name.text);
+ }
+ }
+ if ([...boundNames].join(',') !== 'execFileSync') {
+ violations.push(
+ `child_process 匯入必須恰為 execFileSync(實際:${[...boundNames].join('、') || '無'})`,
+ );
+ }
+
+ // 2) GIT_ENV 定義本身也走 AST:字串比對會被「定義行照抄進註解」假陽性繞過。
+ const gitEnvDecl = sourceFile.statements
+ .filter(ts.isVariableStatement)
+ .flatMap((statement) => [...statement.declarationList.declarations])
+ .find((decl) => ts.isIdentifier(decl.name) && decl.name.text === 'GIT_ENV');
+ if (!gitEnvDecl?.initializer || !ts.isObjectLiteralExpression(gitEnvDecl.initializer)) {
+ violations.push('GIT_ENV 必須是頂層物件字面量定義');
+ } else {
+ const props = gitEnvDecl.initializer.properties;
+ const hasSpreadProcessEnv = props.some(
+ (prop) =>
+ ts.isSpreadAssignment(prop) &&
+ ts.isPropertyAccessExpression(prop.expression) &&
+ ts.isIdentifier(prop.expression.expression) &&
+ prop.expression.expression.text === 'process' &&
+ prop.expression.name.text === 'env',
+ );
+ if (!hasSpreadProcessEnv) violations.push('GIT_ENV 必須展開 process.env');
+ for (const key of ['LC_ALL', 'LANGUAGE']) {
+ const assignment = props.find(
+ (prop): prop is ts.PropertyAssignment =>
+ ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name) && prop.name.text === key,
+ );
+ if (
+ !assignment ||
+ !ts.isStringLiteral(assignment.initializer) ||
+ assignment.initializer.text !== 'C'
+ ) {
+ violations.push(`GIT_ENV 必須含 ${key}: 'C'`);
+ }
+ }
+ }
+
+ // 3) git() wrapper 的範圍。
+ const wrapper = sourceFile.statements.find(
+ (statement): statement is ts.FunctionDeclaration =>
+ ts.isFunctionDeclaration(statement) && statement.name?.text === 'git',
+ );
+ if (!wrapper) {
+ violations.push('找不到 git() wrapper');
+ return violations;
+ }
+
+ // 4) 全檔走訪:動態逃逸口、字串夾帶、綁定引用形式、wrapper 內 options 的 env 屬性。
+ const forbiddenIdentifiers = new Set([
+ 'eval',
+ 'Function',
+ 'getBuiltinModule',
+ 'require',
+ 'createRequire',
+ ]);
+ let wrapperCallCount = 0;
+ const visit = (node: ts.Node) => {
+ if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
+ violations.push('禁止動態 import');
+ }
+ if (ts.isIdentifier(node) && forbiddenIdentifiers.has(node.text)) {
+ violations.push(`禁止出現識別字「${node.text}」`);
+ }
+ if (
+ (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) &&
+ !(ts.isImportDeclaration(node.parent) && node.parent.moduleSpecifier === node) &&
+ /execFileSync|child_process/.test(node.text)
+ ) {
+ violations.push(`字串字面量不得夾帶子行程 API 名:「${node.text}」`);
+ }
+ if (ts.isIdentifier(node) && boundNames.has(node.text)) {
+ const parent = node.parent;
+ const isImportBinding = ts.isImportSpecifier(parent);
+ const isDirectCallee = ts.isCallExpression(parent) && parent.expression === node;
+ if (!isImportBinding && !isDirectCallee) {
+ violations.push('execFileSync 只能作為直接呼叫的 callee,不得取別名或間接引用');
+ } else if (isDirectCallee) {
+ const call = parent as ts.CallExpression;
+ if (node.pos < wrapper.pos || node.end > wrapper.end) {
+ violations.push('execFileSync 呼叫必須位於 git() wrapper 內');
+ } else {
+ wrapperCallCount += 1;
+ const options = call.arguments[2];
+ const envProp =
+ options && ts.isObjectLiteralExpression(options)
+ ? options.properties.find(
+ (prop): prop is ts.PropertyAssignment =>
+ ts.isPropertyAssignment(prop) &&
+ ts.isIdentifier(prop.name) &&
+ prop.name.text === 'env',
+ )
+ : undefined;
+ if (
+ !envProp ||
+ !ts.isIdentifier(envProp.initializer) ||
+ envProp.initializer.text !== 'GIT_ENV'
+ ) {
+ violations.push('execFileSync options 必須有 AST 可驗的 env: GIT_ENV 屬性');
+ }
+ }
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sourceFile);
+ if (wrapperCallCount === 0) {
+ violations.push('git() wrapper 內必須至少有一次 execFileSync 呼叫');
+ }
+
+ return violations;
+ }
+
+ it('git 子行程呼叫必須收斂在唯一帶 GIT_ENV 的 wrapper(AST 級)', () => {
+ const source = readFileSync(SCRIPT_PATH, 'utf-8');
+ expect(auditGitWrapperStructure(source)).toEqual([]);
+ });
+
+ // mutation 元測試:把每條已知繞法寫成變異版,audit 必須回報違規(防守門自身退化)。
+ // 變異只做 AST 解析、不執行,語意合法與否不影響測試目的。
+ it.each([
+ [
+ '變數別名(const fn = execFileSync)',
+ (source: string) => `${source}\nconst fn = execFileSync;\nfn(['status']);\n`,
+ '不得取別名',
+ ],
+ [
+ 'eval 字串夾帶(eval 識別字)',
+ (source: string) => `${source}\nconst hijacked = eval('spawn');\n`,
+ '禁止出現識別字「eval」',
+ ],
+ [
+ 'new Function 動態產碼',
+ (source: string) => `${source}\nconst maker = new Function('return 1');\n`,
+ '禁止出現識別字「Function」',
+ ],
+ [
+ 'process.getBuiltinModule 取模組',
+ (source: string) => `${source}\nconst cp = process.getBuiltinModule('node:fs');\n`,
+ '禁止出現識別字「getBuiltinModule」',
+ ],
+ [
+ '動態 import',
+ (source: string) => `${source}\nconst lazy = import('node:fs');\n`,
+ '禁止動態 import',
+ ],
+ [
+ '字串夾帶 API 名(cp[key] 的前置)',
+ (source: string) => `${source}\nconst key = 'execFileSync';\n`,
+ '字串字面量不得夾帶子行程 API 名',
+ ],
+ [
+ 'wrapper 外直呼',
+ (source: string) =>
+ `${source}\nexecFileSync('git', ['status'], { env: GIT_ENV, encoding: 'utf-8' });\n`,
+ '必須位於 git() wrapper 內',
+ ],
+ [
+ 'renamed import(execFileSync as run)',
+ (source: string) =>
+ source.replace(
+ "import { execFileSync } from 'node:child_process';",
+ "import { execFileSync as run } from 'node:child_process';",
+ ),
+ '不得改名',
+ ],
+ [
+ 'namespace import(* as cp)',
+ (source: string) =>
+ source.replace(
+ "import { execFileSync } from 'node:child_process';",
+ "import * as cp from 'node:child_process';",
+ ),
+ '只能具名匯入',
+ ],
+ [
+ 'env 只留在註解、options 實際未傳(字串 includes 的假陽性路徑)',
+ (source: string) => source.replace(' env: GIT_ENV,\n', ' // env: GIT_ENV\n'),
+ 'env: GIT_ENV 屬性',
+ ],
+ [
+ 'GIT_ENV 定義不再展開 process.env',
+ (source: string) =>
+ source.replace(
+ "const GIT_ENV = { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' };",
+ "const GIT_ENV = { LC_ALL: 'C', LANGUAGE: 'C' };",
+ ),
+ '必須展開 process.env',
+ ],
+ [
+ 'GIT_ENV 的 LC_ALL 被改弱',
+ (source: string) => source.replace("LC_ALL: 'C'", "LC_ALL: 'en_US.UTF-8'"),
+ "GIT_ENV 必須含 LC_ALL: 'C'",
+ ],
+ [
+ 'GIT_ENV 的 LANGUAGE 被改弱',
+ (source: string) => source.replace("LANGUAGE: 'C'", "LANGUAGE: 'zh_TW'"),
+ "GIT_ENV 必須含 LANGUAGE: 'C'",
+ ],
+ [
+ // 該子路徑在現行 Node 不存在(寫入即載入失敗、大聲 fail-closed);
+ // 此 mutation 釘的是防禦性加固後的 specifier 匹配範圍,非已知繞過。
+ '帶子路徑的 child_process specifier(node:child_process/promises)',
+ (source: string) => `${source}\nimport { execFile } from 'node:child_process/promises';\n`,
+ '恰為 execFileSync',
+ ],
+ [
+ 'require 注入',
+ (source: string) => `${source}\nconst cp3 = require('node:fs');\n`,
+ '禁止出現識別字「require」',
+ ],
+ [
+ 'createRequire 注入',
+ (source: string) => `${source}\nconst req = createRequire(import.meta.url);\n`,
+ '禁止出現識別字「createRequire」',
+ ],
+ ])('AST 鎖 mutation:%s 必紅', (_label, mutate, expectedViolation) => {
+ const source = readFileSync(SCRIPT_PATH, 'utf-8');
+ const mutated = mutate(source);
+ expect(mutated).not.toBe(source);
+ const violations = auditGitWrapperStructure(mutated);
+ expect(violations.some((message) => message.includes(expectedViolation))).toBe(true);
+ });
+
+ // 訊息比對曾連續三次失敗(漏訊息、依賴英文、又漏一種);改用 ls-files/ls-tree 後
+ // 「不存在」是 exit 0 的空輸出而非例外,不得再退回比對 git 的 fatal 訊息。
+ // 禁的是「cat-file -e + fatal 訊息比對」這條路徑;`cat-file --batch-check` 的
+ // 結構化輸出(object store 探測)是合法用途,不在禁止之列。
+ it('存在性判定不得依賴 git 的 fatal 訊息比對', () => {
+ const source = readFileSync(SCRIPT_PATH, 'utf-8');
+ expect(source).toContain("git(['ls-files'");
+ expect(source).toContain("git(['ls-tree'");
+ // 只看可執行行:註解會提到 cat-file -e 等字樣作為「不採用」的反面說明。
+ const code = source
+ .split('\n')
+ .filter((line) => !line.trimStart().startsWith('//') && !line.trimStart().startsWith('*'))
+ .join('\n');
+ for (const forbidden of [
+ "'cat-file', '-e'",
+ 'cat-file -e',
+ 'does not exist',
+ 'invalid object name',
+ ]) {
+ expect(code).not.toContain(forbidden);
+ }
+ });
+
+ // 行為對照:在有 git 翻譯的機器上這是真測試;沒有翻譯的環境 git 本來就輸出英文,
+ // 測試不會誤紅也不會提供額外保證,故與上面的結構鎖搭配使用。
+ const TRANSLATED_LOCALE = { LC_ALL: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN' };
+
+ it('非英文 locale 下「尚無 commit」仍正確跳過(不得誤擋初始 commit)', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-locale-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ mkdirSync(join(repo, dirname(LOG_PATH)), { recursive: true });
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-first-entry' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+
+ const { status, output } = runIsolated(repo, { ...GIT_ENV, ...TRANSLATED_LOCALE });
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+
+ it('非英文 locale 下 staged 刪除仍必紅', () => {
+ const repo = setupRepo();
+ git(repo, 'rm', '--cached', LOG_PATH);
+ const { status, output } = runIsolated(repo, { ...GIT_ENV, ...TRANSLATED_LOCALE });
+ expect(status).toBe(1);
+ expect(output).toContain('不可刪除');
+ });
+
+ // 已有 commit 的 repo 首次引入 002:工作區與 index 有檔、HEAD/基準 tree 無檔。
+ // `cat-file -e` 對此回「exists on disk, but not in ']['」,與 index 版訊息不同,
+ // 是訊息比對法漏掉的第五種;改用 ls-tree 後直接是 exit 0 空輸出。
+ it('pre-commit:repo 已有 commit、首次加入 002 應放行', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-first-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ mkdirSync(join(repo, dirname(LOG_PATH)), { recursive: true });
+ writeFileSync(join(repo, 'other.txt'), 'x');
+ git(repo, 'add', '--all');
+ git(repo, 'commit', '-m', 'init');
+
+ // 002 只在工作區與 index,不在 HEAD。
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-first-entry' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+
+ it('--base-ref:PR 首次加入 002(基準 tree 無檔、工作區有檔)應放行', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-first-ci-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ mkdirSync(join(repo, dirname(LOG_PATH)), { recursive: true });
+ writeFileSync(join(repo, 'other.txt'), 'x');
+ git(repo, 'add', '--all');
+ git(repo, 'commit', '-m', 'init');
+ git(repo, 'checkout', '-b', 'pr');
+
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-first-entry' }],
+ }),
+ 'introduce 002',
+ );
+
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+
+ // `git checkout --orphan` 之後 HEAD 指向尚未存在的分支,`rev-parse --verify` 失敗,
+ // 但 repo 的歷史 commit 都還在。若把它當成「無基準版」,刪除防護與總分鏈整個跳過——
+ // 標準 Git 指令即可觸發,不需劫持環境。
+ it('orphan HEAD 下掏空 002 必紅(不得誤判為無基準版)', () => {
+ const repo = setupRepo();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'penalty-must-survive' }, { id: 'reward-existing-entry' }],
+ }),
+ 'baseline',
+ );
+ git(repo, 'checkout', '--orphan', 'evil');
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-clean-slate' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('不可視為無基準版');
+ });
+
+ // 更深的繞法:orphan 之後把 named refs 全刪(branch -D+清 packed-refs),
+ // `rev-list --all` 為空但歷史 commit 物件仍在。判準必須逐層兜底
+ //(refs/reflog/object store 三層):refs 被刪光由 reflog 接住,
+ // reflog 也被 expire 時由 object store 探測接住。
+ function setupRefsWipedOrphan() {
+ const repo = setupRepo();
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+170',
+ entries: [{ id: 'penalty-must-survive' }, { id: 'reward-existing-entry' }],
+ }),
+ 'baseline',
+ );
+ git(repo, 'checkout', '--orphan', 'evil');
+ git(repo, 'branch', '-D', 'main');
+ git(repo, 'branch', '-D', 'pr');
+ rmSync(join(repo, '.git', 'packed-refs'), { force: true });
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-clean-slate' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+ return repo;
+ }
+
+ it('orphan+刪光 named refs 後掏空 002 仍必紅(reflog 探測)', () => {
+ const repo = setupRefsWipedOrphan();
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('不可視為無基準版');
+ });
+
+ it('orphan+刪光 named refs+expire reflog 後掏空 002 仍必紅(object store 探測)', () => {
+ const repo = setupRefsWipedOrphan();
+ git(repo, 'reflog', 'expire', '--expire=now', '--all');
+ rmSync(join(repo, '.git', 'logs'), { recursive: true, force: true });
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('不可視為無基準版');
+ });
+
+ it('真正尚無任何 commit 的 repo 仍放行(初始 commit)', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-empty-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ mkdirSync(join(repo, dirname(LOG_PATH)), { recursive: true });
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-first-entry' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+
+ it('--base-commit:基準 commit 無法解析時明確失敗', () => {
+ const repo = setupRepo();
+ const { status, output } = runGuard(repo, '--base-commit', 'deadbeefdeadbeefdeadbeef');
+ expect(status).toBe(1);
+ expect(output).toContain('無法解析基準 commit');
+ });
+
+ // `git merge-base` 以 exit 1 表示「無共同祖先」;orphan/unrelated histories 的 PR
+ // 仍必須 fail-closed,且訊息可診斷(非通用「無法解析」——那會誤導成 ref 打錯)。
+ it('--base-ref:與基準無共同祖先(orphan PR)時 fail-closed 且訊息可診斷', () => {
+ const repo = setupRepo();
+ git(repo, 'checkout', '--orphan', 'unrelated');
+ commitLog(
+ repo,
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+1',
+ entries: [{ id: 'reward-orphan-root' }],
+ }),
+ 'orphan root',
+ );
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(1);
+ expect(output).toContain('無共同祖先');
+ });
+
+ it('刪除 002 檔案的 PR 必紅', () => {
+ const repo = setupRepo();
+ git(repo, 'rm', LOG_PATH);
+ git(repo, 'commit', '-m', 'delete log');
+ const { status, output } = runGuard(repo, '--base-ref', 'main');
+ expect(status).toBe(1);
+ expect(output).toContain('不可刪除');
+ });
+
+ // pre-commit 語意讀 index:staged 刪除會讓 `git show :` 取不到內容,
+ // 不得與「與 002 無關的 commit」同樣靜默跳過。
+ it('pre-commit:staged 刪除 002(git rm --cached)必紅', () => {
+ const repo = setupRepo();
+ git(repo, 'rm', '--cached', LOG_PATH);
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('不可刪除');
+ });
+
+ // `git mv` 的 `--name-only` 只列新路徑,舊路徑不出現;腳本層以 index/HEAD 存在性
+ // 判定故仍必紅,但 hook 觸發條件不得再依賴 diff 呈現(見「守門觸發面」)。
+ it('pre-commit:git mv 改名 002 必紅', () => {
+ const repo = setupRepo();
+ git(repo, 'mv', LOG_PATH, join(dirname(LOG_PATH), 'renamed.md'));
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(1);
+ expect(output).toContain('不可刪除');
+ });
+
+ it('pre-commit:002 不存在於 index 與 HEAD 時跳過', () => {
+ const repo = mkdtempSync(join(tmpdir(), 'verify-002-'));
+ repos.push(repo);
+ git(repo, 'init', '-b', 'main');
+ writeFileSync(join(repo, 'other.txt'), 'unrelated');
+ git(repo, 'add', '--all');
+ git(repo, 'commit', '-m', 'no log file');
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(0);
+ expect(output).toContain('跳過');
+ });
+
+ // 以 symlink 路徑呼叫時,argv[1] 與 import.meta.url 的字面路徑不等(macOS 的 /tmp
+ // 即為 /private/tmp 的 symlink);未解析 realpath 會讓 main() 不執行卻 exit 0。
+ it('經 symlink 路徑呼叫仍執行 main(不得靜默 exit 0)', () => {
+ const repo = setupRepo();
+ const holder = mkdtempSync(join(tmpdir(), 'verify-002-link-'));
+ repos.push(holder);
+ mkdirSync(join(holder, 'real'));
+ copyFileSync(SCRIPT_PATH, join(holder, 'real', 'guard.mjs'));
+ symlinkSync(join(holder, 'real'), join(holder, 'link'), 'dir');
+
+ const { status, output } = runScript(repo, join(holder, 'link', 'guard.mjs'));
+ expect(status).toBe(0);
+ // 靜默不執行時 stdout 為空;有輸出才證明 main() 真的跑了。
+ expect(output).toContain('002 記分守門');
+ });
+
+ it('pre-commit:正確 append 綠燈', () => {
+ const repo = setupRepo();
+ writeFileSync(
+ join(repo, LOG_PATH),
+ buildLog({
+ header: '> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+171',
+ entries: [{ id: 'reward-new-entry' }, { id: 'reward-existing-entry' }],
+ }),
+ );
+ git(repo, 'add', '--all');
+ const { status, output } = runGuard(repo);
+ expect(status).toBe(0);
+ expect(output).toContain('通過');
+ });
+});
+
+// 守門的兩個「觸發面」——hook 條件與 CI 條件——是實際的破口所在:
+// 腳本層判定正確不代表會被叫起來。這組鎖住觸發條件本身。
+describe('守門觸發面(hook 條件與 CI 條件)', () => {
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+ const read = (relative: string) => readFileSync(join(REPO_ROOT, relative), 'utf-8');
+
+ it('pre-commit 第 6 步無條件執行守門,不得以 git diff 判斷觸發', () => {
+ const hook = read('.husky/pre-commit');
+ const markerIndex = hook.indexOf('# 6. 002 記分守門');
+ expect(markerIndex).toBeGreaterThan(-1);
+
+ // 只看可執行行;註解本身會提到 git diff 作為反面說明。
+ const step = hook
+ .slice(markerIndex)
+ .split('\n')
+ .filter((line) => !line.trimStart().startsWith('#'))
+ .join('\n');
+ expect(step).toContain('node scripts/verify-002-log.mjs');
+ // `git mv` 的 --name-only 只列新路徑,任何 diff-based 觸發條件都會被它繞過。
+ expect(step).not.toContain('git diff');
+ });
+
+ // `isDirectRun` 的 realpathSync 失敗只在 argv[1] 於載入後失效時發生,執行期無法穩定構造
+ // (node 必須先讀到該檔才能執行),故以結構鎖取代行為測試:一旦有人把 catch 加回去,
+ // 判不出直跑時就會靜默不執行 main() 而 exit 0(假成功)。
+ it('isDirectRun 不得吞掉 realpath 例外', () => {
+ const source = read('scripts/verify-002-log.mjs');
+ const start = source.indexOf('function isDirectRun()');
+ expect(start).toBeGreaterThan(-1);
+ const body = source.slice(start, source.indexOf('\n}', start));
+ expect(body).toContain('realpathSync');
+ expect(body).not.toContain('catch');
+ });
+
+ // 本 PR 內發生過四次「文件/註解與實作漂移」,每次都是靠人記得去掃某個檔案。
+ // 這裡把它變成機械檢查:固定範圍 × 固定的已被取代措辭清單,任一命中即紅。
+ // 新增/改寫守門行為時,把被取代的舊措辭加進這張表,範圍有新檔案就加進清單。
+ const GATE_FILES = [
+ 'scripts/verify-002-log.mjs',
+ 'scripts/__tests__/verify-002-log.test.ts',
+ '.husky/pre-commit',
+ '.github/workflows/ci.yml',
+ 'AGENTS.md',
+ 'CLAUDE.md',
+ ];
+ // 改用 regex 而非字面字串:實測發現同一個主張只要在兩個關鍵詞之間插入不同修飾語,
+ // 字面比對就會漏接。改以主張的關鍵骨架比對,可涵蓋自然改寫。
+ //
+ // 但要誠實看待這個鎖的能力邊界:它只擋得住「已知的舊主張」,擋不住全新的錯誤敘述,
+ // 而放寬到能涵蓋所有改寫就會誤傷「刻意描述被否決做法」的正確文字(本守門的註解就
+ // 大量這樣寫)。真正的防線是鎖住行為的結構測試(例如下方「不得依賴 fatal 訊息比對」
+ // 與 AST 級 wrapper 鎖);這份清單是補漏用的衛生檢查,不是漂移偵測器。
+ const SUPERSEDED_PATTERNS = [
+ /約 50ms/, // 開銷已改為相對比較,不記具體數字
+ /p50 約 120/, // 同上
+ /毫秒級/, // 同上
+ /基準版無法解析時跳過/, // 已改 fail-closed
+ /不依賴解析是否成功/, // 基準版可解析時只採解析結果
+ /僅 002 檔變更時/, // hook 已改無條件執行
+ /AGT-LOG-04/, // 殘餘風險已移出控制矩陣、取消編號
+ /窮舉[^,。\n]{0,12}(四種|訊息)/, // 存在性判定已改結構化探測
+ /(必須|判別[^,。\n]{0,8})靠\s*stderr/, // 同上:不再以 stderr 文字判別
+ /rev-parse 失敗只可能是/, // orphan HEAD 下此宣稱為假
+ /SUPERSEDED_PHRASES/, // 本清單的舊常數名,已改為 SUPERSEDED_PATTERNS
+ /rev-list -n 1 --all`/, // 舊「無基準」判準(--all 即結尾);現行為 --all --reflog + object store
+ ];
+
+ // 本檔自身也在掃描範圍內,故需剔除上面那份清單的字面值,否則必然自我命中。
+ const stripChecklist = (source: string) => {
+ const start = source.indexOf('const SUPERSEDED_PATTERNS');
+ if (start === -1) return source;
+ return source.slice(0, start) + source.slice(source.indexOf('];', start));
+ };
+
+ it.each(GATE_FILES)('%s 不得殘留已被取代的措辭', (file) => {
+ const source = stripChecklist(read(file));
+ const hits = SUPERSEDED_PATTERNS.filter((pattern) => pattern.test(source)).map(String);
+ expect(hits).toEqual([]);
+ });
+
+ it('ci.yml 對 pull_request 與 main push 都掛守門,且置於 install 之前', () => {
+ const workflow = read('.github/workflows/ci.yml');
+ const prStep = workflow.indexOf('--base-ref "${{ github.event.pull_request.base.sha }}"');
+ const pushStep = workflow.indexOf('--base-commit "${{ github.event.before }}"');
+ const install = workflow.indexOf('pnpm install --frozen-lockfile');
+
+ expect(prStep).toBeGreaterThan(-1);
+ expect(pushStep).toBeGreaterThan(-1);
+ // 零 npm 依賴,必須搶在 install 前紅燈。
+ expect(prStep).toBeLessThan(install);
+ expect(pushStep).toBeLessThan(install);
+ expect(workflow).toContain("if: github.event_name == 'pull_request'");
+ expect(workflow).toContain("github.event_name == 'push'");
+ // push 必須用 --base-commit:--base-ref 會取 merge-base,force push 時驗不出被改寫的條目。
+ expect(workflow).not.toContain('--base-ref "${{ github.event.before }}"');
+ // 分支初建/force push 後 before 為全零,須跳過而非誤紅。
+ expect(workflow).toContain("github.event.before != '0000000000000000000000000000000000000000'");
+ });
+});
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
index 6cfe3ce98..64f71fa18 100644
--- a/scripts/tsconfig.json
+++ b/scripts/tsconfig.json
@@ -1,4 +1,8 @@
{
"extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ // scripts 為 Node 腳本與其測試:覆寫 base 的 types 白名單以載入 node 內建模組型別。
+ "types": ["node"]
+ },
"include": ["**/*.ts"]
}
diff --git a/scripts/verify-002-log.mjs b/scripts/verify-002-log.mjs
new file mode 100644
index 000000000..7a4b33c81
--- /dev/null
+++ b/scripts/verify-002-log.mjs
@@ -0,0 +1,554 @@
+/**
+ * 002 記分守門(issue #608;CI 端強制 issue #661)
+ *
+ * 驗證重點:
+ * 1. 檔頭「本次分數變化:+N(reward a、penalty b、neutral c)」與本次新增條目計數一致
+ * 2. 檔頭「累計總分」= 基準版累計總分 + N;只有「無基準版」(初始 commit)才跳過,
+ * 基準版存在卻無法解析或讀不出累計總分一律 fail-closed
+ * 3. 新增條目符合四行模板(日期/ID/原因/解法)、日期為 YYYY-MM-DD;ID 對全檔唯一,
+ * 但僅對本次造成的重複擋 commit(歷史既有重複不回溯)
+ *
+ * 三種執行語意共用同一 validate002 核心(無雙實作):
+ * - pre-commit(預設):staged 版(index)vs HEAD 版
+ * - PR CI(--base-ref ][):HEAD 版 vs merge-base(][, HEAD) 版
+ * - main push CI(--base-commit ):HEAD 版 vs 該 commit 版(不取 merge-base——
+ * force push 時 非 HEAD 祖先,merge-base 會退到更早的共同祖先而漏驗被改寫的條目)
+ * 兩個 flag 互斥;檔案相對基準未變更時跳過。
+ *
+ * 失敗行為一律 fail-closed:基準版無法解析、基準 ref 無法解析、檔案被刪除或改名
+ * 皆視為驗證失敗,不得靜默放行。
+ */
+import { execFileSync } from 'node:child_process';
+import { realpathSync } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+export const LOG_PATH = 'docs/dev/002_development_reward_penalty_log.md';
+
+// 檔頭記分行的標準格式;新 commit 一律要求此格式。
+// 正負號強制存在:文件所定格式為 `+N`/`+T`,省略加號會讓非標準檔頭寫進稽核記錄。
+const HEADER_STRICT_RE =
+ /^> 本次分數變化:([+-]\d+)(reward (\d+)、penalty (\d+)、neutral (\d+))|累計總分:([+-]\d+)$/;
+// 前版檔頭僅需能取出累計總分(相容歷史自由格式)。
+const HEADER_TOTAL_RE = /^> 本次分數變化:.*|累計總分:([+-]?\d+)$/;
+
+const ID_LINE_PREFIX = '- ID:';
+const ENTRY_LINE_PREFIXES = ['- 日期:', ID_LINE_PREFIX, '- 原因:', '- 解法:'];
+// 日期與 ID 各有專屬檢查,這兩欄只需確認非空。
+const CONTENT_PREFIXES = ['- 原因:', '- 解法:'];
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+const ID_PREFIXES = ['reward-', 'penalty-', 'neutral-'];
+
+function findHeaderLine(content) {
+ return content.split('\n').find((line) => line.startsWith('> 本次分數變化:')) ?? null;
+}
+
+// 不經區段結構、直接掃全檔的 `- ID:`。刪除比對需要它作後備:基準版若本身不可解析
+// (多個「## 條目」區段、區段被移除),parseEntries 會回傳空 entries,
+// 使刪除檢查落入真空而讓任何刪除靜默通過。
+export function scanRawIds(content) {
+ const ids = new Set();
+ if (!content) return ids;
+ for (const rawLine of content.split('\n')) {
+ const line = rawLine.trimEnd();
+ if (!line.startsWith(ID_LINE_PREFIX)) continue;
+ const id = line.slice(ID_LINE_PREFIX.length).trim();
+ if (id) ids.add(id);
+ }
+ return ids;
+}
+
+export function parseStrictHeader(content) {
+ const line = findHeaderLine(content);
+ if (!line) return null;
+ const match = line.match(HEADER_STRICT_RE);
+ if (!match) return null;
+ return {
+ line,
+ delta: Number(match[1]),
+ reward: Number(match[2]),
+ penalty: Number(match[3]),
+ neutral: Number(match[4]),
+ total: Number(match[5]),
+ };
+}
+
+export function parsePreviousTotal(content) {
+ const line = findHeaderLine(content);
+ if (!line) return null;
+ const match = line.match(HEADER_TOTAL_RE);
+ return match ? Number(match[1]) : null;
+}
+
+// 解析「## 條目(新→舊)」區段;每個條目為連續 4 行、以空行分隔。
+// 另以「- 日期:」作次要邊界:歷史檔有 2 處漏空行使多筆黏成一塊(8 行 2 筆、12 行 3 筆),
+// 僅靠空行切分會讓後續 3 筆條目的 ID 對唯一性與刪除防護隱形。
+// 格式錯誤附掛於各條目(entry.errors),供呼叫端只對新增條目擋 commit。
+export function parseEntries(content) {
+ const globalErrors = [];
+ const entries = [];
+ const lines = content.split('\n');
+ const sectionStarts = lines.reduce(
+ (acc, line, index) => (line.startsWith('## 條目') ? [...acc, index] : acc),
+ [],
+ );
+ if (sectionStarts.length === 0) {
+ return { entries, globalErrors: ['找不到「## 條目」區段'] };
+ }
+ // 只解析第一個區段,故多個「## 條目」等於替後續區段開一個永久盲區:
+ // 前置 decoy 區段抄齊全部 ID 即可滿足刪除防護,真區段從此不受守門檢視。
+ if (sectionStarts.length > 1) {
+ return {
+ entries,
+ globalErrors: [`「## 條目」區段必須唯一(找到 ${sectionStarts.length} 個)`],
+ };
+ }
+ const sectionStart = sectionStarts[0];
+
+ let block = [];
+ const flush = () => {
+ if (block.length === 0) return;
+ // 各欄位取值(trim 後);缺該行則不存在此 key。供內容檢查與跨版本非空比對共用。
+ const fields = {};
+ for (const prefix of ENTRY_LINE_PREFIXES) {
+ const line = block.find((candidate) => candidate.startsWith(prefix));
+ if (line !== undefined) {
+ fields[prefix] = line.slice(prefix.length).trim();
+ }
+ }
+ const entry = { id: null, lines: [...block], fields, errors: [] };
+ // 空白 ID 視同缺少 ID:留成空字串會被後續 truthy 篩選排除在新增條目之外,
+ // 使該筆同時繞過前綴、唯一性、計數與總分檢查。
+ const idValue = fields['- ID:'] ?? '';
+ if (idValue) {
+ entry.id = idValue;
+ }
+ // 定位字串優先用 ID:500+ 條目的檔案裡,只引用首行(多為日期)不足以指出是哪一筆。
+ const locator = entry.id ?? block[0];
+ if (block.length !== 4) {
+ entry.errors.push(`條目行數應為 4 行(實際 ${block.length} 行):「${locator}」`);
+ }
+ block.forEach((line, index) => {
+ const prefix = ENTRY_LINE_PREFIXES[index];
+ if (prefix && !line.startsWith(prefix)) {
+ entry.errors.push(`條目「${locator}」第 ${index + 1} 行應以「${prefix}」開頭:「${line}」`);
+ }
+ });
+ const date = fields['- 日期:'];
+ if (date !== undefined && !DATE_RE.test(date)) {
+ entry.errors.push(`條目「${locator}」日期格式應為 YYYY-MM-DD:「${date}」`);
+ }
+ // 四行模板要求每筆都有一句話 root cause 與 resolution;只檢查前綴會讓空值通過。
+ for (const prefix of CONTENT_PREFIXES) {
+ if (fields[prefix] === '') {
+ entry.errors.push(`條目「${locator}」的「${prefix}」不可為空`);
+ }
+ }
+ if (!entry.id) {
+ entry.errors.push(
+ fields['- ID:'] === undefined
+ ? `條目缺少 ID 行:「${block[0]}」`
+ : `條目 ID 不可為空:「${block[0]}」`,
+ );
+ }
+ entries.push(entry);
+ block = [];
+ };
+
+ for (const rawLine of lines.slice(sectionStart + 1)) {
+ const line = rawLine.trimEnd();
+ if (line === '') {
+ flush();
+ continue;
+ }
+ if (line.startsWith('## ')) {
+ flush();
+ break;
+ }
+ if (line.startsWith('- 日期:') && block.length > 0) {
+ flush();
+ }
+ block.push(line);
+ }
+ flush();
+
+ return { entries, globalErrors };
+}
+
+/**
+ * 核心驗證:比對待驗版本(stagedContent)與基準版本(headContent)。
+ * pre-commit 語意為 staged vs HEAD;CI 語意為 HEAD vs merge-base。
+ * headContent 為 null 表示無基準版本情境(跳過刪除防護與總分鏈驗證)。
+ */
+export function validate002({ stagedContent, headContent }) {
+ const errors = [];
+
+ const { entries: stagedEntries, globalErrors } = parseEntries(stagedContent);
+ errors.push(...globalErrors);
+
+ // 只有「無基準版」(null/undefined)才算沒有基準;空字串仍走解析並因找不到區段而 fail-closed。
+ const hasBase = headContent != null;
+ const headParsed = hasBase ? parseEntries(headContent) : { entries: [], globalErrors: [] };
+ const headEntries = headParsed.entries;
+ const headEntryIds = new Set(headEntries.map((entry) => entry.id));
+
+ // 基準版本身不可解析時 fail-closed:無法確認歷史完整性就不得放行。
+ // 否則「先讓基準版變成不可解析、下一個 commit 清空全部歷史」即可兩道閘全綠。
+ if (headParsed.globalErrors.length > 0) {
+ errors.push(
+ `基準版 002 無法解析,守門無法確認歷史條目完整性:${headParsed.globalErrors.join('、')}`,
+ );
+ }
+
+ // 歷史條目不可靜默刪除(防湮滅 penalty 證據):基準版全部 ID(不限標準前綴)
+ // 必須仍存在於待驗版,缺失即擋。待驗版側取「解析結果 ∪ 原始文字掃描」以免解析失敗時誤報;
+ // 基準版側的取法見下方(可解析時只採解析結果)。
+ const stagedParsedIds = new Set(stagedEntries.map((entry) => entry.id).filter(Boolean));
+ const stagedRawIds = scanRawIds(stagedContent);
+ const stagedIds = new Set([...stagedParsedIds, ...stagedRawIds]);
+ // 基準版可解析時只採信解析結果:區段外的獨立 `- ID:` 行(文件範例等)不是條目,
+ // 併進來會讓「移除範例行」被誤報成刪除歷史條目。
+ // 基準版不可解析時才退回全檔原始文字掃描(該情境已另行 fail-closed,此處為第二道保險)。
+ const headAllIds = new Set(
+ (headParsed.globalErrors.length > 0
+ ? [...headEntryIds, ...scanRawIds(headContent)]
+ : [...headEntryIds]
+ ).filter((id) => Boolean(id)),
+ );
+ const deletedIds = [...headAllIds].filter((id) => !stagedIds.has(id));
+ if (deletedIds.length > 0) {
+ errors.push(`歷史條目不可刪除,缺失 ID:${deletedIds.map((id) => `「${id}」`).join('、')}`);
+ }
+
+ // 原本可解析的條目不可移出解析範圍(被 `## ` 標題截斷、或搬到「## 條目」之前)。
+ // 原始文字仍在故刪除防護不失效,但格式/掏空/唯一性檢查都不再覆蓋它,等同開盲區。
+ // 以「基準版解析得到、待驗版只剩原始文字」為判準,故不會誤傷檔頭模板的示例 ID。
+ const hiddenIds = [...headEntryIds].filter(
+ (id) => id && !stagedParsedIds.has(id) && stagedRawIds.has(id),
+ );
+ if (hiddenIds.length > 0) {
+ errors.push(
+ `條目不可移出「## 條目」解析範圍(仍在檔內但已不受檢視):${hiddenIds
+ .map((id) => `「${id}」`)
+ .join('、')}`,
+ );
+ }
+
+ // 掏空既有條目 = 就地刪除,是刪除防護的等效規避路徑(保留檔案與 ID、把內容清空)。
+ // 判準只看「有沒有從有變成無」而非內容是否改動:精確性修正(改錯字、更正數字)
+ // 保留非空故不受影響;語意層的改寫由審查把關,不在守門範圍。
+ // 歷史上本來就為空的欄位維持豁免,不回溯擋 commit。
+ const headEntriesById = new Map(
+ headEntries.filter((entry) => entry.id).map((entry) => [entry.id, entry]),
+ );
+ for (const entry of stagedEntries) {
+ const headEntry = entry.id ? headEntriesById.get(entry.id) : undefined;
+ if (!headEntry) continue;
+ for (const prefix of ENTRY_LINE_PREFIXES) {
+ if (headEntry.fields[prefix] && !entry.fields[prefix]) {
+ errors.push(`條目「${entry.id}」的「${prefix}」原有內容不可清空`);
+ }
+ }
+ }
+
+ // ID 全檔唯一性。比照格式檢查只對本次造成的重複擋 commit:
+ // 若無條件掃全檔,歷史上一旦出現過重複,之後每個 commit 都會被卡死(即使沒動 002)。
+ const countIds = (entries) => {
+ const counts = new Map();
+ for (const entry of entries) {
+ if (!entry.id) continue;
+ counts.set(entry.id, (counts.get(entry.id) ?? 0) + 1);
+ }
+ return counts;
+ };
+ const headIdCounts = countIds(headEntries);
+ for (const [id, count] of countIds(stagedEntries)) {
+ if (count < 2) continue;
+ if (count <= (headIdCounts.get(id) ?? 0)) continue;
+ errors.push(`ID 重複:「${id}」`);
+ }
+
+ const newEntries = stagedEntries.filter((entry) => entry.id && !headEntryIds.has(entry.id));
+
+ // 僅對本次新增(或無法辨識 ID 的)條目套用格式錯誤,歷史條目不回溯擋 commit。
+ for (const entry of stagedEntries) {
+ if (entry.errors.length === 0) continue;
+ if (entry.id && headEntryIds.has(entry.id)) continue;
+ errors.push(...entry.errors);
+ }
+
+ const stagedHeaderLine = findHeaderLine(stagedContent);
+ const headHeaderLine = hasBase ? findHeaderLine(headContent) : null;
+
+ // 檔頭未動且無新增條目(如 typo 修正、prettier 重排):不驗記分。
+ if (newEntries.length === 0 && stagedHeaderLine === headHeaderLine) {
+ return { errors };
+ }
+
+ const header = parseStrictHeader(stagedContent);
+ if (!header) {
+ errors.push(
+ '檔頭記分行缺失或格式不符,應為:「> 本次分數變化:+N(reward a、penalty b、neutral c)|累計總分:+T」',
+ );
+ return { errors };
+ }
+
+ // 新增條目依 ID 前綴分類計數。
+ const counts = { reward: 0, penalty: 0, neutral: 0 };
+ for (const entry of newEntries) {
+ const prefix = ID_PREFIXES.find((candidate) => entry.id.startsWith(candidate));
+ if (!prefix) {
+ errors.push(`新增條目 ID 必須以 reward-/penalty-/neutral- 開頭:「${entry.id}」`);
+ continue;
+ }
+ counts[prefix.slice(0, -1)] += 1;
+ }
+
+ if (
+ header.reward !== counts.reward ||
+ header.penalty !== counts.penalty ||
+ header.neutral !== counts.neutral
+ ) {
+ errors.push(
+ `檔頭計數(reward ${header.reward}、penalty ${header.penalty}、neutral ${header.neutral})` +
+ `與本次新增條目(reward ${counts.reward}、penalty ${counts.penalty}、neutral ${counts.neutral})不符`,
+ );
+ }
+
+ const expectedDelta = counts.reward - counts.penalty;
+ if (header.delta !== expectedDelta) {
+ errors.push(`本次分數變化應為 ${expectedDelta}(reward - penalty),檔頭為 ${header.delta}`);
+ }
+
+ // 總分鏈:前版累計 + N = 本版累計。無基準版(初始 commit)才可跳過;
+ // 有基準版卻讀不出前版累計時 fail-closed,否則任意總分都能寫入而不被察覺。
+ if (!hasBase) {
+ return { errors };
+ }
+ const previousTotal = parsePreviousTotal(headContent);
+ if (previousTotal === null) {
+ errors.push('基準版檔頭讀不出累計總分,無法驗算總分鏈');
+ return { errors };
+ }
+ const expectedTotal = previousTotal + header.delta;
+ if (header.total !== expectedTotal) {
+ errors.push(
+ `累計總分斷鏈:前版 ${previousTotal} + 本次 ${header.delta} = ${expectedTotal},檔頭為 ${header.total}`,
+ );
+ }
+
+ return { errors };
+}
+
+// LC_ALL/LANGUAGE 鎖英文輸出。存在性判定改用離開碼與空輸出後,這不再是正確性的
+// 依據,僅讓 git 輸出恆為決定性,避免日後有人新增解析時踩到 locale。
+const GIT_ENV = { ...process.env, LC_ALL: 'C', LANGUAGE: 'C' };
+
+// 全檔唯一的 git 呼叫點:集中帶入 GIT_ENV 與 stdio,讓「忘記帶 env」在結構上不可能。
+// 非零離開一律拋出(由呼叫端決定是否為預期),不在此吞任何錯誤。
+function git(args) {
+ return execFileSync('git', args, {
+ env: GIT_ENV,
+ encoding: 'utf-8',
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+}
+
+// repo 可用性在首次使用時確認一次;之後 `ls-files`/`ls-tree` 若因 repo 中途不可用而
+// 非零離開,仍會上拋 fail-closed,故不需重複確認。
+let repoUsable = false;
+function assertRepoUsable() {
+ if (repoUsable) return;
+ git(['rev-parse', '--git-dir']);
+ repoUsable = true;
+}
+
+// 「這個 repo 一個 commit 都還沒有」——唯一能把 HEAD 不解析當成「無基準版」的前提。
+// 不可用 `rev-parse --verify HEAD` 代替:`git checkout --orphan` 之後 HEAD 指向尚未存在
+// 的分支,verify 一樣失敗,但 repo 裡的歷史 commit 都還在,此時視為無基準版會讓
+// 刪除防護與總分鏈整個跳過(標準 Git 指令即可觸發,不需劫持環境)。
+// 判準必須結構性且逐層兜底(refs/reflog/object store 三層),不可只看 named refs:
+// - refs+reflog(--all --reflog 一次涵蓋前兩層):orphan 之後把 named refs 全刪
+// (branch -D+清 packed-refs)會讓 --all 為空,但 reflog 仍留有紀錄。
+// - object store(第三層):reflog 也被 expire+刪 .git/logs 時,只要任何 commit 物件
+// 仍在就不是無基準版。只在前兩層為空時執行(正常 repo 永不觸發);真空 repo 至多
+// 數個 staged blob,輸出極小。被構造的大 repo 若超出 maxBuffer 會上拋 fail-closed,
+// 方向仍正確(誤擋不誤放)。
+// 連 commit 物件都被 prune(reflog expire+gc --prune=now)的 repo 與真空 repo 在
+// 結構上無法區分,本機守門到此為止,由 CI 以 GitHub 事件的基準 SHA 兜底
+//(見 AGENTS.md「已知殘餘風險」)。
+function hasAnyCommit() {
+ if (git(['rev-list', '-n', '1', '--all', '--reflog']).trim() !== '') return true;
+ return git(['cat-file', '--batch-all-objects', '--batch-check=%(objecttype)'])
+ .split('\n')
+ .some((line) => line === 'commit');
+}
+
+function refResolves(ref) {
+ try {
+ git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// spec 形如「:path」(index)、「HEAD:path」、「:path」;不存在時回傳 null。
+//
+// 存在性改用 `ls-files`/`ls-tree` 而非 `cat-file -e`:後者把「不存在」表達成 status 128
+// + fatal 訊息,與「repo 不可用」共用同一個離開碼,只能靠比對英文訊息區分——那是
+// 人類可讀輸出而非 API 契約,本守門已因此連續破三次(漏訊息、依賴英文、又漏一種)。
+// `ls-*` 對不存在的路徑輸出空字串且 exit 0,把「不存在」變成正常回傳值,
+// 非零離開則一律是環境問題並上拋 fail-closed,這條脆弱性從根上消失。
+function gitShow(spec) {
+ assertRepoUsable();
+ const separator = spec.indexOf(':');
+ const ref = spec.slice(0, separator);
+ const path = spec.slice(separator + 1);
+
+ let listed = '';
+ if (ref === '') {
+ listed = git(['ls-files', '--', path]);
+ } else if (refResolves(ref)) {
+ listed = git(['ls-tree', '--name-only', ref, '--', path]);
+ } else if (ref === 'HEAD' && !hasAnyCommit()) {
+ // 真初始 commit:repo 一個 commit 都沒有,確實無基準版可比。
+ return null;
+ } else {
+ // ref 不解析但 repo 已有 commit(orphan HEAD、損毀 symref)或指定的基準 ref 失效:
+ // 這不是「無基準版」,靜默當成 null 會讓刪除防護與總分鏈整個跳過。
+ throw new Error(`ref「${ref}」無法解析,但 repo 已有 commit——不可視為無基準版`);
+ }
+ if (listed.trim() === '') return null;
+
+ return git(['show', spec]);
+}
+
+function report(errors) {
+ if (errors.length > 0) {
+ console.error('002 記分守門失敗:');
+ for (const message of errors) {
+ console.error(`- ${message}`);
+ }
+ process.exit(1);
+ }
+ console.log('002 記分守門通過');
+}
+
+// pre-commit 語意:staged 版(index)vs HEAD 版。
+function runPreCommit() {
+ const stagedContent = gitShow(`:${LOG_PATH}`);
+ const headContent = gitShow(`HEAD:${LOG_PATH}`);
+ if (stagedContent === null) {
+ // index 無此路徑但 HEAD 有 = staged 刪除(`git rm`),必須擋下;
+ // 兩邊皆無才是與 002 無關的 commit。
+ if (headContent !== null) {
+ report([`${LOG_PATH} 不可刪除(HEAD 存在此檔,index 已移除)`]);
+ return;
+ }
+ console.log(`002 記分守門跳過(${LOG_PATH} 不存在於 index 與 HEAD)`);
+ return;
+ }
+ report(validate002({ stagedContent, headContent }).errors);
+}
+
+// CI 語意:HEAD 版(最終態)vs 基準版;只驗整體一致性、不逐 commit。
+// useMergeBase=true(PR):基準取 merge-base(ref, HEAD)——base 分支會前進,需退回分岔點。
+// useMergeBase=false(main push):基準直接取 ref(推送前的 tip)。此處**不得**取 merge-base:
+// 非快轉/force push 時 before 不是 HEAD 的祖先,merge-base 會退到更早的共同祖先,
+// 使 before 與祖先之間新增的 penalty 條目在改寫後消失也驗不出來——而那正是本模式的存在理由。
+function runAgainstBaseRef(ref, { useMergeBase }) {
+ let base = ref;
+ // 兩處 catch 都不吞錯誤(立即 exit 1),但必須帶出 git 原始訊息:
+ // 「ref 打錯」與「repo 不可用/git 壞掉」的處置完全不同,只印通用句會誤導診斷。
+ const gitStderr = (error) => String(error?.stderr ?? error?.message ?? '').trim();
+ if (useMergeBase) {
+ try {
+ base = git(['merge-base', ref, 'HEAD']).trim();
+ } catch (error) {
+ // `git merge-base` 以 exit 1 表示「無共同祖先」(documented 行為),非環境錯誤:
+ // orphan/unrelated histories 的 PR 落在這裡。歷史條目完整性無法對帳,維持
+ // fail-closed,但訊息須可診斷,不與「ref 打錯」「repo 不可用」(exit 128)混為一談。
+ console.error(
+ error?.status === 1 && refResolves(ref)
+ ? `002 記分守門失敗:base ref「${ref}」與 HEAD 無共同祖先` +
+ '(orphan/unrelated histories)——歷史條目完整性無法對帳,fail-closed'
+ : `002 記分守門失敗:無法解析 merge-base(base ref「${ref}」):${gitStderr(error)}`,
+ );
+ process.exit(1);
+ }
+ } else {
+ try {
+ base = git(['rev-parse', '--verify', `${ref}^{commit}`]).trim();
+ } catch (error) {
+ console.error(`002 記分守門失敗:無法解析基準 commit(「${ref}」):${gitStderr(error)}`);
+ process.exit(1);
+ }
+ }
+
+ const label = `${useMergeBase ? 'merge-base' : '基準'} ${base.slice(0, 12)}`;
+ const currentContent = gitShow(`HEAD:${LOG_PATH}`);
+ const baseContent = gitShow(`${base}:${LOG_PATH}`);
+
+ if (currentContent === null) {
+ if (baseContent === null) {
+ console.log(`002 記分守門跳過(${LOG_PATH} 不存在)`);
+ return;
+ }
+ report([`${LOG_PATH} 不可刪除(${label} 存在此檔)`]);
+ return;
+ }
+ if (currentContent === baseContent) {
+ console.log(`002 記分守門跳過(${LOG_PATH} 相對 ${label} 無變更)`);
+ return;
+ }
+ report(validate002({ stagedContent: currentContent, headContent: baseContent }).errors);
+}
+
+function main() {
+ const args = process.argv.slice(2);
+ const modes = [
+ ['--base-ref', true],
+ ['--base-commit', false],
+ ].filter(([flag]) => args.includes(flag));
+
+ // 兩個 flag 基準取法不同,同時出現無法判定意圖;靜默取其一會讓誤用得到假綠。
+ if (modes.length > 1) {
+ console.error('002 記分守門失敗:--base-ref 與 --base-commit 互斥,不可同時指定');
+ process.exit(1);
+ }
+ if (modes.length === 0) {
+ runPreCommit();
+ return;
+ }
+
+ const [flag, useMergeBase] = modes[0];
+ const ref = args[args.indexOf(flag) + 1];
+ if (!ref) {
+ console.error(`002 記分守門失敗:${flag} 需指定基準(例如 origin/main 或 base SHA)`);
+ process.exit(1);
+ }
+ runAgainstBaseRef(ref, { useMergeBase });
+}
+
+// argv[1] 需先解析 symlink 再比對:macOS 的 /tmp 是 /private/tmp 的 symlink,
+// 以絕對路徑呼叫時兩側不等會讓 main() 靜默不執行並 exit 0(假成功)。
+// realpathSync 失敗代表 argv[1] 無法解析,此時無從判定是否為直跑。
+// 舊版在此吞掉例外並退回字面比較,比不中就靜默視為「非直跑」——main() 不執行卻 exit 0
+// 正是假成功。改為不吞,由下方統一 fail-closed。
+function isDirectRun() {
+ const invoked = process.argv[1];
+ if (!invoked) return false;
+ return import.meta.url === pathToFileURL(realpathSync(invoked)).href;
+}
+
+try {
+ if (isDirectRun()) {
+ main();
+ }
+} catch (error) {
+ // 任何未預期例外(git 無法執行、物件存在卻讀不出、argv[1] 無法解析)一律 fail-closed。
+ console.error(
+ `002 記分守門失敗:執行期例外——${error instanceof Error ? error.message : String(error)}`,
+ );
+ process.exit(1);
+}
]