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); +}