Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@
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 失敗,請修復後再提交"
exit 1
fi

# 2. TypeScript 檢查 (快速驗證類型錯誤)
echo "\nStep 2/5: TypeScript 檢查..."
echo "\nStep 2/6: TypeScript 檢查..."
pnpm typecheck
if [ $? -ne 0 ]; then
echo "\nTypeScript 檢查失敗,請修復類型錯誤後再提交"
exit 1
fi

# 3. 格式檢查 (確保所有文件已格式化)
echo "\nStep 3/5: Prettier 格式檢查..."
echo "\nStep 3/6: Prettier 格式檢查..."
pnpm format
if [ $? -ne 0 ]; then
echo "\n格式檢查失敗,請執行 'pnpm format:fix' 後再提交"
Expand All @@ -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 檢查通過"
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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)

Expand All @@ -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,快速必要檢查)

Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion docs/dev/002_development_reward_penalty_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> 版本:outline-v2-ultra
> 原則:每筆只保留日期、ID、原因、解法。
> 本次分數變化:+1|累計總分:+173
> 本次分數變化:+1(reward 1、penalty 0、neutral 0)|累計總分:+174

## 新增模板(4 行)

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
247 changes: 247 additions & 0 deletions scripts/__tests__/verify-002-log.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading