From 01094931f1e554468fd3ce4717555bfdc2db2392 Mon Sep 17 00:00:00 2001 From: screenleon Date: Thu, 2 Jul 2026 09:44:14 +0900 Subject: [PATCH 1/4] feat(CC-425): gate --head for fixed base..head ref diffs pmctl gate run / pr-gate.sh can now diff a fixed base..head ref pair (branch, tag, or commit) with no PR and no working tree involved -- review a branch before opening a PR, or diff v0.6.0..v0.7.0 tag-to-tag. Rejects --allow-dirty as incompatible since it exists to fold local uncommitted state into scope, which a fixed ref pair never touches. Both pmctl gate run routes (foreground exec and --lifecycle detached via gate-supervisor.sh's -- passthrough) forward --head unchanged since neither special-cases it -- unrecognized flags already pass through. Repo audit during implementation found the ticket's other stated problem (gate result keyed by PR#) already resolved by the CC-423 detached-lifecycle refactor (gate_id-keyed run dirs) and the timestamp-based --output default -- narrowing this ticket's actual scope to --head alone. Co-Authored-By: Claude Sonnet 5 --- BACKLOG.md | 13 +++---- CHANGELOG.md | 4 ++ MILESTONES.md | 2 +- scripts/pr-gate.sh | 62 ++++++++++++++++++++++++++---- scripts/test-pr-gate.sh | 83 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 17 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 596673f8..099f95ce 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -73,7 +73,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-393 | 🟢 someday | design: portable-skill-substrate — CLI-agnostic skill 控制層(design seed after v0.6.0 N≥2;3 control skills + Portable Skill v0 frontmatter;umbrella: CC-333) | arch | 2026-06-16 | — | — | design | | CC-412 | ✅ closed 2026-07-01 | memory substrate 跨工具可攜:位置 seam(`PM_MEMORY_DIR` override)+ 注入/檢索分層(可攜核心=pmctl retrieval API)。v0.8.0 Phase 1 headline | arch/memory | 2026-06-23 | pr:#352 | P3 | retrieval | | CC-423 | ✅ closed 2026-07-01 | gate detached lifecycle:`pmctl gate run --lifecycle detached`(現為預設)回傳 gate_id 立即退出;gate-supervisor 以 nohup/setsid 跑 pr-gate.sh;sentinel 機制 + `pmctl gate wait ` 輪詢,result 完整性 fail-closed;session interrupt 不影響 gate 執行結果。v0.8.0 Phase 2 | arch | 2026-06-25 | pr:#353 | P3 | — | -| CC-425 | 🔵 active | **[gate: 解除 PR 綁定,改以 base..head ref 對為輸入]** 現在 `pmctl gate run` 預設從 `origin/main` fork point 推斷 base,gate result 以 PR# 為 key;改成接受任意兩個 ref(`--base --head `),讓 gate 可在開 PR 前本地跑,也可比較任意 branch 差異。需重構 gate 的 base 解析邏輯與 result 存放路徑(目前以 PR# 為 key,改以 `..` slug 或 run_id)。排入 v0.8.0 Phase 2。 | ops/gate | 2026-06-25 | — | P3 | — | +| CC-425 | ✅ closed 2026-07-02 | `pr-gate.sh --head ` 新增;diff 一組固定 base..head ref(branch/tag/commit),不涉及 PR 或 working tree;`--base` 既有支援已可省 PR。與 `--allow-dirty` 互斥(明確拒絕)。 | ops/gate | 2026-06-25 | pr:#TBD | P3 | — | | CC-431 | 🟢 someday | **[test-e2e.sh + release-verify.sh: opencode adapter support]** `--adapter` 目前只接受 `claude\|codex\|auto`;opencode 在 v0.6.0 加入後未同步更新 e2e 驗證路徑。需:(1) 將 opencode 加入兩腳本的 adapter 驗證清單;(2) Phase B dispatch 支援 opencode;(3) Phase C pr-gate smoke 評估是否可用 opencode executor(目前硬碼 codex)。觸發:release-verify --e2e --adapter opencode 被拒(exit 2)。 | ops/test | 2026-06-30 | — | P3 | — | | CC-432 | ✅ closed 2026-07-02 | test-release-verify.sh 12 個重複 `--no-suite` 呼叫改共用快取(`rv_no_suite_once`),380s → ~127s;方向 A(假 repo 隔離)/序列化耦合窄化皆評估後擱置不追(風險高於效益) | ops/test | 2026-07-01 | pr:#354 | P2 | design | | CC-433 | 🟢 someday | **[detached lifecycle:抽共用 sentinel lib + wait 改主動通知]** (1) `scripts/dispatch-supervisor.sh` 與 `scripts/gate-supervisor.sh` 的 setsid/nohup 啟動 + nonce-authenticated sentinel 寫入邏輯結構相同但各自重寫,應抽成共用 lib,兩邊各自只保留獨有業務邏輯(preflight+adapter vs. 直接 exec pr-gate.sh);(2) `pmctl dispatch wait`/`pmctl gate wait` 目前用 `sleep \$POLL_INTERVAL` 輪詢 sentinel 檔案,應改為主動通知(如 blocking read on FIFO、inotify 等),supervisor 完成時主動喚醒 wait 而非讓它每 N 秒醒來檢查一次。解法未定案,需先 `/pre-impl` 或 `/spike` 收斂設計。 | arch/gate | 2026-07-01 | — | P3 | design | @@ -1147,16 +1147,13 @@ Fix:文件化 `GOPATH=/tmp/gopath go build` 慣例到 brief self_verify go bui **Priority**: P3(someday). -## CC-425 — gate: 解除 PR 綁定,改以 base..head ref 對為輸入 🔵 active +## CC-425 — gate: 解除 PR 綁定,改以 base..head ref 對為輸入 ✅ 2026-07-02 -**Problem**: `pmctl gate run` 目前的 base 推斷邏輯綁死在 `git merge-base --fork-point origin/main HEAD`,gate result 也以 PR# 為 primary key——這意味著 gate 只能在已有 PR(或預設對 main)的情況下有意義地跑,無法在開 PR 前本地對任意兩個 branch 做 diff-gate,也無法比較 `v0.6.0..v0.7.0` 這類 tag-to-tag diff。 +**See**: pr:#TBD -**Why**: 讓 gate 成為通用的「diff 品質閘門」,不依賴 PR 存在。可用於:開 PR 前先本地跑確認、milestone boundary 差異審查、任意 feature branch 對 release branch 的 diff review。 +**Resolution**: 盤點現有程式碼後發現票面描述的兩個問題中,一個已在先前重構中解決:`pmctl gate run`(detached lifecycle,CC-423)的 result 存放路徑早已改用 `gate_id`(`sw_project_run_dir`)而非 PR#;foreground 路徑的 `--output` 預設值也是 `.gate-results/gate-.md`(timestamp-based),並非 PR# key。`--base ` 亦已存在且可在無 PR 情況下運作(`gh pr view` 失敗會 fallback 到 `origin/HEAD` symbolic-ref,不 hard-fail)。真正缺的是 `--head `:diff 邏輯之前寫死比對 `HEAD`(當前 checkout),無法比較兩個任意固定 ref(如 tag-to-tag、或未 checkout 的 branch)。 -**Requirement**: -- `pmctl gate run [--base ] [--head ]`:兩者均可省略(維持現有推斷行為作為 fallback)。 -- gate result 存放路徑從 PR# key 改為 `..` 或 run_id,PR# 僅在有 PR 時作為 optional metadata 加入。 -- 文件 + `pmctl gate run --help` 說明新參數。 +新增 `pr-gate.sh --head `:省略時維持現有行為(working tree / 當前分支 fallback);指定時走獨立分支直接 `git diff "$BASE"..."$HEAD_REF"`,不觸碰 working tree 或 dirty-preflight 邏輯,因此與 `--allow-dirty`(其存在目的是把 working tree 折入 scope)明確互斥並拒絕(exit 2)。Reviewer brief context block 在 `--head` 生效時額外顯示 `Head: ` 一行。`pmctl gate run` 兩條路徑(foreground exec 與 `--lifecycle detached` 透過 `gate-supervisor.sh` 的 `--` passthrough)皆無需改動即可轉發 `--head`,因為都是把未知旗標原樣傳給 `pr-gate.sh`。`--help` usage block 與 unknown-arg 提示同步補上。新增 3 個測試(固定 ref diff 正確性、無效 head ref 報錯、`--head`+`--allow-dirty` 衝突拒絕)於 `scripts/test-pr-gate.sh`,122 項全數通過;`test-gate-lifecycle.sh`/`test-pmctl-gate.sh` 無回歸。 **Priority**: P3(someday). diff --git a/CHANGELOG.md b/CHANGELOG.md index 675dec3d..20905af0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **`pr-gate.sh --head ` (CC-425).** Gate can now diff a fixed `base..head` ref pair (branch, tag, or commit) with no PR and no working tree involved — review a branch before opening a PR, or diff `v0.6.0..v0.7.0` tag-to-tag. Rejects `--allow-dirty` (which folds in local uncommitted state) as incompatible. Forwarded transparently through both the foreground and `--lifecycle detached` routes since `pmctl gate run` already passes unrecognized flags through to `pr-gate.sh`. + --- ## [0.7.1] — 2026-06-30 diff --git a/MILESTONES.md b/MILESTONES.md index 9ffea1c0..901833c9 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,7 +41,7 @@ | CC-423 | gate detached lifecycle:`pmctl gate run --lifecycle detached`(現為預設)回傳 gate_id 立即退出;gate-supervisor 以 nohup/setsid 跑 pr-gate.sh;sentinel 機制 + `pmctl gate wait ` 輪詢,result 完整性 fail-closed,鏡像既有 `dispatch --lifecycle detached` 模式 | ✅ done pr:#353 | | CC-433 | detached lifecycle 收尾:(1) 抽出 dispatch/gate 兩份 supervisor 共用的 sentinel 啟動邏輯成共用 lib;(2) `pmctl dispatch wait`/`pmctl gate wait` 的輪詢(`sleep` 迴圈)改為主動通知(FIFO/inotify 等,解法未定案)。CC-423 交付後發現的簡化與效率改善項,解法待 `/pre-impl` 或 `/spike` 收斂 | 🟢 someday | | CC-432 | run-all-tests.sh 耗時瓶頸:`test-release-verify.sh` 12 個重複 `--no-suite` 呼叫改共用快取(`rv_no_suite_once`),380s → ~127s。方向 A(Phase 3 smoke 改隔離假 repo)與 `LIVE_DB_EXCLUSIVE` 序列化耦合窄化皆評估後擱置不追(風險高於效益,未來可重新評估) | ✅ done pr:#354 | -| CC-425 | gate 解除 PR 綁定:`pmctl gate run [--base ] [--head ]`,兩者均可省略(維持現有 fork-point 推斷作為 fallback);gate result 存放路徑從 PR# key 改為 `..` 或 run_id,PR# 僅在有 PR 時作為 optional metadata。讓 gate 可在開 PR 前本地跑,也可比較任意 branch/tag 差異 | 🔵 active | +| CC-425 | gate 解除 PR 綁定:`pr-gate.sh --head ` 新增,diff 固定 base..head ref 對,不涉 PR/working tree;盤點發現 result 路徑 PR# key 問題已在 CC-423 detached lifecycle 重構中解決(改用 gate_id),`--base` 也已支援無 PR 場景,故實際範圍小於原評估 | ✅ done pr:#TBD | > CC-433 排入 Phase 2 作為 CC-423 的後續收斂項,非阻塞本 Phase 其餘票的完成。CC-432 為 CC-423 pr-gate 迭代中發現並記錄的衍生票,同樣併入 Phase 2。CC-425 原評估「需重構 gate result key schema,範圍比 CC-276/423 大一截」暫不排入,2026-07-02 使用者確認排入本 Phase 處理——需重構 base 解析邏輯與 result 存放路徑(PR# key → base..head slug/run_id),實作前建議先 `/pre-impl` 確認既有 PR# key 的 gate result 讀取路徑(`pmctl gate wait`/`pmctl artifacts show` 等)向下相容策略。 diff --git a/scripts/pr-gate.sh b/scripts/pr-gate.sh index fea71619..98207bdb 100755 --- a/scripts/pr-gate.sh +++ b/scripts/pr-gate.sh @@ -61,6 +61,10 @@ _kill_process_tree() { # --targeted alias for --reviewers (matches /pr-gate skill vocabulary) # --scope context hint passed into the review brief # --base base branch for diff (default: origin/HEAD → main) +# --head head ref for diff (default: HEAD / working tree); pass a fixed ref +# (branch, tag, commit) to diff base..head without a PR or working +# tree involved -- e.g. review a branch before opening a PR, or diff +# one tag against another (v0.6.0..v0.7.0). Incompatible with --allow-dirty. # --run-dir out-of-repo dir for gate artifacts (briefs/results/trace); optional, # defaults to in-repo paths under --cd when absent (backward compat) # --output result file (default: .gate-results/gate-.md) @@ -85,6 +89,7 @@ TIER_OVERRIDE="" REVIEWERS_OVERRIDE="" SCOPE="" BASE_OVERRIDE="" +HEAD_OVERRIDE="" OUTPUT_OVERRIDE="" TIMEOUT="1200" SEQUENTIAL=true # default: sequential (lower token cost) @@ -112,6 +117,7 @@ while [[ $# -gt 0 ]]; do --targeted) REVIEWERS_OVERRIDE="$2"; shift 2;; # alias: /pr-gate skill + script comments say "targeted" --scope) SCOPE="$2"; shift 2;; --base) BASE_OVERRIDE="$2"; shift 2;; + --head) HEAD_OVERRIDE="$2"; shift 2;; --output) OUTPUT_OVERRIDE="$2"; shift 2;; --executor) EXECUTOR_OPTION="$2"; shift 2;; --model) DISPATCH_MODEL="$2"; shift 2;; @@ -128,11 +134,11 @@ while [[ $# -gt 0 ]]; do [[ $# -ge 2 ]] || { printf 'Error: --override-file requires a file path\n' >&2; exit 2; } OVERRIDE_FILE="$2"; shift 2;; -h|--help) - sed -n '2,63p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,84p' "$0" | sed 's/^# \{0,1\}//' exit 0;; *) printf 'Unknown arg: %s\n' "$1" >&2 - printf 'Accepted: --cd --run-dir --tier --brief --reviewers|--targeted --scope --base --output --executor --model --isolation --timeout --parallel --sequential --allow-hooks --allow-dirty --override-file (-h for help)\n' >&2 + printf 'Accepted: --cd --run-dir --tier --brief --reviewers|--targeted --scope --base --head --output --executor --model --isolation --timeout --parallel --sequential --allow-hooks --allow-dirty --override-file (-h for help)\n' >&2 exit 2;; esac done @@ -447,6 +453,31 @@ if ! git rev-parse --verify "$BASE" > /dev/null 2>&1; then exit 1 fi +# ── Detect head ref ──────────────────────────────────────────────────────── +# Default HEAD keeps the existing working-tree/branch-diff behavior below. +# A fixed --head ref (branch, tag, commit) diffs base..head_ref directly with +# no working tree involved, so it is incompatible with --allow-dirty (which +# exists specifically to fold uncommitted working-tree state into scope). +HEAD_REF="HEAD" +if [[ -n "$HEAD_OVERRIDE" ]]; then + if [[ "$ALLOW_DIRTY" == true ]]; then + printf 'Error: --head and --allow-dirty are incompatible (--head diffs a fixed ref pair; --allow-dirty folds in local working-tree changes)\n' >&2 + exit 2 + fi + HEAD_REF="$HEAD_OVERRIDE" + if ! git rev-parse --verify "$HEAD_REF" > /dev/null 2>&1; then + printf 'Error: head ref not found: %s\n' "$HEAD_REF" >&2 + exit 1 + fi +fi +# Surfaced in reviewer brief context blocks (Base: ${BASE}${HEAD_METADATA_LINE}) +# only when a fixed --head ref is in play; a plain HEAD comparison omits the +# line entirely since it would just say "Head: HEAD" (no information). +HEAD_METADATA_LINE="" +if [[ "$HEAD_REF" != "HEAD" ]]; then + HEAD_METADATA_LINE=$'\n Head: '"${HEAD_REF}" +fi + _worktree_is_dirty() { # uncommitted tracked changes (staged or unstaged) ... if ! git diff --quiet HEAD 2>/dev/null; then return 0; fi @@ -461,8 +492,9 @@ _worktree_is_dirty() { # commits first for a complete, reproducible review -- unless they explicitly # opt into reviewing the working tree as-is. A dirty-only tree with NO # committed changes is handled by the working-tree fallback below and is NOT -# failed here (nothing is omitted in that case). -if ! git diff "$BASE"...HEAD --quiet 2>/dev/null && _worktree_is_dirty; then +# failed here (nothing is omitted in that case). Skipped entirely for a fixed +# --head ref: that path never reads the working tree. +if [[ "$HEAD_REF" == "HEAD" ]] && ! git diff "$BASE"...HEAD --quiet 2>/dev/null && _worktree_is_dirty; then if [[ "$ALLOW_DIRTY" != true ]]; then _dt=$(git diff HEAD --name-only 2>/dev/null | { grep -c . || true; }) _du=$(git ls-files --others --exclude-standard | { grep -c . || true; }) @@ -481,7 +513,21 @@ fi # ── Collect diff ────────────────────────────────────────────────────────────── # Use --name-status so renames expose BOTH old and new paths for sensitive matching. # Use --numstat to detect binary files (shown as -\t-\t). -if [[ "$ALLOW_DIRTY" == true ]] && _worktree_is_dirty; then +if [[ "$HEAD_REF" != "HEAD" ]]; then + # Fixed base..head ref pair (e.g. tag-to-tag, or a branch reviewed before a + # PR exists) -- no working tree involved, so no dirty/fallback branches apply. + DIFF_FILES=$(git diff "$BASE"..."$HEAD_REF" --name-status | awk ' + /^R/ { print $2; print $3; next } + /^[AMDCT]/ { print $2 } + ') + DIFF_STAT=$(git diff "$BASE"..."$HEAD_REF" --stat) + BINARY_HIT=$(git diff "$BASE"..."$HEAD_REF" --numstat | { grep -c $'^-\t-\t' || true; }) + LINES=$(git diff "$BASE"..."$HEAD_REF" --numstat | awk ' + /^-\t-\t/ { next } + { s += $1 + $2 } + END { print s+0 } + ') +elif [[ "$ALLOW_DIRTY" == true ]] && _worktree_is_dirty; then # --allow-dirty: fold the working tree into scope. Two-dot diff vs BASE # captures committed + uncommitted tracked changes; untracked listed separately. DIFF_FILES=$( { git diff "$BASE" --name-status | awk ' @@ -840,7 +886,7 @@ context: Executor: ${EXECUTOR} Reviewers: ${REVIEWER_DISPLAY} Not reviewed: ${SKIPPED_DISPLAY} - Base: ${BASE} + Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') ${GATE_OVERRIDES_CONTEXT_BLOCK} @@ -1027,7 +1073,7 @@ context: Tier: ${TIER} Executor: ${EXECUTOR} Reviewer: ${r} - Base: ${BASE} + Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') ${GATE_OVERRIDES_CONTEXT_BLOCK} @@ -1251,7 +1297,7 @@ context: Executor: ${EXECUTOR} Reviewers: ${REVIEWER_DISPLAY} Not reviewed: ${SKIPPED_DISPLAY} - Base: ${BASE} + Base: ${BASE}${HEAD_METADATA_LINE} Scope: ${SCOPE:-none} Date: $(date '+%Y-%m-%d') ${GATE_OVERRIDES_CONTEXT_BLOCK} diff --git a/scripts/test-pr-gate.sh b/scripts/test-pr-gate.sh index 890b3499..c917de14 100755 --- a/scripts/test-pr-gate.sh +++ b/scripts/test-pr-gate.sh @@ -4522,4 +4522,87 @@ run_test test_gate_run_dir_no_output_failure_leaves_no_repo_artifacts run_test test_gate_run_dir_no_verdict_failure_leaves_no_repo_artifacts run_test test_gate_run_dir_parallel_failure_leaves_no_repo_artifacts +# CC-425: --head diffs a fixed base..head ref pair with no PR or working +# tree involved (e.g. review a branch before opening a PR, or a tag-to-tag diff). +test_head_override_diffs_fixed_ref() { + local name="head-override-diffs-fixed-ref" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" brief="$dir/brief.md" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo_with_branch "$repo" standard + # Checked out on main (not feature) proves --head does not require checking + # out the ref -- it diffs base..head_ref directly. + git -C "$repo" checkout -q main + + set +e + CODEX_GATE_CAPTURE_BRIEF="$brief" \ + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --head feature + local code=$? + set -e + if [[ "$code" -ne 0 ]]; then + fail "$name" "exit $code, expected 0" + return + fi + assert_file_contains "$name" "$brief" "Head: feature" || return + assert_file_contains "$name" "$brief" "app.go" || return + pass "$name" +} + +test_head_override_invalid_ref() { + local name="head-override-invalid-ref" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$repo" docs + + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --head nonexistent-ref-98765 + local code=$? + set -e + if [[ "$code" -eq 0 ]]; then + fail "$name" "expected non-zero exit" + return + fi + assert_file_contains "$name" "$err" "Error: head ref not found: nonexistent-ref-98765" || return + assert_not_contains "$name" "$out" "DISPATCH_STUB" || return + pass "$name" +} + +test_head_override_rejects_allow_dirty() { + local name="head-override-rejects-allow-dirty" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo_with_branch "$repo" standard + git -C "$repo" checkout -q main + + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --head feature --allow-dirty + local code=$? + set -e + if [[ "$code" -eq 0 ]]; then + fail "$name" "expected non-zero exit" + return + fi + assert_file_contains "$name" "$err" "--head and --allow-dirty are incompatible" || return + assert_not_contains "$name" "$out" "DISPATCH_STUB" || return + pass "$name" +} + +run_test test_head_override_diffs_fixed_ref +run_test test_head_override_invalid_ref +run_test test_head_override_rejects_allow_dirty + th_summary From 543d3e8232382c2e6c3d4c75afd90f3e09650836 Mon Sep 17 00:00:00 2001 From: screenleon Date: Thu, 2 Jul 2026 09:47:07 +0900 Subject: [PATCH 2/4] chore(CC-425): backfill pr:#355 reference Co-Authored-By: Claude Sonnet 5 --- BACKLOG.md | 4 ++-- MILESTONES.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 099f95ce..baba4afc 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -73,7 +73,7 @@ CC-001/CC-002 were consumed by PR #24 fix bundle inline, with no standalone entr | CC-393 | 🟢 someday | design: portable-skill-substrate — CLI-agnostic skill 控制層(design seed after v0.6.0 N≥2;3 control skills + Portable Skill v0 frontmatter;umbrella: CC-333) | arch | 2026-06-16 | — | — | design | | CC-412 | ✅ closed 2026-07-01 | memory substrate 跨工具可攜:位置 seam(`PM_MEMORY_DIR` override)+ 注入/檢索分層(可攜核心=pmctl retrieval API)。v0.8.0 Phase 1 headline | arch/memory | 2026-06-23 | pr:#352 | P3 | retrieval | | CC-423 | ✅ closed 2026-07-01 | gate detached lifecycle:`pmctl gate run --lifecycle detached`(現為預設)回傳 gate_id 立即退出;gate-supervisor 以 nohup/setsid 跑 pr-gate.sh;sentinel 機制 + `pmctl gate wait ` 輪詢,result 完整性 fail-closed;session interrupt 不影響 gate 執行結果。v0.8.0 Phase 2 | arch | 2026-06-25 | pr:#353 | P3 | — | -| CC-425 | ✅ closed 2026-07-02 | `pr-gate.sh --head ` 新增;diff 一組固定 base..head ref(branch/tag/commit),不涉及 PR 或 working tree;`--base` 既有支援已可省 PR。與 `--allow-dirty` 互斥(明確拒絕)。 | ops/gate | 2026-06-25 | pr:#TBD | P3 | — | +| CC-425 | ✅ closed 2026-07-02 | `pr-gate.sh --head ` 新增;diff 一組固定 base..head ref(branch/tag/commit),不涉及 PR 或 working tree;`--base` 既有支援已可省 PR。與 `--allow-dirty` 互斥(明確拒絕)。 | ops/gate | 2026-06-25 | pr:#355 | P3 | — | | CC-431 | 🟢 someday | **[test-e2e.sh + release-verify.sh: opencode adapter support]** `--adapter` 目前只接受 `claude\|codex\|auto`;opencode 在 v0.6.0 加入後未同步更新 e2e 驗證路徑。需:(1) 將 opencode 加入兩腳本的 adapter 驗證清單;(2) Phase B dispatch 支援 opencode;(3) Phase C pr-gate smoke 評估是否可用 opencode executor(目前硬碼 codex)。觸發:release-verify --e2e --adapter opencode 被拒(exit 2)。 | ops/test | 2026-06-30 | — | P3 | — | | CC-432 | ✅ closed 2026-07-02 | test-release-verify.sh 12 個重複 `--no-suite` 呼叫改共用快取(`rv_no_suite_once`),380s → ~127s;方向 A(假 repo 隔離)/序列化耦合窄化皆評估後擱置不追(風險高於效益) | ops/test | 2026-07-01 | pr:#354 | P2 | design | | CC-433 | 🟢 someday | **[detached lifecycle:抽共用 sentinel lib + wait 改主動通知]** (1) `scripts/dispatch-supervisor.sh` 與 `scripts/gate-supervisor.sh` 的 setsid/nohup 啟動 + nonce-authenticated sentinel 寫入邏輯結構相同但各自重寫,應抽成共用 lib,兩邊各自只保留獨有業務邏輯(preflight+adapter vs. 直接 exec pr-gate.sh);(2) `pmctl dispatch wait`/`pmctl gate wait` 目前用 `sleep \$POLL_INTERVAL` 輪詢 sentinel 檔案,應改為主動通知(如 blocking read on FIFO、inotify 等),supervisor 完成時主動喚醒 wait 而非讓它每 N 秒醒來檢查一次。解法未定案,需先 `/pre-impl` 或 `/spike` 收斂設計。 | arch/gate | 2026-07-01 | — | P3 | design | @@ -1149,7 +1149,7 @@ Fix:文件化 `GOPATH=/tmp/gopath go build` 慣例到 brief self_verify go bui ## CC-425 — gate: 解除 PR 綁定,改以 base..head ref 對為輸入 ✅ 2026-07-02 -**See**: pr:#TBD +**See**: pr:#355 **Resolution**: 盤點現有程式碼後發現票面描述的兩個問題中,一個已在先前重構中解決:`pmctl gate run`(detached lifecycle,CC-423)的 result 存放路徑早已改用 `gate_id`(`sw_project_run_dir`)而非 PR#;foreground 路徑的 `--output` 預設值也是 `.gate-results/gate-.md`(timestamp-based),並非 PR# key。`--base ` 亦已存在且可在無 PR 情況下運作(`gh pr view` 失敗會 fallback 到 `origin/HEAD` symbolic-ref,不 hard-fail)。真正缺的是 `--head `:diff 邏輯之前寫死比對 `HEAD`(當前 checkout),無法比較兩個任意固定 ref(如 tag-to-tag、或未 checkout 的 branch)。 diff --git a/MILESTONES.md b/MILESTONES.md index 901833c9..48c02e0a 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,7 +41,7 @@ | CC-423 | gate detached lifecycle:`pmctl gate run --lifecycle detached`(現為預設)回傳 gate_id 立即退出;gate-supervisor 以 nohup/setsid 跑 pr-gate.sh;sentinel 機制 + `pmctl gate wait ` 輪詢,result 完整性 fail-closed,鏡像既有 `dispatch --lifecycle detached` 模式 | ✅ done pr:#353 | | CC-433 | detached lifecycle 收尾:(1) 抽出 dispatch/gate 兩份 supervisor 共用的 sentinel 啟動邏輯成共用 lib;(2) `pmctl dispatch wait`/`pmctl gate wait` 的輪詢(`sleep` 迴圈)改為主動通知(FIFO/inotify 等,解法未定案)。CC-423 交付後發現的簡化與效率改善項,解法待 `/pre-impl` 或 `/spike` 收斂 | 🟢 someday | | CC-432 | run-all-tests.sh 耗時瓶頸:`test-release-verify.sh` 12 個重複 `--no-suite` 呼叫改共用快取(`rv_no_suite_once`),380s → ~127s。方向 A(Phase 3 smoke 改隔離假 repo)與 `LIVE_DB_EXCLUSIVE` 序列化耦合窄化皆評估後擱置不追(風險高於效益,未來可重新評估) | ✅ done pr:#354 | -| CC-425 | gate 解除 PR 綁定:`pr-gate.sh --head ` 新增,diff 固定 base..head ref 對,不涉 PR/working tree;盤點發現 result 路徑 PR# key 問題已在 CC-423 detached lifecycle 重構中解決(改用 gate_id),`--base` 也已支援無 PR 場景,故實際範圍小於原評估 | ✅ done pr:#TBD | +| CC-425 | gate 解除 PR 綁定:`pr-gate.sh --head ` 新增,diff 固定 base..head ref 對,不涉 PR/working tree;盤點發現 result 路徑 PR# key 問題已在 CC-423 detached lifecycle 重構中解決(改用 gate_id),`--base` 也已支援無 PR 場景,故實際範圍小於原評估 | ✅ done pr:#355 | > CC-433 排入 Phase 2 作為 CC-423 的後續收斂項,非阻塞本 Phase 其餘票的完成。CC-432 為 CC-423 pr-gate 迭代中發現並記錄的衍生票,同樣併入 Phase 2。CC-425 原評估「需重構 gate result key schema,範圍比 CC-276/423 大一截」暫不排入,2026-07-02 使用者確認排入本 Phase 處理——需重構 base 解析邏輯與 result 存放路徑(PR# key → base..head slug/run_id),實作前建議先 `/pre-impl` 確認既有 PR# key 的 gate result 讀取路徑(`pmctl gate wait`/`pmctl artifacts show` 等)向下相容策略。 From 2ae07d7e745921706f9f4cf2c38c9056a5d2cdde Mon Sep 17 00:00:00 2001 From: screenleon Date: Thu, 2 Jul 2026 09:56:53 +0900 Subject: [PATCH 3/4] fix(CC-425): address gate NO-GO findings on --head - Guard bare `--head` operand with a controlled error instead of a raw `unbound variable` crash under set -u (mirrors --override-file). - Clarify --head uses the same merge-base (three-dot) semantics as the existing --base path in --help, code comments, BACKLOG, and CHANGELOG -- the prior wording ("diff base..head") read as a literal two-dot tree diff, which is not what the implementation does. - Add test_head_override_merge_base_semantics: a diverged base/head topology (main and feature each gain independent commits) proving three-dot semantics -- base's own progress does not leak into the diff. - Add test_head_override_missing_operand for the new guard. - Fix the stale MILESTONES.md Phase 2 note that still described CC-425 as needing pre-implementation design work, contradicting the done row immediately above it. scripts/test-pr-gate.sh: 124 passed, 0 failed (was 122; +2 new). Co-Authored-By: Claude Sonnet 5 --- BACKLOG.md | 4 ++- CHANGELOG.md | 2 +- MILESTONES.md | 4 +-- scripts/pr-gate.sh | 25 ++++++++++---- scripts/test-pr-gate.sh | 72 +++++++++++++++++++++++++++++++++++++++-- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index baba4afc..7854afd7 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1153,7 +1153,9 @@ Fix:文件化 `GOPATH=/tmp/gopath go build` 慣例到 brief self_verify go bui **Resolution**: 盤點現有程式碼後發現票面描述的兩個問題中,一個已在先前重構中解決:`pmctl gate run`(detached lifecycle,CC-423)的 result 存放路徑早已改用 `gate_id`(`sw_project_run_dir`)而非 PR#;foreground 路徑的 `--output` 預設值也是 `.gate-results/gate-.md`(timestamp-based),並非 PR# key。`--base ` 亦已存在且可在無 PR 情況下運作(`gh pr view` 失敗會 fallback 到 `origin/HEAD` symbolic-ref,不 hard-fail)。真正缺的是 `--head `:diff 邏輯之前寫死比對 `HEAD`(當前 checkout),無法比較兩個任意固定 ref(如 tag-to-tag、或未 checkout 的 branch)。 -新增 `pr-gate.sh --head `:省略時維持現有行為(working tree / 當前分支 fallback);指定時走獨立分支直接 `git diff "$BASE"..."$HEAD_REF"`,不觸碰 working tree 或 dirty-preflight 邏輯,因此與 `--allow-dirty`(其存在目的是把 working tree 折入 scope)明確互斥並拒絕(exit 2)。Reviewer brief context block 在 `--head` 生效時額外顯示 `Head: ` 一行。`pmctl gate run` 兩條路徑(foreground exec 與 `--lifecycle detached` 透過 `gate-supervisor.sh` 的 `--` passthrough)皆無需改動即可轉發 `--head`,因為都是把未知旗標原樣傳給 `pr-gate.sh`。`--help` usage block 與 unknown-arg 提示同步補上。新增 3 個測試(固定 ref diff 正確性、無效 head ref 報錯、`--head`+`--allow-dirty` 衝突拒絕)於 `scripts/test-pr-gate.sh`,122 項全數通過;`test-gate-lifecycle.sh`/`test-pmctl-gate.sh` 無回歸。 +新增 `pr-gate.sh --head `:省略時維持現有行為(working tree / 當前分支 fallback);指定時走獨立分支,採與既有 `--base` 相同的 merge-base(three-dot)語意 `git diff "$BASE"..."$HEAD_REF"`(比較 head 相對 merge-base 的變更,而非字面 two-dot tree diff——base 之後的獨立進展不會滲入 diff),不觸碰 working tree 或 dirty-preflight 邏輯,因此與 `--allow-dirty`(其存在目的是把 working tree 折入 scope)明確互斥並拒絕(exit 2)。Reviewer brief context block 在 `--head` 生效時額外顯示 `Head: ` 一行。`pmctl gate run` 兩條路徑(foreground exec 與 `--lifecycle detached` 透過 `gate-supervisor.sh` 的 `--` passthrough)皆無需改動即可轉發 `--head`,因為都是把未知旗標原樣傳給 `pr-gate.sh`。`--help` usage block 與 unknown-arg 提示同步補上並釐清 three-dot 語意。 + +**Gate 第一輪 NO-GO(critic block-soft + qa-tester block)修復**:(1) 補 `--head` 缺 operand 的受控錯誤(原本會以 raw `unbound variable` crash,比照 `--override-file` 加 guard);(2) 補 diverged base/head 拓樸測試(`test_head_override_merge_base_semantics`:main 與 feature 各自獨立前進,驗證 three-dot 只看 head 相對 merge-base 的變更、不含 base 的獨立進展);(3) 全文(`--help`、code comment、本 resolution)統一明確標註 three-dot/merge-base 語意,避免與字面 `base..head` two-dot tree diff 混淆;(4) 修正 MILESTONES.md Phase 2 註記與已完成列矛盾的過期文字。第二輪 5 個 `--head` 測試(原 3 + 新 2)、`scripts/test-pr-gate.sh` 共 124 項全數通過;`test-gate-lifecycle.sh`/`test-pmctl-gate.sh`/`test-pr-gate-profile.sh` 無回歸。 **Priority**: P3(someday). diff --git a/CHANGELOG.md b/CHANGELOG.md index 20905af0..8a4d641d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Added -- **`pr-gate.sh --head ` (CC-425).** Gate can now diff a fixed `base..head` ref pair (branch, tag, or commit) with no PR and no working tree involved — review a branch before opening a PR, or diff `v0.6.0..v0.7.0` tag-to-tag. Rejects `--allow-dirty` (which folds in local uncommitted state) as incompatible. Forwarded transparently through both the foreground and `--lifecycle detached` routes since `pmctl gate run` already passes unrecognized flags through to `pr-gate.sh`. +- **`pr-gate.sh --head ` (CC-425).** Gate can now review a fixed head ref (branch, tag, or commit) with no PR and no working tree involved — review a branch before opening a PR, or diff `v0.6.0..v0.7.0` tag-to-tag. Uses the same merge-base (three-dot) semantics as the default `--base` path, so `base`'s independent progress after the fork point never leaks into the diff. Rejects `--allow-dirty` (which folds in local uncommitted state) as incompatible, and rejects a bare `--head` with a controlled error instead of crashing. Forwarded transparently through both the foreground and `--lifecycle detached` routes since `pmctl gate run` already passes unrecognized flags through to `pr-gate.sh`. --- diff --git a/MILESTONES.md b/MILESTONES.md index 48c02e0a..29b6ba6c 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -41,9 +41,9 @@ | CC-423 | gate detached lifecycle:`pmctl gate run --lifecycle detached`(現為預設)回傳 gate_id 立即退出;gate-supervisor 以 nohup/setsid 跑 pr-gate.sh;sentinel 機制 + `pmctl gate wait ` 輪詢,result 完整性 fail-closed,鏡像既有 `dispatch --lifecycle detached` 模式 | ✅ done pr:#353 | | CC-433 | detached lifecycle 收尾:(1) 抽出 dispatch/gate 兩份 supervisor 共用的 sentinel 啟動邏輯成共用 lib;(2) `pmctl dispatch wait`/`pmctl gate wait` 的輪詢(`sleep` 迴圈)改為主動通知(FIFO/inotify 等,解法未定案)。CC-423 交付後發現的簡化與效率改善項,解法待 `/pre-impl` 或 `/spike` 收斂 | 🟢 someday | | CC-432 | run-all-tests.sh 耗時瓶頸:`test-release-verify.sh` 12 個重複 `--no-suite` 呼叫改共用快取(`rv_no_suite_once`),380s → ~127s。方向 A(Phase 3 smoke 改隔離假 repo)與 `LIVE_DB_EXCLUSIVE` 序列化耦合窄化皆評估後擱置不追(風險高於效益,未來可重新評估) | ✅ done pr:#354 | -| CC-425 | gate 解除 PR 綁定:`pr-gate.sh --head ` 新增,diff 固定 base..head ref 對,不涉 PR/working tree;盤點發現 result 路徑 PR# key 問題已在 CC-423 detached lifecycle 重構中解決(改用 gate_id),`--base` 也已支援無 PR 場景,故實際範圍小於原評估 | ✅ done pr:#355 | +| CC-425 | gate 解除 PR 綁定:`pr-gate.sh --head ` 新增,以既有 `--base` 相同的 merge-base(three-dot)語意 diff 一組固定 ref,不涉 PR/working tree;盤點發現 result 路徑 PR# key 問題已在 CC-423 detached lifecycle 重構中解決(改用 gate_id),`--base` 也已支援無 PR 場景,故實際範圍小於原評估 | ✅ done pr:#355 | -> CC-433 排入 Phase 2 作為 CC-423 的後續收斂項,非阻塞本 Phase 其餘票的完成。CC-432 為 CC-423 pr-gate 迭代中發現並記錄的衍生票,同樣併入 Phase 2。CC-425 原評估「需重構 gate result key schema,範圍比 CC-276/423 大一截」暫不排入,2026-07-02 使用者確認排入本 Phase 處理——需重構 base 解析邏輯與 result 存放路徑(PR# key → base..head slug/run_id),實作前建議先 `/pre-impl` 確認既有 PR# key 的 gate result 讀取路徑(`pmctl gate wait`/`pmctl artifacts show` 等)向下相容策略。 +> CC-433 排入 Phase 2 作為 CC-423 的後續收斂項,非阻塞本 Phase 其餘票的完成。CC-432 為 CC-423 pr-gate 迭代中發現並記錄的衍生票,同樣併入 Phase 2。CC-425 原評估「需重構 gate result key schema,範圍比 CC-276/423 大一截」暫不排入,2026-07-02 使用者確認排入本 Phase 處理;實作前盤點發現 result key 已在 CC-423 detached lifecycle 重構中改為 gate_id-keyed,範圍縮小為僅需新增 `--head `,`/pre-impl` 的向下相容顧慮已不適用,見 pr:#355。 ### Phase 3 — CC-381 spike-only(P3;design 收斂,非完整實作) diff --git a/scripts/pr-gate.sh b/scripts/pr-gate.sh index 98207bdb..e4827ef3 100755 --- a/scripts/pr-gate.sh +++ b/scripts/pr-gate.sh @@ -62,9 +62,12 @@ _kill_process_tree() { # --scope context hint passed into the review brief # --base base branch for diff (default: origin/HEAD → main) # --head head ref for diff (default: HEAD / working tree); pass a fixed ref -# (branch, tag, commit) to diff base..head without a PR or working -# tree involved -- e.g. review a branch before opening a PR, or diff -# one tag against another (v0.6.0..v0.7.0). Incompatible with --allow-dirty. +# (branch, tag, commit) to review it without a PR or working tree +# involved -- e.g. review a branch before opening a PR, or diff one +# tag against another (v0.6.0..v0.7.0). Uses the SAME merge-base +# (three-dot) semantics as the default HEAD path: reviews what +# changed on head since it diverged from base, not a literal +# two-dot tree diff. Incompatible with --allow-dirty. # --run-dir out-of-repo dir for gate artifacts (briefs/results/trace); optional, # defaults to in-repo paths under --cd when absent (backward compat) # --output result file (default: .gate-results/gate-.md) @@ -117,7 +120,12 @@ while [[ $# -gt 0 ]]; do --targeted) REVIEWERS_OVERRIDE="$2"; shift 2;; # alias: /pr-gate skill + script comments say "targeted" --scope) SCOPE="$2"; shift 2;; --base) BASE_OVERRIDE="$2"; shift 2;; - --head) HEAD_OVERRIDE="$2"; shift 2;; + --head) + # Guard the operand explicitly: under `set -u` a bare `--head` with no + # following arg would abort with a raw "unbound variable" instead of the + # script's controlled CLI error style (mirrors --override-file below). + [[ $# -ge 2 ]] || { printf 'Error: --head requires a ref\n' >&2; exit 2; } + HEAD_OVERRIDE="$2"; shift 2;; --output) OUTPUT_OVERRIDE="$2"; shift 2;; --executor) EXECUTOR_OPTION="$2"; shift 2;; --model) DISPATCH_MODEL="$2"; shift 2;; @@ -134,7 +142,7 @@ while [[ $# -gt 0 ]]; do [[ $# -ge 2 ]] || { printf 'Error: --override-file requires a file path\n' >&2; exit 2; } OVERRIDE_FILE="$2"; shift 2;; -h|--help) - sed -n '2,84p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,87p' "$0" | sed 's/^# \{0,1\}//' exit 0;; *) printf 'Unknown arg: %s\n' "$1" >&2 @@ -514,8 +522,11 @@ fi # Use --name-status so renames expose BOTH old and new paths for sensitive matching. # Use --numstat to detect binary files (shown as -\t-\t). if [[ "$HEAD_REF" != "HEAD" ]]; then - # Fixed base..head ref pair (e.g. tag-to-tag, or a branch reviewed before a - # PR exists) -- no working tree involved, so no dirty/fallback branches apply. + # Fixed head ref (e.g. tag-to-tag, or a branch reviewed before a PR exists) + # -- no working tree involved, so no dirty/fallback branches apply. Three-dot + # (merge-base) diff, matching the default HEAD path below: reviews what + # changed on HEAD_REF since it diverged from BASE, not a literal two-dot + # tree diff -- so BASE moving forward independently does not appear here. DIFF_FILES=$(git diff "$BASE"..."$HEAD_REF" --name-status | awk ' /^R/ { print $2; print $3; next } /^[AMDCT]/ { print $2 } diff --git a/scripts/test-pr-gate.sh b/scripts/test-pr-gate.sh index c917de14..fb712907 100755 --- a/scripts/test-pr-gate.sh +++ b/scripts/test-pr-gate.sh @@ -4522,8 +4522,10 @@ run_test test_gate_run_dir_no_output_failure_leaves_no_repo_artifacts run_test test_gate_run_dir_no_verdict_failure_leaves_no_repo_artifacts run_test test_gate_run_dir_parallel_failure_leaves_no_repo_artifacts -# CC-425: --head diffs a fixed base..head ref pair with no PR or working -# tree involved (e.g. review a branch before opening a PR, or a tag-to-tag diff). +# CC-425: --head reviews a fixed ref with no PR or working tree involved +# (e.g. review a branch before opening a PR, or a tag-to-tag diff). Happy-path +# only -- see test_head_override_merge_base_semantics below for the two-dot +# vs three-dot distinction on a diverged base/head topology. test_head_override_diffs_fixed_ref() { local name="head-override-diffs-fixed-ref" should_run "$name" || return 0 @@ -4601,8 +4603,74 @@ test_head_override_rejects_allow_dirty() { pass "$name" } +# --head uses the SAME merge-base (three-dot) semantics as the default HEAD +# path, not a literal two-dot tree diff. Build a topology where base and head +# diverge independently (main gains a commit feature never sees, feature gains +# a commit main never sees) so two-dot vs three-dot produce different file +# sets: three-dot reports only feature's own change (app.go); two-dot would +# additionally report main-only.txt as removed (main's independent progress +# leaking into the diff). +test_head_override_merge_base_semantics() { + local name="head-override-merge-base-semantics" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" brief="$dir/brief.md" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo_with_branch "$repo" standard + ( + cd "$repo" + git checkout -q main + printf 'main-only progress\n' > main-only.txt + git add main-only.txt + git commit -q -m "main-only progress" + ) + + set +e + CODEX_GATE_CAPTURE_BRIEF="$brief" \ + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --head feature + local code=$? + set -e + if [[ "$code" -ne 0 ]]; then + fail "$name" "exit $code, expected 0" + return + fi + assert_file_contains "$name" "$brief" "app.go" || return + assert_not_contains "$name" "$brief" "main-only.txt" || return + pass "$name" +} + +test_head_override_missing_operand() { + local name="head-override-missing-operand" + should_run "$name" || return 0 + local dir="$TMP_ROOT/$name" + local home="$dir/home" repo="$dir/repo" runner="$dir/runner" + local out="$dir/out" err="$dir/err" + mkdir -p "$dir" + create_runner "$runner" + create_agents "$home" critic qa-tester architecture-reviewer security-reviewer risk-reviewer + create_repo "$repo" docs + + set +e + run_gate "$home" "$runner" "$repo" "$out" "$err" --base main --head + local code=$? + set -e + if [[ "$code" -ne 2 ]]; then + fail "$name" "exit $code, expected 2 (controlled usage error)" + return + fi + assert_file_contains "$name" "$err" "Error: --head requires a ref" || return + assert_not_contains "$name" "$err" "unbound variable" || return + assert_not_contains "$name" "$out" "DISPATCH_STUB" || return + pass "$name" +} + run_test test_head_override_diffs_fixed_ref run_test test_head_override_invalid_ref run_test test_head_override_rejects_allow_dirty +run_test test_head_override_merge_base_semantics +run_test test_head_override_missing_operand th_summary From 8899ddd507b86feb1909cd3027ab39449f9a437b Mon Sep 17 00:00:00 2001 From: screenleon Date: Thu, 2 Jul 2026 10:03:46 +0900 Subject: [PATCH 4/4] fix(CC-425): add structured Steps docstrings to --head tests qa-tester block (round 2 NO-GO): the five new --head test functions used prose comments but skipped this file's existing docstring convention (one-line behavior statement + numbered "# Steps:" list, e.g. test_artifact_filter_drops_gate_artifacts). No behavioral change -- critic and architecture-reviewer already approved this round. scripts/test-pr-gate.sh: 124 passed, 0 failed (unchanged count). Co-Authored-By: Claude Sonnet 5 --- scripts/test-pr-gate.sh | 47 +++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/scripts/test-pr-gate.sh b/scripts/test-pr-gate.sh index fb712907..a70eb81d 100755 --- a/scripts/test-pr-gate.sh +++ b/scripts/test-pr-gate.sh @@ -4527,6 +4527,14 @@ run_test test_gate_run_dir_parallel_failure_leaves_no_repo_artifacts # only -- see test_head_override_merge_base_semantics below for the two-dot # vs three-dot distinction on a diverged base/head topology. test_head_override_diffs_fixed_ref() { + # --head reviews a fixed ref pair without requiring that ref to be + # checked out -- proves the flag diffs base..head_ref directly rather than + # relying on the working tree's current branch. + # Steps: + # 1. Build a repo with main + a feature branch carrying a committed change. + # 2. Check out main (NOT feature) so the working tree is not on the reviewed ref. + # 3. Run the gate with --base main --head feature. + # 4. Assert exit 0, the brief records "Head: feature", and the feature-only file is in scope. local name="head-override-diffs-fixed-ref" should_run "$name" || return 0 local dir="$TMP_ROOT/$name" @@ -4555,6 +4563,13 @@ test_head_override_diffs_fixed_ref() { } test_head_override_invalid_ref() { + # An unresolvable --head ref must fail loud with a controlled error before + # any dispatch happens, mirroring the existing --base validation. + # Steps: + # 1. Build a plain repo (no feature branch needed -- the ref never resolves). + # 2. Run the gate with --head pointing at a nonexistent ref name. + # 3. Assert non-zero exit and the "head ref not found" error on stderr. + # 4. Assert no dispatch stub output landed on stdout (gate aborted pre-dispatch). local name="head-override-invalid-ref" should_run "$name" || return 0 local dir="$TMP_ROOT/$name" @@ -4579,6 +4594,14 @@ test_head_override_invalid_ref() { } test_head_override_rejects_allow_dirty() { + # --head diffs a fixed ref pair with no working tree involved, so combining + # it with --allow-dirty (which exists to fold working-tree state into scope) + # is a contradictory input and must be rejected, not silently ignored. + # Steps: + # 1. Build a repo with main + a feature branch carrying a committed change. + # 2. Check out main and run the gate with --head feature --allow-dirty together. + # 3. Assert non-zero exit and the "incompatible" error on stderr. + # 4. Assert no dispatch stub output landed on stdout. local name="head-override-rejects-allow-dirty" should_run "$name" || return 0 local dir="$TMP_ROOT/$name" @@ -4603,14 +4626,16 @@ test_head_override_rejects_allow_dirty() { pass "$name" } -# --head uses the SAME merge-base (three-dot) semantics as the default HEAD -# path, not a literal two-dot tree diff. Build a topology where base and head -# diverge independently (main gains a commit feature never sees, feature gains -# a commit main never sees) so two-dot vs three-dot produce different file -# sets: three-dot reports only feature's own change (app.go); two-dot would -# additionally report main-only.txt as removed (main's independent progress -# leaking into the diff). test_head_override_merge_base_semantics() { + # --head uses the SAME merge-base (three-dot) semantics as the default HEAD + # path, not a literal two-dot tree diff -- base's own independent progress + # after the fork point must not leak into the reviewed diff. + # Steps: + # 1. Build a repo with main + a feature branch carrying a committed change (app.go). + # 2. Check out main and commit an independent main-only file the feature branch never sees. + # 3. Run the gate with --base main --head feature (base and head now diverged both ways). + # 4. Assert exit 0, app.go is in scope, and main-only.txt is NOT in scope -- + # a two-dot diff would additionally report main-only.txt as removed. local name="head-override-merge-base-semantics" should_run "$name" || return 0 local dir="$TMP_ROOT/$name" @@ -4643,6 +4668,14 @@ test_head_override_merge_base_semantics() { } test_head_override_missing_operand() { + # A bare --head with no following operand must fail with a controlled CLI + # error, not a raw `unbound variable` crash under set -u. + # Steps: + # 1. Build a plain repo. + # 2. Run the gate with --base main --head as the last argument (no operand). + # 3. Assert exit 2 (usage error) and the controlled "--head requires a ref" message. + # 4. Assert stderr does NOT contain "unbound variable" (the raw crash this guards against). + # 5. Assert no dispatch stub output landed on stdout. local name="head-override-missing-operand" should_run "$name" || return 0 local dir="$TMP_ROOT/$name"