From e90c183fa4846a6b2bccf1dcef4b0d38ec05b77c Mon Sep 17 00:00:00 2001 From: haotool Date: Tue, 7 Jul 2026 03:41:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20002=20=E8=A8=98=E5=88=86=20pre?= =?UTF-8?q?-commit=20=E8=87=AA=E5=8B=95=E5=AE=88=E9=96=80=EF=BC=88issue=20?= =?UTF-8?q?608=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/verify-002-log.mjs:驗證 002 檔頭「本次分數變化:+N(reward a、penalty b、neutral c)」 - 與 staged 新增條目前綴計數對帳(a+b+c = 新增條目數、N = a - b),累計總分鏈以 HEAD 前版 + N 驗證 - 條目格式守門:四行模板(日期/ID/原因/解法)、日期 YYYY-MM-DD、ID 全檔唯一;歷史條目不回溯擋 commit - 歷史條目不可靜默刪除:HEAD 條目 ID 必須為 staged 子集(不限標準前綴),缺失列出 ID 並擋下 - 掛入 .husky/pre-commit 第 6 步,僅 002 檔在 staged set 時執行(對齊既有條件執行模式) - 新增 18 例 vitest 單元測試並掛入 test:root;AGENTS.md/CLAUDE.md 同步 6 步驟清單、記分行固定格式 - 文件註記 rebase --continue 不觸發 pre-commit,需手動跑守門腳本或 amend 觸發 測試:npx vitest run scripts/__tests__/verify-002-log.test.ts(18 passed);pre-commit 6 步驟全過 Co-authored-by: Cursor --- .husky/pre-commit | 26 +- AGENTS.md | 8 +- CLAUDE.md | 3 + .../dev/002_development_reward_penalty_log.md | 7 +- package.json | 2 +- scripts/__tests__/verify-002-log.test.ts | 247 ++++++++++++++++++ scripts/verify-002-log.mjs | 243 +++++++++++++++++ 7 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 scripts/__tests__/verify-002-log.test.ts create mode 100644 scripts/verify-002-log.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index 147792264..75a473568 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,38 @@ 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:檔頭記分與新增條目自動對帳) +if git diff --cached --name-only | grep -qE "docs/dev/002_development_reward_penalty_log\.md"; then + echo "\nStep 6/6: 002 記分守門..." + node scripts/verify-002-log.mjs + if [ $? -ne 0 ]; then + echo "\n002 記分守門失敗,請依照訊息修正檔頭記分與條目" + exit 1 + fi +else + echo "\nStep 6/6: 002 記分守門 (跳過 - 無相關變更)" fi echo "\n所有 Pre-commit 檢查通過" diff --git a/AGENTS.md b/AGENTS.md index ae8cb8897..033bd17dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,7 +115,7 @@ scripts/ # 驗證/SEO/版本/SSOT 腳本 | `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-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` | @@ -189,6 +189,9 @@ Agent **必須**先完成: - `本次分數變化 = reward_count - penalty_count` - `最新總分 = 前次總分 + 本次分數變化` - 每次新增 002 條目時,必須同步更新本次分數變化與累計總分(可放於檔頭摘要行或同批 commit 的 SSOT 文件)。 +- 檔頭記分行固定格式:`> 本次分數變化:+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 全檔唯一性與歷史條目不可刪除;初始 commit 情境跳過總分鏈驗證。 +- rebase 解 002 衝突後,`git rebase --continue` 不觸發 pre-commit——必須手動執行 `node scripts/verify-002-log.mjs` 驗證,或事後以 `git commit --amend` 重新觸發守門。 ### Phase 5. 推送與合併(Push & Merge Controls) @@ -205,13 +208,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,快速必要檢查) diff --git a/CLAUDE.md b/CLAUDE.md index 888321296..255103fdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,9 @@ pnpm format:fix # prettier --write . - `本次分數變化 = reward_count - penalty_count` - `最新總分 = 前次總分 + 本次分數變化` - 每次 commit 前新增 002 紀錄時,必須同步更新「本次分數變化」與「累計總分」。 +- 檔頭記分行固定格式:`> 本次分數變化:+N(reward a、penalty b、neutral c)|累計總分:+T`;條目 ID 必須以 `reward-` / `penalty-` / `neutral-` 開頭。 +- `pre-commit` 第 6 步(`scripts/verify-002-log.mjs`,僅 002 檔變更時執行)自動驗證記分:`a+b+c` = 本次新增條目數、`N = a - b`、`T` = 前版累計 + `N`、四行模板、ID 唯一性與歷史條目不可刪除(issue #608)。 +- rebase 解 002 衝突後,`git rebase --continue` 不觸發 pre-commit——必須手動執行 `node scripts/verify-002-log.mjs` 驗證,或事後以 `git commit --amend` 重新觸發守門。 ### Phase 7. 版本發布與依賴管理(Release & Dependencies) diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 35fb3faee..bdc8d69e2 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、原因、解法。 -> 本次分數變化:+1|累計總分:+173 +> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+174 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-07-07 +- ID:reward-608-002-score-precommit-guard +- 原因:002 檔頭記分靠人工核對,14 天內 9 件「本次分數與新增條目不符」(PR #626/#629/#630/#637 等 Codex review threads 累犯),rebase 補救成常態(issue #608) +- 解法:新增 `scripts/verify-002-log.mjs` 掛入 pre-commit 第 6 步(僅 002 staged 時執行)——自動對帳檔頭計數與 staged 新增條目前綴計數、驗證累計總分鏈(HEAD 前版 + N)、四行模板、ID 唯一性與歷史條目不可刪除;附 18 例 vitest 單元測試並同步 AGENTS.md/CLAUDE.md 記分行固定格式 + - 日期:2026-07-07 - ID:reward-rw-content-page-skeleton-destickify-dedupe - 原因:內容頁頂列 sticky+backdrop-blur 捲動時壓在內容上(使用者裁決頁面上方不應跟隨捲動),且指南頁 FAQ 段與開放資料頁授權聲明存在跨頁逐字重複內容(SEO duplicate content) diff --git a/package.json b/package.json index 7b18d8dec..201994443 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "format:fix": "prettier --write .", "typecheck": "pnpm -r typecheck", "test": "pnpm -r 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", + "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__/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", diff --git a/scripts/__tests__/verify-002-log.test.ts b/scripts/__tests__/verify-002-log.test.ts new file mode 100644 index 000000000..030b88fb9 --- /dev/null +++ b/scripts/__tests__/verify-002-log.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { + parseEntries, + parsePreviousTotal, + parseStrictHeader, + validate002, +} from '../verify-002-log.mjs'; + +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, + }); + }); + + 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(['找不到「## 條目」區段']); + }); +}); + +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); + }); + + 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('正常 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([]); + }); +}); diff --git a/scripts/verify-002-log.mjs b/scripts/verify-002-log.mjs new file mode 100644 index 000000000..184d76453 --- /dev/null +++ b/scripts/verify-002-log.mjs @@ -0,0 +1,243 @@ +/** + * 002 記分守門(issue #608) + * + * 驗證重點: + * 1. 檔頭「本次分數變化:+N(reward a、penalty b、neutral c)」與本次 staged 新增條目計數一致 + * 2. 檔頭「累計總分」= 前版(HEAD)累計總分 + N;初始 commit 或前版無法解析時跳過 + * 3. 新增條目符合四行模板(日期/ID/原因/解法)、日期為 YYYY-MM-DD、ID 對全檔唯一 + */ +import { execSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +export const LOG_PATH = 'docs/dev/002_development_reward_penalty_log.md'; + +// 檔頭記分行的標準格式;新 commit 一律要求此格式。 +const HEADER_STRICT_RE = + /^> 本次分數變化:([+-]?\d+)(reward (\d+)、penalty (\d+)、neutral (\d+))|累計總分:([+-]?\d+)$/; +// 前版檔頭僅需能取出累計總分(相容歷史自由格式)。 +const HEADER_TOTAL_RE = /^> 本次分數變化:.*|累計總分:([+-]?\d+)$/; + +const ENTRY_LINE_PREFIXES = ['- 日期:', '- ID:', '- 原因:', '- 解法:']; +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; +} + +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 行、以空行分隔。 +// 格式錯誤附掛於各條目(entry.errors),供呼叫端只對新增條目擋 commit。 +export function parseEntries(content) { + const globalErrors = []; + const entries = []; + const lines = content.split('\n'); + const sectionStart = lines.findIndex((line) => line.startsWith('## 條目')); + if (sectionStart === -1) { + return { entries, globalErrors: ['找不到「## 條目」區段'] }; + } + + let block = []; + const flush = () => { + if (block.length === 0) return; + const entry = { id: null, lines: [...block], errors: [] }; + if (block.length !== 4) { + entry.errors.push(`條目行數應為 4 行(實際 ${block.length} 行):「${block[0]}」`); + } + block.forEach((line, index) => { + const prefix = ENTRY_LINE_PREFIXES[index]; + if (prefix && !line.startsWith(prefix)) { + entry.errors.push(`條目第 ${index + 1} 行應以「${prefix}」開頭:「${line}」`); + } + }); + const dateLine = block.find((line) => line.startsWith('- 日期:')); + if (dateLine) { + const date = dateLine.slice('- 日期:'.length).trim(); + if (!DATE_RE.test(date)) { + entry.errors.push(`日期格式應為 YYYY-MM-DD:「${date}」`); + } + } + const idLine = block.find((line) => line.startsWith('- ID:')); + if (idLine) { + entry.id = idLine.slice('- ID:'.length).trim(); + } else { + entry.errors.push(`條目缺少 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; + } + block.push(line); + } + flush(); + + return { entries, globalErrors }; +} + +/** + * 核心驗證:比對 staged 內容與前版(HEAD)內容。 + * headContent 為 null 表示初始 commit 情境(跳過總分鏈驗證)。 + */ +export function validate002({ stagedContent, headContent }) { + const errors = []; + + const { entries: stagedEntries, globalErrors } = parseEntries(stagedContent); + errors.push(...globalErrors); + + const headEntryIds = new Set( + headContent ? parseEntries(headContent).entries.map((entry) => entry.id) : [], + ); + + // 歷史條目不可靜默刪除(防湮滅 penalty 證據):HEAD 全部條目 ID(不限標準前綴) + // 必須仍存在於 staged 版本,缺失即擋 commit。 + const stagedIds = new Set(stagedEntries.map((entry) => entry.id).filter(Boolean)); + const deletedIds = [...headEntryIds].filter((id) => id && !stagedIds.has(id)); + if (deletedIds.length > 0) { + errors.push(`歷史條目不可刪除,缺失 ID:${deletedIds.map((id) => `「${id}」`).join('、')}`); + } + + // ID 全檔唯一性。 + const seen = new Set(); + for (const entry of stagedEntries) { + if (!entry.id) continue; + if (seen.has(entry.id)) { + errors.push(`ID 重複:「${entry.id}」`); + } + seen.add(entry.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 = headContent ? 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 = 本版累計。 + const previousTotal = headContent ? parsePreviousTotal(headContent) : null; + if (previousTotal !== null) { + const expectedTotal = previousTotal + header.delta; + if (header.total !== expectedTotal) { + errors.push( + `累計總分斷鏈:前版 ${previousTotal} + 本次 ${header.delta} = ${expectedTotal},檔頭為 ${header.total}`, + ); + } + } + + return { errors }; +} + +function readStaged(filePath) { + try { + return execSync(`git show :${filePath}`, { encoding: 'utf-8' }); + } catch { + return null; + } +} + +function readFromHead(filePath) { + try { + return execSync(`git show HEAD:${filePath}`, { encoding: 'utf-8' }); + } catch { + return null; + } +} + +function main() { + const stagedContent = readStaged(LOG_PATH); + if (stagedContent === null) { + console.log(`002 記分守門跳過(${LOG_PATH} 不在 staged set)`); + return; + } + + const { errors } = validate002({ stagedContent, headContent: readFromHead(LOG_PATH) }); + if (errors.length > 0) { + console.error('002 記分守門失敗:'); + for (const message of errors) { + console.error(`- ${message}`); + } + process.exit(1); + } + console.log('002 記分守門通過'); +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isDirectRun) { + main(); +}