08 品質校驗時間軸
從改第一行字到回歸驗收,每一道維持品質的校驗都列在這裡:它什麼時候跑、擋什麼、不能證明什麼、自己怎麼手動跑、失敗時看哪裡。本章是導讀;正式規則與操作細節以 docs/agents/ci-checks.md、development-workflow.md §3、issue-run.md 與各腳本檔頭為準。
互動版:diagrams/08-quality-gates.html(來源 diagrams/src/08-quality-gates.workflow.json)
強度分級
| 強度 | 意思 | 例子 |
|---|---|---|
| 本地強制 | git hook 或 Claude Code hook 擋下,agent 繞不過(claude-hook-git-bypass.mjs 連 --no-verify 都擋) | pre-push hook、reference-transaction 守衛、.claude/settings.json deny |
| 流程內 | skill 或腳本在固定步驟呼叫,失敗即停;但沒有 hook 強制,另開終端手動 commit 可以跳過 | check-contracts.sh 內的各 check-*、focused 測試、Codex review |
| 人工觸發 | 只有人按下才跑 | GitLab 手動 web pipeline |
三種強度都只在本機或 GitLab Runner 上跑;沒有任何一道是 GitLab 伺服器端強制(「pipeline 綠才能 merge」未啟用)。
1. 編輯時(agent 工具呼叫)
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
.claude/settings.json deny 清單 | 本地強制 | Claude Code 每次工具呼叫 | 拒絕 git push、glab mr、glab issue create、psql/mysql/sqlcmd/sqlplus、編輯 .claude/settings*.json | 人在自己的終端做同樣的事不受限;也不證明 agent 透過其他指令(例如腳本包裝)沒碰到目標 | 無;規則只能由人改 .claude/settings.json | 工具呼叫被拒時 Claude Code 直接顯示規則;格式錯的規則由 check-claude-settings.mjs 在 push 前抓出 |
PreToolUse hook claude-hook-git-bypass.mjs | 本地強制 | 每個 Bash 工具呼叫(settings.json 的 hooks.PreToolUse) | 指令含 -c core.hooksPath=、git config core.hooksPath …、--no-verify、git commit -n 即 exit 2 拒絕 | 只比對字串,寫進腳本檔再執行的繞過抓不到;也不檢查指令的其他內容 | printf '{"tool_input":{"command":"git commit -n"}}' | node scripts/claude-hook-git-bypass.mjs(exit 2 代表命中) | stderr 一行「拒絕:指令以 … 繞過 git hook」;規則在 scripts/claude-hook-git-bypass.mjs 的 BYPASS 表 |
concurrent-edits.mjs | 流程內 | 開工前,對要改的檔案跑一次 | 其他 worktree 對同一檔的未合入修改、整合分支自 baseline 以來對這些檔的 commit | 只看檔案層級重疊,不看語意;跑完之後別人再改就不知道 | node scripts/concurrent-edits.mjs <file>… | stdout 列出 worktree 與 commit;CLEAR 代表沒有重疊 |
2. commit 時
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
check-commit-messages.mjs | 流程內(commit 時)/本地強制(push 時) | /issue-run 每次 commit 後;check-before-push.sh 第一步再對 HEAD 跑一次 | 非 merge commit 的訊息符合 git-standards:<類型>: <描述>,類型限 feat/fix/docs/style/refactor/perf/test/chore | 只讀 git objects,不看 diff 是否與訊息相符、不看範圍 | node scripts/check-commit-messages.mjs HEAD <baseline>(檢查 <baseline>..HEAD;只給 HEAD 時改檢查尚未在任何 remote 的 commits) | stderr 列出不合格的 SHA 與原因;改訊息用 git commit --amend 或 rebase |
git diff --check | 流程內 | 文件任務 commit 前 | 行尾空白、衝突標記 | 不檢查內容 | git diff --check(已 commit 的用 git diff --check <baseline>..HEAD) | git 直接印 path:line: trailing whitespace |
沒有 pre-commit hook:commit 本身不被擋,所有錯誤留到 push 前才會被 hook 強制抓出。
3. 實作中(只跑本單的驗證)
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
| compile | 流程內 | 每次改 Java | 受影響模組與其 consumer 編譯通過 | 行為正確;producer 編譯通過不代表 consumer 相容 | bash gradlew :<module>:compileJava | Gradle 輸出 |
spay4SourceCheck | 流程內 | 改 spay4-bo Java 後、跑 Docker 測試前 | ConceptPackageStructureTest、SupplyMethodDocumentationContractTest 兩個來源契約(不起 Spring/DB) | 其他欄位文件、方法數上限、任何行為 | bash gradlew :spay4-bo:spay4SourceCheck --rerun --no-build-cache --offline --max-workers=2 --console=plain | Gradle 輸出;定義在 spay4-bo/build.gradle |
spay4FastCheck | 流程內 | spay4SourceCheck 之後、Docker 測試之前;isolated runner 每輪也先跑 | spay4-bo 全部 DB-free 測試(拿掉 native/G6 開關,Testcontainers 測試自動 skip) | schema、交易、鎖、真實 DB 語意;skipped 不算通過 | bash gradlew :spay4-bo:spay4FastCheck --rerun --offline --console=plain | Gradle 輸出;runner 內失敗為 FAST_CHECK_FAILED |
focused 測試(run-docker-tests.sh) | 流程內 | 行為變更時,只跑本單相關的測試類別 | 指定測試在本機 Docker MySQL 上通過;腳本先 docker-ensure.sh,執行中每分鐘探測 Docker | 其他模組、未指定的類別、slow 層;Docker 凍結時結果作廢 | SPAY4_TESTCONTAINERS_ENABLED=true bash scripts/run-docker-tests.sh :spay4-bo:test --tests '<class>'(腳本不代設開關,沒設則 Testcontainers 測試會 skip),結果用 node scripts/junit-summary.mjs spay4-bo test <class> 讀 | stdout 最後一行 DOCKER_UNAVAILABLE/DOCKER_LOST 代表環境問題,先 bash scripts/docker-ensure.sh 再重跑;測試失敗看 junit-summary.mjs 的 per-testcase 結果 |
模組對應表與 Legacy 模組規則見 development-workflow §3。文件與工具任務不跑這一節。
4. 候選前(每張 Issue 固定跑)
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
check-test-guardrail.sh | 流程內(也在 check-contracts.sh 內) | 每張 Issue 候選前 | 每個 scripts/*.test.mjs、scripts/test_*.py、docs/spay4/tools/*.test.mjs 都列在 check-contracts.sh 或 check-before-push.sh | 測試內容是否合理、是否真的會跑到(只做字串比對) | bash scripts/check-test-guardrail.sh | stderr「未接入 guardrail 的測試:<path>」;把檔名加進 check-contracts.sh 的 node --test 清單 |
check-sql-impact.mjs | 流程內 | 每張 Issue 候選前 | baseline 到目前 worktree 是否觸及 schema 相關檔;Issue 宣告 SQL prerequisite:無 而命中即 exit 1 | 只判斷「有沒有碰到」,不判斷語意;SQL impact ack: 列出的路徑視為已裁定 | node scripts/check-sql-impact.mjs <baseline> --issue <iid> | stdout 列出命中路徑;exit 1 是停點,走 SQL 路徑 C |
pinned-sources.mjs | 流程內 | 跑任何 Docker 測試前 | 變更檔是否被其他 tracked 檔以 SHA-256 釘住(compat fixture、site-progress 等),改一行註解也會讓釘住它的測試紅 | 多檔串接後才雜湊的 pin 偵測不到 | node scripts/pinned-sources.mjs <baseline> | 每個命中一行 PINNED <path> <sha256> <釘住它的檔案>;更新 pin 或停下來問 Operator |
deliver-sync-baseline.mjs | 流程內 | step 0、review 前、候選前 | 整合分支領先 baseline 時是否與本單改動或規則檔重疊:不重疊只記錄;重疊且 merge-tree 乾淨就合進來並前移 baseline;有衝突 exit 3 | 合進來的上游變更對本單的影響——merged 後要重讀 ruleFiles | node scripts/deliver-sync-baseline.mjs <iid>(在卡片 worktree、變更已 commit) | stdout JSON 的 status/overlap/conflicts;exit 3 時 git merge --abort 或手動解 |
Codex review(run-codex-review.mjs)/code-review skill | 流程內 | 候選前;最多兩輪修正 | Standards(repo 規範)與 Spec(Issue 要求)雙軸 review;Codex 不可用時改用 code-review skill 並標記 | 測試覆蓋與行為正確;review 只看 diff 與 spec | node scripts/run-codex-review.mjs <baseline> <spec.md> <worktree> <out-dir> [rulings.md] | <out-dir>/review.md;exit 1 時 stderr 一行 JSON,REVIEW_WROTE_REPO 代表 reviewer 動到 worktree |
deliver-candidate.mjs | 流程內 | review 通過後 | baseline 必須是 HEAD 與整合分支的 merge-base,才把本單 commits 壓成一個候選 commit | 候選內容本身的品質 | node scripts/deliver-candidate.mjs <iid> | stdout JSON;exit 1 多半是 baseline 過期,先跑 deliver-sync-baseline.mjs |
執行順序與停點見 issue-run Checks。
5. 合入整合分支
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
merge-into-integration.sh lock 與預約 | 流程內(唯一入口) | 「結案 n」、批次 Integrate、retro 合入 | 同一時間只有一組「回歸→合入」;候選未含整合分支 tip 時做漂移檢查(DRIFT exit 6);retro 分支與進行中 worktree 重疊時 INFLIGHT exit 7(inflight-overlap.mjs) | 合入內容的正確性;--accept-drift 只在 Operator 明示不重跑時用 | bash scripts/merge-into-integration.sh <branch> <message> [--regression <result.json>];狀態 --status | stdout 第一個字:MERGED/CONFLICT/INTEGRATION_BUSY(exit 75,稍後再試,不寫等待迴圈)/DRIFT/INFLIGHT;用法在腳本檔頭 |
reference-transaction 守衛 | 本地強制 | 任何對 refs/heads/<整合分支> 的寫入(prepared 階段) | 只放行無變化、單一 parent 直接 commit、fast-forward 到 origin/<整合分支>;merge commit、多 commit fast-forward、改寫歷史、刪除只允許 lock owner | pack-refs 與不帶舊 SHA 的 update-ref -d 是已知縫隙;主 checkout 在 develop/master 或 detached 時不檢查 | 安裝:bash scripts/install-integration-guard-hook.sh;守衛本身由 git 觸發 | git 拒絕 ref 更新時印出 hook 的 stderr;規則在 scripts/git-hooks/reference-transaction 檔頭 |
check-before-push.sh | 流程內(此時)/本地強制(push 時) | close-issue.mjs 合入後對 merged HEAD 跑一次 | 同下節;通過且工作區乾淨時寫 pass 標記並印出鎖定 SHA 的 推送指令 | 同下節 | bash scripts/check-before-push.sh | 同下節 |
只改 scripts/、.claude/、docs/、根目錄 *.md 的分支免回歸、可插隊合入,仍要過 check-contracts.sh。
6. push 時
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
| pre-push hook | 本地強制 | 每次 git push(install-pre-push-hook.sh 安裝一次,linked worktree 共用) | 推送的每個 commit 必須等於 HEAD、工作區乾淨(含未追蹤檔);對同一 SHA 已有 pass 標記就沿用,否則呼叫 check-before-push.sh;驗收期間 HEAD 或工作區改變即拒絕 | 不是伺服器端強制:--no-verify 或其他 client 可繞過(agent 側由 claude-hook-git-bypass.mjs 擋);不等於 GitLab CI 綠 | 無法手動觸發 hook 本身;跑下列 gate 等價 | hook stderr 以「拒絕 push:」開頭並說明原因 |
check-before-push.sh | 本地強制(hook 呼叫)/流程內(手動) | pre-push hook,或手動 | 依序:Node v24.21.0、git/python3 版本、check-commit-messages.mjs HEAD、整套 check-contracts.sh、V4 verifier 語法與測試(verify-v4-schema.mjs、v4-schema-verifier.mjs、push-gate-output.sh 的測試) | Java 編譯/測試、真實 DB、GitLab Runner、通知 runtime;手動跑時 HEAD 已在 remote-tracking ref 內會直接略過 | bash scripts/check-before-push.sh(SPAY_PUSH_GATE_FORCE=1 強制驗收、SPAY_PUSH_GATE_VERBOSE=1 即時輸出) | 終端只印失敗步驟最後 80 行,完整 log 在 $(git rev-parse --git-path spay-push-gate.log);最後一行 stderr push 前離線驗收失敗:<step>;完整 log:<path> |
check-contracts.sh 內的每道 check-*
check-contracts.sh 是本地 push gate 與 GitLab spay4-contract-check 共用的入口;以下每一列都在它裡面依序執行,任一非零即停。這張表由 check-quality-gates-doc.mjs 守著:check-contracts.sh 或 check-before-push.sh 新呼叫一支 check-* 腳本而這裡沒寫,push gate 就會失敗。
| 校驗 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|
check-text-encoding.mjs | 所有追蹤與未忽略的新文字檔(含兩支無副檔名 hook)是合法 UTF-8、無 BOM、無 NUL;範圍內 symlink 直接失敗 | 內容正確;副檔名清單以腳本為準 | node scripts/check-text-encoding.mjs | 只印相對路徑與錯誤類型,不印內容 |
check-secrets.mjs | PEM 私鑰、service account JSON、-firebase.json 等樣式 | 其他形式的密碼或 token(只比對固定樣式) | node scripts/check-secrets.mjs | 只印 path 與規則代碼,不印內容 |
check-markdown-links.mjs | Markdown 本地相對連結與 #anchor(含 frontmatter 內的 .md 路徑)存在;docs/archive/ 來源不檢查 | 外部 URL(不連線)、連結指到的內容是否仍正確 | node scripts/check-markdown-links.mjs | 列出 path:line 與壞掉的連結 |
check-skill-lines.mjs | skill Markdown 單行長度上限(一句一行,減少並行 session 的整行衝突) | 內容 | node scripts/check-skill-lines.mjs | 列出 path:line 與長度 |
check-legacy-skill-refs.mjs | AGENTS.md、docs/、skills 內不再以斜線指令形式推薦淘汰中的 deliver-spec、deliver-batch skill(一律改 /issue-run) | 其他過期說明 | node scripts/check-legacy-skill-refs.mjs | 列出 path:line |
check-script-refs.mjs | 文件提到的 <name>.mjs/.sh/.py/.ps1 在 repo 內必須有同名追蹤檔(腳本改名後文件不會悄悄過期) | 引用的用法與參數是否正確 | node scripts/check-script-refs.mjs | 列出 path:line name;提舊名請去掉副檔名,不加例外 |
check-claude-settings.mjs | .claude/settings.json 權限規則格式(Claude Code 對格式錯的規則只略過並警告,不會失敗) | 規則的意圖是否正確 | node scripts/check-claude-settings.mjs | 列出有問題的規則 |
check-adr.mjs | docs/adr/ 結構:概念檔 kebab-case、每節前有同編號錨點、編號全域唯一、無完全 Superseded 章節、README 索引一致 | 決策內容 | node scripts/check-adr.mjs | 列出 path 與原因 |
check-quality-gates-doc.mjs | check-contracts.sh 與 check-before-push.sh 呼叫到的每個 scripts/check-*.mjs/.sh(含 run_step 內與多行續行;*.test.mjs 由 check-test-guardrail.sh 管)都出現在本章 | 本章描述是否仍正確,只保證名字沒漏 | node scripts/check-quality-gates-doc.mjs | 列出缺漏檔名與修正指示(在本章加一列) |
build-workflow-site.mjs --check | docs/workflow/site/(統一入口站)與 Markdown 重產結果零 diff:忘了重產或手改 HTML 都擋下(README 統一入口站) | 站的內容與排版是否正確 | node scripts/build-workflow-site.mjs --check | 列出不同步的檔名與重產指令 |
| Bash/Node/Python 語法 | bash -n 各 shell gate 與 hook;node --check 所有 scripts/*.mjs、scripts/lib/*.mjs;py_compile 各 Python 工具與測試 | 執行期行為 | 見 check-contracts.sh 對應行 | 直譯器錯誤訊息 |
| docs/Postman validators | validate-docs.mjs、validate-postman.mjs、update-bo-ui-foundation-postman.mjs --check(A1/A2 collection) | 全站 Postman inventory 同步、API 實際行為 | node docs/spay4/tools/validate-docs.mjs 等 | validator 自己的輸出 |
node --test 回歸清單與 Python unittest | scripts/*.test.mjs 與 docs/spay4/tools/*.test.mjs 全部、run-python-tests.py 分三片跑的 Python 測試;fixture 都在 OS 暫存目錄 | 任何 Java、DB、GitLab、Orca 的真實行為 | node --test --test-force-exit --test-timeout=120000 <files> | node:test 的 TAP 輸出;Python 只印失敗分片的 log |
check-test-guardrail.sh | 同第 4 節 | 同第 4 節 | bash scripts/check-test-guardrail.sh | 同第 4 節 |
push 前另有唯讀的範圍報告 report-push-scope.mjs(交付範圍報告),列出要推出去的 commits 與路徑,不是驗收。
7. GitLab 手動 CI
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 本地等價指令 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
spay4-contract-check | 人工觸發 | GitLab「Run pipeline」(web)指定分支 | bash scripts/check-contracts.sh,在 node:24.21.0-bookworm-slim 上,15 分鐘 timeout | 與本地相同;加上 Runner 可用才算接上 CI | bash scripts/check-contracts.sh | GitLab job log |
spay4-schema-verifier-test | 人工觸發 | 同上,與上一個 job 平行 | V4 verifier 語法與測試(before_script: [],不裝任何套件) | 真實 target schema、READY 狀態(合成 READY 只在測試裡) | node --check scripts/verify-v4-schema.mjs && node --check scripts/lib/v4-schema-verifier.mjs && node --test --test-force-exit --test-timeout=120000 scripts/verify-v4-schema.test.mjs | GitLab job log |
現況:自動 pipeline 暫停,push/MR/tag/schedule 都不會起 pipeline,只接受手動 web pipeline;兩個 job 都沒有 allow_failure、retry 或 cache。啟用條件、安裝細節與驗收邊界見 docs/agents/ci-checks.md。
8. 回歸分級
| 校驗 | 強度 | 觸發點 | 檢查什麼 | 不能證明什麼 | 手動怎麼跑 | 失敗看哪裡 |
|---|---|---|---|---|---|---|
daily 層 :spay4-bo:test | 流程內 | 日常、focused 之外想跑整個模組時 | 排除 @Tag("slow") 的全部測試;runner 帶 --base <整合分支> 時重 class 只在 diff 命中其 guard 時跑 | slow 層、被選跑略過的重 class;不是驗收 | SPAY4_TESTCONTAINERS_ENABLED=true python3 scripts/run-spay4-isolated-tests.py --ref HEAD --base <integration-branch>(runner 不自動啟用任何環境 gate;見 runner README) | runner 的 result.json;CLOCK_UNSTABLE 先 --clock-probe 量時鐘 |
| acceptance 層(固定 SHA) | 流程內 | 候選 commit 要合入前;批次只跑一次統一回歸 | SPAY4_SLOW_TESTS_ENABLED=true、SPAY4_TESTCONTAINERS_ENABLED=true,不給 --base,全部 class 都跑;通過才能帶 --regression 合入 | 排除任何重 class 或 slow 的 run 不算驗收;--no-reservation 的量測 run 永遠不是合入證據 | node scripts/queue-regression.mjs --artifacts-root <dir> [--merge <message>] --detach,再 --wait <dir> | queue-regression.json 的 status(REGRESSION_FAILED/SYNC_CONFLICT…);result.json 的 fullAcceptance 必須為 true |
queue-regression.mjs 預約 | 流程內 | acceptance 層起跑前 | 以 merge-into-integration.sh --reserve --wait 嚴格 FIFO 排隊,候選落後整合分支 tip 時先同步再跑;失敗自動釋放預約 | 排隊中別人的改動——所以才要同步後再跑 | 同上 | stdout 最後一行 JSON;merge-into-integration.sh --status 看誰持有預約 |
分級定義與重 class 清單見 SPay4 測試方式;排隊規則見 issue-run Candidate and acceptance。
整體邊界
- push 前的 gate(pre-push hook →
check-before-push.sh→check-contracts.sh)是離線的:不連 DB、tracker、Cloudflare;也不證明 Java 編譯、真實 DB、部署。實作中與候選前的校驗各有自己的依賴:compile 與 focused 測試需要 JDK 與本機 Docker,check-sql-impact.mjs會讀 GitLab Issue body,acceptance 回歸需要 Docker 與整合分支預約;它們的邊界以各自那一列的「不能證明什麼」為準。 - 流程內 gate 的強制力來自 skill 照步驟跑;真正繞不過的只有三道 hook(PreToolUse、pre-push、
reference-transaction)與 settings deny。 - GitLab 端目前沒有任何自動把關;push 之後的品質靠 push 前的 gate 與人工 pipeline。