From 3a77d4481097e8918d7b615c62bc3d23aea3241a Mon Sep 17 00:00:00 2001 From: che cheng Date: Wed, 3 Jun 2026 09:59:46 +0800 Subject: [PATCH] test(ensemble-code-review): extract diff recipe to bin/pai-build-diff + bats + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 3 輪 self-dogfood 硬化的 diff 模式 inline recipe 抽成 version-pinned shipped script,改成「單一真相源 + 自動化測試」取代人工 re-audit。 新增: - bin/pai-build-diff — diff 模式建構器(5 模式 + 退出碼契約 0/3/1)。 封裝 ref/N 驗證、untracked symlink/FIFO/換行檔名安全、empty-tree base、 git/gh exit-code 檢查。SKILL.md 改成呼叫它(${CLAUDE_PLUGIN_ROOT}/bin/...)。 - test/pai-build-diff.bats — 25 個 regression(含 3 輪抓到的全部 bug)。 - test/run.sh + test/README.md — 本地一鍵 shellcheck + bats。 - .github/workflows/test.yml — CI 每次 push/PR 自動跑。 修正(extraction dogfood 抓到): - 未知 mode 訊息 $MODE 緊貼全形( 被 set -u 誤判 unbound variable → 改 ${MODE}。已加 regression。 bump 2.13.2 → 2.14.0(plugin.json + marketplace.json)。 --- .claude-plugin/marketplace.json | 2 +- .github/workflows/test.yml | 29 +++ .../.claude-plugin/plugin.json | 2 +- plugins/parallel-ai-agents/CHANGELOG.md | 10 + plugins/parallel-ai-agents/bin/pai-build-diff | 117 ++++++++++ .../skills/ensemble-code-review/SKILL.md | 51 ++--- plugins/parallel-ai-agents/test/README.md | 31 +++ .../test/pai-build-diff.bats | 202 ++++++++++++++++++ plugins/parallel-ai-agents/test/run.sh | 13 ++ 9 files changed, 420 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100755 plugins/parallel-ai-agents/bin/pai-build-diff create mode 100644 plugins/parallel-ai-agents/test/README.md create mode 100644 plugins/parallel-ai-agents/test/pai-build-diff.bats create mode 100755 plugins/parallel-ai-agents/test/run.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2bfe984..974664a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "parallel-ai-agents", "source": "./plugins/parallel-ai-agents", "description": "平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.13.2", + "version": "2.14.0", "author": { "name": "Che Cheng" }, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b6e6644 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: test + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + shellcheck-bats: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/parallel-ai-agents + steps: + - uses: actions/checkout@v4 + + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + # shellcheck 已預裝在 ubuntu-latest runner + + - name: shellcheck + run: shellcheck bin/pai-build-diff + + - name: bats + run: bats test/ diff --git a/plugins/parallel-ai-agents/.claude-plugin/plugin.json b/plugins/parallel-ai-agents/.claude-plugin/plugin.json index f45e9d0..9976cea 100644 --- a/plugins/parallel-ai-agents/.claude-plugin/plugin.json +++ b/plugins/parallel-ai-agents/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "parallel-ai-agents", "description": "平行派發任務給多個 AI agent(Claude + Codex),獨立執行後交叉比對結果。Codex 改走直接 HTTP wrapper(bin/codex-call,Swift script)取代 codex exec subprocess,解決 hang 問題且避開 Python 版本飄移", - "version": "2.13.2", + "version": "2.14.0", "author": { "name": "Che Cheng" } diff --git a/plugins/parallel-ai-agents/CHANGELOG.md b/plugins/parallel-ai-agents/CHANGELOG.md index ad13e88..3339cfd 100644 --- a/plugins/parallel-ai-agents/CHANGELOG.md +++ b/plugins/parallel-ai-agents/CHANGELOG.md @@ -11,6 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.14.0] - 2026-06-03 + +### Added +- **`bin/pai-build-diff` —— diff 模式建構器抽成 version-pinned shipped script**(單一真相源)。原本 inline 在 `ensemble-code-review` SKILL.md 的 bash recipe(經 3 輪 self-dogfood 硬化)抽成獨立可執行檔,`SKILL.md` 改成呼叫它(`bash "${CLAUDE_PLUGIN_ROOT}/bin/pai-build-diff" "$MODE" ...`)。好處:消除「LLM 改寫 inline recipe 時 typo」的風險、可 shellcheck、可 bats 覆蓋。退出碼契約:`0` 有 diff/`3` 無變更(良性)/`1` 錯誤。 +- **`test/pai-build-diff.bats` —— 25 個 bats regression 測試**:把 3 輪人工 re-audit 抓到的 bug 全部固化(5 種模式、退出碼、ref/N 驗證防 injection/dashed-ref/0/leading-zero/位數溢位、untracked symlink no-follow、FIFO no-hang、換行檔名 C-quote、empty-tree base)。取代「每次改 diff 邏輯都人工重審」。附 `test/run.sh`(shellcheck + bats 一鍵)+ `test/README.md`。 +- **`.github/workflows/test.yml` —— CI**:每次 push / PR 自動跑 shellcheck + bats。 + +### Fixed +- **`bin/pai-build-diff` 未知 mode 的多位元組 bug**(extraction 過程 dogfood 抓到):`未知 MODE: $MODE(限...`—— `$MODE` 緊貼全形 `(`,bash 把 `(` 的首位元組吞進變數名 → `set -u` 誤報 `unbound variable`。改 `${MODE}` 明確界定。已加 regression 測試。 + ## [2.13.2] - 2026-06-02 ### Fixed diff --git a/plugins/parallel-ai-agents/bin/pai-build-diff b/plugins/parallel-ai-agents/bin/pai-build-diff new file mode 100755 index 0000000..beeeb91 --- /dev/null +++ b/plugins/parallel-ai-agents/bin/pai-build-diff @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# pai-build-diff — ensemble-code-review 的 diff 模式建構器(單一真相源) +# +# 把 git/gh 的變更取成 unified diff 寫到 stdout,給 ensemble-code-review skill +# 重導到自己的 DIFF_FILE(async reviewer agents 讀的那個檔)。 +# +# 用法: +# pai-build-diff [-C ] --diff +# pai-build-diff [-C ] --base # 本分支相對 的 merge-base(三點) +# pai-build-diff [-C ] --since # ..HEAD(兩點) +# pai-build-diff [-C ] --commits # 最近 N 個 commit +# pai-build-diff [-C ] --pr # gh pr diff +# +# 退出碼(給測試與 skill 區分): +# 0 diff 已寫 stdout(有變更) +# 1 錯誤(非 repo / ref 非法 / N 非法 / git|gh 失敗 / 用法錯) +# 3 無變更可審(git 成功但 diff 為空 —— 良性,非錯誤) +# +# 設計重點(3 輪 self-dogfood 硬化的成果,全部住這): +# * ref/N 一律先驗證再用 —— 防 command injection + dashed-ref 被當 option。 +# * 所有 git/gh 檢查 exit code —— 不可把 git 報錯的空輸出當「無變更、乾淨通過」(假綠燈)。 +# * --diff 含 untracked 新檔,但 symlink/FIFO/dir 只列路徑不讀內容 +# (避免 follow-symlink 洩漏 / FIFO read 卡死),一般新檔走 git diff(自動 C-quote 檔名, +# 防含換行檔名偽造假 diff 行)+ head -c 64KB 上限。 +# * --commits N≥commit 總數 → 對 empty tree 取 diff(不 clamp 成 HEAD~TOT 越界 fatal)。 +# * 內部 buffer 用 trap EXIT 清理是安全的(同步、無 async reader);skill 的 DIFF_FILE 才需手動 Phase 4.5 清。 + +set -uo pipefail + +usage() { + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' +} + +err() { printf '%s\n' "$*" >&2; } + +# ── 解析 -C (可選,給測試指向 fixture repo;skill 不帶則用 cwd) ── +repo_arg="." +if [ "${1:-}" = "-C" ]; then + [ $# -ge 2 ] || { err "-C 需要一個目錄參數"; exit 1; } + repo_arg="$2"; shift 2 +fi + +MODE="${1:-}" +ARG="${2:-}" + +case "$MODE" in + -h|--help) usage; exit 0 ;; + --diff|--base|--since|--commits|--pr) : ;; + "") err "缺 MODE。用法見 --help"; exit 1 ;; + *) err "未知 MODE: ${MODE}(限 --diff/--base/--since/--commits/--pr)"; exit 1 ;; +esac + +# ── 解析 repo root(杜絕「不在 repo 內卻靜默產生空 diff」) ── +REPO="$(git -C "$repo_arg" rev-parse --show-toplevel 2>/dev/null)" \ + || { err "不在 git repo 內(-C='$repo_arg')→ 改路徑模式或 cd 進 repo"; exit 1; } + +GIT() { git -C "$REPO" --no-pager "$@"; } # --no-pager + 下方 --no-color:杜絕 ANSI 污染輸出 + +# ── 內部 buffer:synchronous,trap EXIT 清理安全(與 skill 的 async DIFF_FILE 無關) ── +WORK="$(mktemp -t pai-build-diff.XXXXXX)" || { err "mktemp 失敗"; exit 1; } +trap 'rm -f "$WORK"' EXIT + +# ── 驗證器(防 injection + dashed-ref):ref 必解析成 commit;N 必正整數(排 0/leading-zero,≤9 位防算術溢位) ── +validate_ref() { GIT rev-parse --verify --quiet "$1^{commit}" >/dev/null || { err "ref 非法: $1"; exit 1; }; } +validate_int() { + # 明確 if(非 A && B || C)—— 排 0/leading-zero(^[1-9][0-9]*$)+ ≤9 位(防 (( ARG >= TOT )) 算術溢位) + if [[ "$1" =~ ^[1-9][0-9]*$ ]] && (( ${#1} <= 9 )); then return 0; fi + err "需正整數(1..9 位): $1"; exit 1 +} + +# ── untracked/新檔 append:只處理一般檔;symlink/FIFO/dir 只列路徑不讀內容 ── +# (避免 follow-symlink 量錯/洩漏目標、避免 FIFO read 卡死、避免洩 symlink 目標路徑)。 +# 內容走 git diff(git 自動 C-quote 檔名,防含換行檔名偽造假 diff 行),head -c 截 64KB 上限。 +append_new() { + local f="$1" + if [ -L "$REPO/$f" ] || [ ! -f "$REPO/$f" ]; then + printf 'new file(symlink/特殊檔,僅列路徑): %q\n' "$f" >>"$WORK" + return + fi + GIT diff --no-color --no-index -- /dev/null "$f" 2>/dev/null | head -c 65536 >>"$WORK" || true +} + +# ── 取 diff(每條檢查 exit code;只用驗證過的值,不裸內插) ── +case "$MODE" in + --diff) + GIT diff --no-color HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } + GIT ls-files --others --exclude-standard -z \ + | while IFS= read -r -d '' f; do append_new "$f"; done ;; + --base) + [ -n "$ARG" ] || { err "--base 需要 "; exit 1; } + validate_ref "$ARG" + GIT diff --no-color "$ARG"...HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;; # 三點=相對 merge-base + --since) + [ -n "$ARG" ] || { err "--since 需要 "; exit 1; } + validate_ref "$ARG" + GIT diff --no-color "$ARG"..HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;; + --commits) + validate_int "$ARG" + TOT="$(GIT rev-list --count HEAD)" || { err "repo 無 commit"; exit 1; } + if (( ARG >= TOT )); then + BASE="$(GIT hash-object -t tree /dev/null)" # N≥全部 → 對 empty tree(含 root 的全部變更;不會 HEAD~TOT 越界 fatal) + else + BASE="HEAD~$ARG" + fi + GIT diff --no-color "$BASE" HEAD >"$WORK" || { err "git diff 失敗"; exit 1; } ;; + --pr) + validate_int "$ARG" + ( cd "$REPO" && gh pr diff --color never "$ARG" ) >"$WORK" || { err "gh pr diff 失敗"; exit 1; } ;; +esac + +# ── 空判定:上面已用 exit code 擋掉 git 報錯 → 這裡的空 = 真「無變更」(非 git fatal 被誤判) ── +if [ ! -s "$WORK" ]; then + err "無變更可審" + exit 3 +fi + +cat "$WORK" diff --git a/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md b/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md index d576ea5..f3d495e 100644 --- a/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md +++ b/plugins/parallel-ai-agents/skills/ensemble-code-review/SKILL.md @@ -88,44 +88,25 @@ allowed-tools: **diff 模式**:依 Phase 0 的來源取 diff,**寫到 temp 檔**(大 diff 不塞 inline args —— Workflow 會把 args JSON-stringify;reviewer 用 file-read tool 讀,避開 escape 地獄 + prompt 膨脹): ```bash -# 0) 把 Phase 0 的判定 lower 成變數(別只留 prose;/ 必帶值,缺值→報錯): -# --diff → MODE=--diff -# --base / --since → MODE=--base|--since、REF= -# --commits / --pr → MODE=--commits|--pr、N= -REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "不在 git repo 內 → 改路徑模式或 cd 進 repo"; exit 1; } -GIT() { git -C "$REPO" --no-pager "$@"; } # --no-pager + --no-color:杜絕 ANSI 污染 DIFF_FILE -DIFF_FILE="$(mktemp -t pai-codereview-diff.XXXXXX)" # ⚠ ensemble 跑完後要 rm(含 changed-line 祕密,見鐵律) - -# ref/N 驗證(防 injection + dashed-ref):ref 必解析成 commit;N 必正整數(排 0/leading-zero,且 ≤9 位防算術溢位) -validate_ref() { GIT rev-parse --verify --quiet "$1^{commit}" >/dev/null || { echo "ref 非法: $1"; exit 1; }; } -validate_int() { [[ "$1" =~ ^[1-9][0-9]*$ ]] && (( ${#1} <= 9 )) || { echo "需正整數(1..9 位): $1"; exit 1; }; } -# untracked/新檔 append:只處理一般檔 —— symlink/FIFO/dir 只列路徑不讀內容(避免 wc-c follow-symlink 量錯、避免 FIFO -# 把 read 卡死、避免洩 symlink 目標路徑)。內容走 git diff(git 自動 C-quote 檔名,防含換行檔名偽造假 diff 行), -# 再 head -c 截 64KB 上限避免單檔撐爆 prompt(用 %q 寫 path-only 檔名,中和控制字元)。 -append_new() { local f="$1" - if [ -L "$REPO/$f" ] || [ ! -f "$REPO/$f" ]; then printf 'new file(symlink/特殊檔,僅列路徑): %q\n' "$f" >>"$DIFF_FILE"; return; fi - GIT diff --no-color --no-index -- /dev/null "$f" 2>/dev/null | head -c 65536 >>"$DIFF_FILE" || true; } - -# 取 diff(每條檢查 exit code;只用驗證過的值,不裸內插) -case "$MODE" in - --diff) - GIT diff --no-color HEAD >"$DIFF_FILE" || { echo "git diff 失敗"; exit 1; } - GIT ls-files --others --exclude-standard -z | while IFS= read -r -d '' f; do append_new "$f"; done ;; - --base) validate_ref "$REF"; GIT diff --no-color "$REF"...HEAD >"$DIFF_FILE" || exit 1 ;; # 三點=相對 merge-base - --since) validate_ref "$REF"; GIT diff --no-color "$REF"..HEAD >"$DIFF_FILE" || exit 1 ;; - --commits) - validate_int "$N" - TOT="$(GIT rev-list --count HEAD)" || { echo "repo 無 commit"; exit 1; } - if (( N >= TOT )); then BASE="$(GIT hash-object -t tree /dev/null)" # N≥全部 → 對 empty tree(含 root 的全部變更;不會 HEAD~TOT 越界 fatal) - else BASE="HEAD~$N"; fi - GIT diff --no-color "$BASE" HEAD >"$DIFF_FILE" || exit 1 ;; - --pr) validate_int "$N"; gh pr diff --color never "$N" >"$DIFF_FILE" || { echo "gh pr diff 失敗"; exit 1; } ;; +# Phase 0 已把判定 lower 成變數(缺值→pai-build-diff 自己報錯): +# --diff → MODE=--diff(無 ARG) +# --base|--since → MODE=--base|--since、ARG= +# --commits|--pr → MODE=--commits|--pr、ARG= +ARG="${ARG:-}" +DIFF_FILE="$(mktemp -t pai-codereview-diff.XXXXXX)" # ⚠ skill 自管:async reviewer 讀這個檔;Phase 4.5 才 rm(含 changed-line 祕密) + +# diff 建構統一交給 version-pinned 的 bin/pai-build-diff —— 3 輪硬化 + bats 覆蓋的「單一真相源」。 +# 絕對路徑不賭 PATH(同 codex-call 理由)。退出碼:0=有 diff/3=無變更(良性)/其它=錯誤。 +bash "${CLAUDE_PLUGIN_ROOT}/bin/pai-build-diff" "$MODE" ${ARG:+"$ARG"} >"$DIFF_FILE"; rc=$? +case "$rc" in + 0) : ;; # 有變更 → 往下派 ensemble + 3) rm -f "$DIFF_FILE"; echo "無變更可審 → 停(不空跑 ensemble)"; exit 0 ;; + *) rm -f "$DIFF_FILE"; echo "diff 建構失敗(見上方 stderr)→ 停"; exit 1 ;; esac - -# 空判定:上面已用 exit code 擋掉 git 報錯 → 這裡的空 = 真「無變更」(非 git fatal 被誤判) -[ -s "$DIFF_FILE" ] || { echo "無變更可審"; exit 1; } ``` +> `pai-build-diff` 封裝了全部硬化:ref/N 驗證(防 command injection + dashed-ref)、untracked 新檔的 symlink/FIFO/換行檔名安全處理、`--commits N≥TOT` 用 empty-tree base、所有 git/gh exit-code 檢查、空判定。**要改 diff 邏輯就改 `bin/pai-build-diff` 並更新 `test/pai-build-diff.bats`,不要在這裡重寫 inline**(單一真相源)。契約細節見該 script 與其 25 個 bats 測試。 + > **PR mode 的 context 陷阱**:`gh pr diff` 取的是**遠端 PR** 的 diff,但 reviewer 用 Read 讀到的「周邊原始碼」是**本地工作樹**(可能停在別的分支)。所以 `--pr` 模式下,contextBlock 要明示「周邊以 diff 內 hunk context 為準,本地 Read 未必對應 PR 的版本」;要精確就先 `gh pr checkout `(會動工作樹,多數情況不值得)。 自動判斷審閱重點(兩模式共用): diff --git a/plugins/parallel-ai-agents/test/README.md b/plugins/parallel-ai-agents/test/README.md new file mode 100644 index 0000000..e1c9194 --- /dev/null +++ b/plugins/parallel-ai-agents/test/README.md @@ -0,0 +1,31 @@ +# test/ + +`ensemble-code-review` diff 模式的自動化測試。把過去人工 re-audit(3 輪 self-dogfood)抓到的 bug 全部固化成 regression,取代「每次改 diff 邏輯都要重新人工審」。 + +## 測什麼 + +| 檔案 | 對象 | +|------|------| +| `pai-build-diff.bats` | `../bin/pai-build-diff`(diff 模式建構器,3 輪硬化的單一真相源)| + +涵蓋:5 種模式(`--diff`/`--base`/`--since`/`--commits`/`--pr`)、退出碼契約(0 有 diff/3 無變更/1 錯誤)、ref/N 驗證(injection、dashed-ref、0/leading-zero、位數溢位)、untracked 安全(symlink no-follow、FIFO no-hang、換行檔名 C-quote)、empty-tree base、未知 mode 的多位元組 regression。 + +## 跑法 + +```bash +# 前置(一次性) +brew install bats-core shellcheck + +# 一鍵:shellcheck + bats +./test/run.sh + +# 或分開 +shellcheck bin/pai-build-diff +bats test/ +``` + +CI(`.github/workflows/test.yml`)在每次 push / PR 自動跑同一組。 + +## 加測試的原則 + +改 `bin/pai-build-diff` 的行為 → **先在 `pai-build-diff.bats` 加一個會 fail 的 case(RED),再改 script 讓它 pass(GREEN)**。不要在 `SKILL.md` inline 重寫 diff 邏輯 —— script 是單一真相源,SKILL.md 只呼叫它。 diff --git a/plugins/parallel-ai-agents/test/pai-build-diff.bats b/plugins/parallel-ai-agents/test/pai-build-diff.bats new file mode 100644 index 0000000..3db909e --- /dev/null +++ b/plugins/parallel-ai-agents/test/pai-build-diff.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# pai-build-diff 的 bats 測試 —— 把 ensemble-code-review diff 模式 3 輪人工 re-audit +# 抓到的 bug 全部固化成 regression。 +# +# 跑法:bats test/pai-build-diff.bats (需 brew install bats-core) +# 只用 bats-core 內建(run/$status/$output),不依賴 bats-assert/bats-support。 +# +# --pr 的 happy-path 需要 live gh + 網路 + 真 PR,不在單元測試範圍;此處只測 +# --pr 的「N 驗證在 gh 之前」(injection / 非正整數被擋)這個會出 bug 的部分。 + +setup() { + BIN="${BATS_TEST_DIRNAME}/../bin/pai-build-diff" + REPO="${BATS_TEST_TMPDIR}/repo" + git init -q "$REPO" + git -C "$REPO" config user.email t@example.com + git -C "$REPO" config user.name tester + git -C "$REPO" config commit.gpgsign false + printf 'v1\n' > "$REPO/a.txt"; git -C "$REPO" add a.txt; git -C "$REPO" commit -qm c1 + printf 'v2\n' > "$REPO/a.txt"; git -C "$REPO" add a.txt; git -C "$REPO" commit -qm c2 + # 此時 REPO:2 commits、工作樹乾淨 +} + +# 小工具:把 tracked 檔改成有 uncommitted 變更 +dirty() { printf 'wip\n' >> "$REPO/a.txt"; } + +# ── 退出碼契約 ────────────────────────────────────────────── + +@test "--diff 乾淨工作樹 → exit 3(無變更可審,非錯誤)" { + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 3 ] + [[ "$output" == *"無變更可審"* ]] +} + +@test "--diff 有 uncommitted 變更 → exit 0 且含該檔 diff" { + dirty + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 0 ] + [[ "$output" == *"diff --git a/a.txt b/a.txt"* ]] + [[ "$output" == *"+wip"* ]] +} + +@test "非 git repo → exit 1" { + mkdir "$BATS_TEST_TMPDIR/plain" + run "$BIN" -C "$BATS_TEST_TMPDIR/plain" --diff + [ "$status" -eq 1 ] + [[ "$output" == *"不在 git repo 內"* ]] +} + +# ── --diff 含 untracked 新檔 ──────────────────────────────── + +@test "--diff 含 untracked 一般新檔(不靜默漏審)" { + printf 'brand new\n' > "$REPO/b.txt" + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 0 ] + [[ "$output" == *"b.txt"* ]] + [[ "$output" == *"+brand new"* ]] +} + +@test "untracked symlink → 只列路徑、不 follow(不洩漏目標內容)" { + printf 'TOP_SECRET_TOKEN_42\n' > "$BATS_TEST_TMPDIR/secret" + ln -s "$BATS_TEST_TMPDIR/secret" "$REPO/link" + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 0 ] + [[ "$output" == *"僅列路徑"* ]] + # 關鍵:symlink 目標內容絕不可出現在 diff + [[ "$output" != *"TOP_SECRET_TOKEN_42"* ]] +} + +@test "untracked FIFO → 不卡死、正常結束" { + mkfifo "$REPO/pipe" + printf 'real change\n' > "$REPO/c.txt" # 確保有東西、exit 0 + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 0 ] + # 跑到這裡就代表沒卡在 FIFO read +} + +@test "untracked 含換行檔名 → git C-quote、不偽造假 diff 行" { + printf 'payload\n' > "$REPO/$(printf 'wn\nfake.txt')" + run "$BIN" -C "$REPO" --diff + [ "$status" -eq 0 ] + # git 把換行 C-quote 成字面 \n(反斜線+n),不是真換行 + [[ "$output" == *'wn\nfake.txt'* ]] +} + +# ── --base / --since(ref 模式) ──────────────────────────── + +@test "--base 合法 → exit 0" { + run "$BIN" -C "$REPO" --base HEAD~1 + [ "$status" -eq 0 ] + [[ "$output" == *"diff --git"* ]] +} + +@test "--since 合法 → exit 0" { + run "$BIN" -C "$REPO" --since HEAD~1 + [ "$status" -eq 0 ] + [[ "$output" == *"diff --git"* ]] +} + +@test "--base 缺 ref → exit 1" { + run "$BIN" -C "$REPO" --base + [ "$status" -eq 1 ] + [[ "$output" == *"需要 "* ]] +} + +@test "--base 壞 ref → exit 1(ref 非法)" { + run "$BIN" -C "$REPO" --base no-such-ref-xyz + [ "$status" -eq 1 ] + [[ "$output" == *"ref 非法"* ]] +} + +@test "--base injection 'x; rm -rf /' → 被擋(exit 1,永不進 shell)" { + run "$BIN" -C "$REPO" --base 'x; rm -rf /' + [ "$status" -eq 1 ] + [[ "$output" == *"ref 非法"* ]] +} + +@test "--base dashed-ref '--config' → 被擋(exit 1,不被當 git option)" { + run "$BIN" -C "$REPO" --base --config + [ "$status" -eq 1 ] + [[ "$output" == *"ref 非法"* ]] +} + +# ── --commits(N 模式 + empty-tree base + 驗證) ───────────── + +@test "--commits 1 → exit 0" { + run "$BIN" -C "$REPO" --commits 1 + [ "$status" -eq 0 ] + [[ "$output" == *"diff --git"* ]] +} + +@test "--commits N=TOT → empty-tree base(含 root 全部變更,不越界 fatal)" { + run "$BIN" -C "$REPO" --commits 2 # repo 剛好 2 commits + [ "$status" -eq 0 ] + # empty tree 為 base → 連最初的 a.txt 內容都算新增 + [[ "$output" == *"new file"* ]] || [[ "$output" == *"--- /dev/null"* ]] +} + +@test "--commits N>TOT → empty-tree base(不 clamp 成 HEAD~TOT fatal)" { + run "$BIN" -C "$REPO" --commits 99 + [ "$status" -eq 0 ] + [[ "$output" == *"diff --git"* ]] +} + +@test "--commits 0 → exit 1(排除 0)" { + run "$BIN" -C "$REPO" --commits 0 + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +@test "--commits 01 → exit 1(排除 leading-zero)" { + run "$BIN" -C "$REPO" --commits 01 + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +@test "--commits 12345678901(11 位)→ exit 1(防算術溢位)" { + run "$BIN" -C "$REPO" --commits 12345678901 + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +@test "--commits abc → exit 1(非數字)" { + run "$BIN" -C "$REPO" --commits abc + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +# ── --pr:只測「N 驗證在 gh 之前」(injection / 非正整數被擋) ─ + +@test "--pr injection → 被擋(驗證先於 gh)" { + run "$BIN" -C "$REPO" --pr 'x; rm -rf /' + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +@test "--pr 0 → exit 1" { + run "$BIN" -C "$REPO" --pr 0 + [ "$status" -eq 1 ] + [[ "$output" == *"需正整數"* ]] +} + +# ── 用法 / 未知 mode ──────────────────────────────────────── + +@test "未知 mode --bogus → exit 1 且印『未知 MODE』(多位元組 regression)" { + run "$BIN" -C "$REPO" --bogus + [ "$status" -eq 1 ] + [[ "$output" == *"未知 MODE"* ]] + # regression:$MODE 緊貼全形『(』曾被 set -u 誤判 unbound variable + [[ "$output" != *"unbound variable"* ]] +} + +@test "無 mode → exit 1(缺 MODE)" { + run "$BIN" -C "$REPO" + [ "$status" -eq 1 ] + [[ "$output" == *"缺 MODE"* ]] +} + +@test "--help → exit 0 且含用法" { + run "$BIN" --help + [ "$status" -eq 0 ] + [[ "$output" == *"用法"* ]] +} diff --git a/plugins/parallel-ai-agents/test/run.sh b/plugins/parallel-ai-agents/test/run.sh new file mode 100755 index 0000000..6ee7c1a --- /dev/null +++ b/plugins/parallel-ai-agents/test/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# 本地一鍵跑 lint + 測試。 +# 前置:brew install bats-core shellcheck +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "── shellcheck bin/pai-build-diff ──" +shellcheck bin/pai-build-diff + +echo "── bats test/ ──" +bats test/ + +echo "✓ 全部通過"