From 982d2dcbbf1653baa74178cea3cdabd3013ffc0b Mon Sep 17 00:00:00 2001 From: Jonathan Zhang Date: Sun, 9 Aug 2026 11:33:43 -1000 Subject: [PATCH] ci(automerge): hold the arm behind a quiet period + unaddressed-findings check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wxa_vpn#1392: four PRs merged 3m18s-16m17s after a review finding posted, with no commit in response — nothing holds the door between the last review and the merge. The merge is server-side once required checks go green, so the consultation moves to ARM time: wait findings_quiet_minutes (default 20) of review silence, then run the unaddressed-findings detector (vendored twin of dotclaude's bb-unaddressed-findings.sh, fetched from this repo's default branch like classify.mjs); findings decline the arm, disarm any stale arm, and leave a sticky comment. Applies to every arm path, bypass labels included (wxa_vpn#1404 merged 56s after a false Codex P1 — an approval says "I read what was there", not "nothing further may be said"). Never a required check; the manual merge button stays untouched, so a wrong finding costs one click, never a wedge. Detector precision measured 90.9% on a fresh 25-PR corpus (#1369-#1401); 40% of merges carried a real unaddressed finding. Co-Authored-By: Claude Fable 5 --- .github/scripts/unaddressed-findings.sh | 328 ++++++++++++++++++ .github/workflows/claude-author-automerge.yml | 215 +++++++++++- selftest/test_automerge_findings_gate.sh | 326 +++++++++++++++++ 3 files changed, 868 insertions(+), 1 deletion(-) create mode 100755 .github/scripts/unaddressed-findings.sh create mode 100644 selftest/test_automerge_findings_gate.sh diff --git a/.github/scripts/unaddressed-findings.sh b/.github/scripts/unaddressed-findings.sh new file mode 100755 index 0000000..d74fe26 --- /dev/null +++ b/.github/scripts/unaddressed-findings.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# VENDORED TWIN — the canonical operator-CLI copy lives in topcoder1/dotclaude +# at templates/ci-workflows/scripts/bb-unaddressed-findings.sh. THIS copy is +# what claude-author-automerge.yml's quiet+findings gate fetches at runtime +# from this repo's default branch (the same fetch pattern as +# .github/scripts/classify.mjs). Keep the two byte-identical below this header +# block when either changes: the operator sweep and the automerge gate must +# flag the same findings, or a PR the sweep flags will have already armed. +# +# bb-unaddressed-findings: detect review findings that landed AFTER the last +# commit — i.e. nothing was pushed in response to them. +# +# Why: review bots post findings continuously, but a PR can be merged seconds +# later. Two defects reached main this way (techrecon#655, ipgeo_core#168 — +# inline findings 41s and 22s before a human click-merge) and both stayed live +# for days. Required-check machinery cannot catch this: the fleet rulesets run +# `required_approving_review_count: 0` and the review bot only has +# `create_inline_comment` + `gh pr comment`, so a finding can never block a +# merge. Detection is the available lever. +# +# Signal: an INLINE review comment newer than the newest commit. Inline comments +# are reserved for "issues you're 80%+ sure are real bugs" (claude-review.yml +# prompt), so they carry far less noise than top-level summaries — the bot posts +# a top-level line on every run, including "No issues found". Top-level comments +# count only when they match a finding marker and no clean marker. +# +# Usage: +# bb-unaddressed-findings +# bb-unaddressed-findings --scan [merged-pr-count] # post-merge sweep +# bb-unaddressed-findings --fixture # offline (tests) +# +# Fixture dir (testing seam) holds: meta.json (single PR object with +# .merged_at + .last_commit), inline.json, issue.json — same shapes the REST +# API returns. +# +# Exit codes: +# 0 — no unaddressed findings +# 1 — unaddressed findings present +# 2 — invocation error + +set -uo pipefail + +FIXTURE="" +SCAN="" +if [[ "${1:-}" == "--fixture" ]]; then + FIXTURE="${2:-}"; shift 2 +elif [[ "${1:-}" == "--scan" ]]; then + SCAN=1; shift +fi + +REPO="${1:-}" +PR="${2:-}" + +if [[ -z "$REPO" ]] || { [[ -z "$SCAN" ]] && [[ -z "$PR" ]]; }; then + echo "usage: $0 | $0 --scan [count]" >&2 + exit 2 +fi + +if ! command -v jq >/dev/null; then + echo "error: jq required" >&2; exit 2 +fi +if [[ -z "$FIXTURE" ]] && ! command -v gh >/dev/null; then + echo "error: gh required" >&2; exit 2 +fi + +if [[ -t 1 ]]; then + RED=$'\033[31m'; GREEN=$'\033[32m'; BOLD=$'\033[1m'; DIM=$'\033[2m'; RESET=$'\033[0m' +else + RED=''; GREEN=''; BOLD=''; DIM=''; RESET='' +fi + +# A top-level comment is a finding only if it announces one and is not a +# clean/skip summary. Inline comments need no such filter. +# NOTE what is NOT here: `^### Codex review`. That header opens 100% of codex +# comments, passes included, so it cannot discriminate within the population it +# matches — it made every clean review a finding that CLEAN_RE then had to +# rescue BY NAME, and a free-form model verdict cannot be enumerated (measured +# 2026-07-31: wxa_vpn #1366/#1360/#1358 were still falsely flagged a day after +# CLEAN_RE was widened, each a pass phrased in prose no marker knew). Removing +# the non-signal deletes the rescue problem rather than lengthening the list. +# +# Codex's finding forms are therefore named explicitly, because its mandated +# regression shape carries no P-token at all (wxa-mcp-server #374/#369/#384) and +# would otherwise have gone silent along with the header. +# +# P0 is in the range because it was previously detected only INCIDENTALLY, by +# the header rather than by its own severity — dropping the header without this +# would have blinded the most urgent class of finding. P3 stays out: widening +# upward from P1 costs nothing, widening downward re-admits nits. +# +# Matched CASE-INSENSITIVELY, like CLEAN_RE and SEVERITY_RE and for the same +# reason — these are model-written words, not literals a workflow prints. It +# matters more here than anywhere else now: with the header gone this is the +# sole admission gate, so a lowercased `verdict: regression` that fails to match +# is not a comment classified wrongly, it is a comment never examined at all. +FINDING_RE='([Ff]lagged [0-9]+ issue|\bP[012]\b|VERDICT: REGRESSION|regression: \S+:[0-9]+)' +# With the header gone from FINDING_RE, CLEAN_RE no longer has to rescue every +# codex pass — those now never match a finding marker in the first place. What +# still reaches it is the LOOSE `\bP[012]\b` alternative, which a clean summary +# trips whenever it names the severities it did not find ("No P1/P2 issues +# found"). CLEAN_RE suppresses those; SEVERITY_RE below decides when a clean +# phrase is contradicted and must be overridden. +# +# A clean verdict landing after the last commit is the DESIRED end state of the +# iterate-until-clean protocol, not a reason to withhold a merge; a sweeper that +# cries wolf gets ignored, which costs exactly as much as not having one. +# Measured 2026-07-30 on wxa_vpn: 6 of the last 15 merged PRs were flagged, 4 of +# them falsely (#1364/#1341/#1343 clean verdicts plus a pr-classify comment); +# wxa-mcp-server#376 was the same shape. +# +# Matched CASE-INSENSITIVELY (see the `test(...; "i")` calls below). These are +# model-generated prose, not literals a workflow prints: README.md documents the +# pass verdict as lowercase `no regressions found` while every body observed live +# capitalises it, and the same drift applies to the `VERDICT:` trailer. +# +# LGTM is the one entry anchored to the start of a line. Every other marker is a +# phrase carrying its own polarity — "No issues found" cannot be negated into a +# finding — whereas LGTM is a bare token, so an unanchored substring test reads +# "Not LGTM: authentication can be bypassed" as a pass and buries an explicitly +# negative review. Same rule as the severity side below: a verdict counts when it +# OPENS its line, not when it turns up inside a sentence. +CLEAN_RE='(No issues found|Skipped:|Bugbot is not enab|Coverage Floor|claude-author-automerge|VERDICT: CLEAN|No regressions found|(^|\n)[[:space:]]*\**LGTM\b|" + existing=$(gh api "/repos/${GITHUB_REPOSITORY}/issues/$PR/comments?per_page=100" --paginate \ + --jq ".[] | select(.body | contains(\"$marker\")) | .id" | head -1 || true) + { + echo "$marker" + echo "**Auto-merge withheld — review findings arrived after the last authored commit.**" + echo "" + echo "The quiet-period gate ran the unaddressed-findings check and it flagged:" + echo "" + echo '```' + printf '%s\n' "$report" | head -40 + echo '```' + echo "" + echo "- Push a commit addressing them — the push re-runs this gate and re-arms" + echo " automatically when the check comes back clean." + echo "- If a finding is wrong, merge manually — this gate only withholds the" + echo " automatic arm; it never blocks the merge button and is not a required check." + } > /tmp/automerge-findings.md + if [ -n "$existing" ]; then + gh api -X PATCH "/repos/${GITHUB_REPOSITORY}/issues/comments/$existing" \ + -f body="$(cat /tmp/automerge-findings.md)" >/dev/null + else + gh pr comment "$PR" --body-file /tmp/automerge-findings.md + fi + { echo "clear=0"; echo "reason=findings"; } >> "$GITHUB_OUTPUT" + echo "::notice::Auto-merge withheld: unaddressed review findings (see the sticky comment). Manual merge remains available." + exit 0 + + - name: Enable auto-merge + if: | + steps.detect.outputs.claude_authored == '1' && + steps.classifier_verdict.outputs.blocked != '1' && + steps.hold.outputs.hold != '1' && + steps.base_gate.outputs.refuse != '1' && + steps.quiet_findings.outputs.clear == '1' && ( + steps.risk.outputs.risky != '1' || + steps.bypass_label.outputs.bypass == '1' || + steps.bypass_codex.outputs.bypass == '1' + ) env: # Prefer the caller-supplied PAT so the merge commit triggers # downstream `push:` workflows (deploy, CI). Fall back to the @@ -1289,6 +1501,7 @@ jobs: steps.hold.outcome == 'failure' || steps.base_gate.outcome == 'failure' || steps.risk.outcome == 'failure' || + steps.quiet_findings.outcome == 'failure' || steps.setup_node.outcome == 'failure' || steps.checkout.outcome == 'failure' || steps.detect.outcome == 'failure') diff --git a/selftest/test_automerge_findings_gate.sh b/selftest/test_automerge_findings_gate.sh new file mode 100644 index 0000000..f6e63e4 --- /dev/null +++ b/selftest/test_automerge_findings_gate.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash +# Behavioral test for the quiet-period + unaddressed-findings gate in +# claude-author-automerge.yml (wxa_vpn#1392). +# +# Incident context: review bots post findings continuously and nothing holds +# the door between the last review and the merge — four PRs merged 3m18s to +# 16m17s after a finding posted, with no commit in response (wxa_vpn#1385, +# #1376, #1378, #1345; the last two carried P1s). The gate holds the ARM (not +# the merge) until the conversation has been quiet for findings_quiet_minutes, +# then runs the unaddressed-findings detector; findings decline the arm and +# disarm any stale arm, never touching the manual merge button. +# +# Cases: +# S1. structural: error-revoke counts steps.quiet_findings.outcome == 'failure' +# S2. structural: Enable auto-merge requires quiet_findings.outputs.clear == '1' +# S3. structural: extracted run block is ${{ }}-interpolation-free +# S4. structural: all three anchor reads paginate +# B1. findings_quiet_minutes=0 ⇒ clear=1 reason=disabled, ZERO gh calls +# B2. quiet satisfied + detector clean ⇒ clear=1 reason=quiet+clean +# B3. quiet satisfied + detector findings ⇒ clear=0 reason=findings, +# --disable-auto called, sticky comment posted +# B4. detector invocation error (rc=2) ⇒ nonzero exit (fail closed) +# B5. young activity ⇒ sleeps the remaining window, then proceeds +# B6. no anchor resolvable (empty commits+comments) ⇒ nonzero exit +# B7. our own marker comment does NOT reset the clock (no sleep) +# B8. chronic chatter ⇒ quiet-cap: clear=0 reason=quiet-cap + disarm +# B9. anchor read fails 3x ⇒ nonzero exit (fail closed) +# V1. vendored detector, --fixture: late bot P1 comment ⇒ rc=1 +# V2. vendored detector, --fixture: clean PR ⇒ rc=0 +# +# Run from the repo root: +# bash selftest/test_automerge_findings_gate.sh +set -euo pipefail + +WF=.github/workflows/claude-author-automerge.yml +CHECKER=.github/scripts/unaddressed-findings.sh +failed=0 +T=$(mktemp -d) +trap 'rm -rf "$T"' EXIT + +# --------------------------------------------------------------------------- +# S1-S2. Structural pins. +# --------------------------------------------------------------------------- +if grep -q "steps.quiet_findings.outcome == 'failure'" "$WF"; then + echo "✓ S1 error-revoke counts a failed quiet/findings read as a gate error" +else + echo "✗ S1 error-revoke does not include steps.quiet_findings.outcome == 'failure' — a crashed gate leaves a stale arm alive" + failed=1 +fi +if awk '/^ - name: Enable auto-merge$/,/^ env:/' "$WF" \ + | grep -q "steps.quiet_findings.outputs.clear == '1'"; then + echo "✓ S2 Enable auto-merge requires quiet_findings clear=1" +else + echo "✗ S2 Enable auto-merge does not require steps.quiet_findings.outputs.clear == '1' — the gate is decorative" + failed=1 +fi + +# --------------------------------------------------------------------------- +# Extract the gate's run block — the shipped bash, not a mirrored copy. +# --------------------------------------------------------------------------- +awk ' + /^ - name: Quiet period \+ unaddressed findings$/ { in_step=1 } + in_step && /^ run: \|/ { in_run=1; next } + in_run { + if ($0 ~ /^ / || $0 == "") { sub(/^ /, ""); print } + else { exit } + } +' "$WF" > "$T/qf.sh" + +if ! grep -q 'QUIET_MINUTES' "$T/qf.sh" || ! grep -q 'GITHUB_OUTPUT' "$T/qf.sh"; then + echo "✗ could not extract the quiet/findings run block from $WF" + exit 1 +fi +echo "✓ extracted quiet/findings step ($(wc -l < "$T/qf.sh" | tr -d ' ') lines)" + +# S3. Interpolation-free (extraction-safe AND injection-safe). +if grep -q '${{' "$T/qf.sh"; then + echo "✗ S3 run block contains \${{ }} interpolation — untestable standalone and an injection risk; use env vars" + failed=1 +else + echo "✓ S3 run block is interpolation-free (env vars only)" +fi + +# S4. All three anchor reads paginate. +for ep in 'pulls/${PR}/commits?per_page=100' 'pulls/${PR}/comments?per_page=100' 'issues/${PR}/comments?per_page=100'; do + if grep -F "$ep" "$T/qf.sh" | grep -q -- '--paginate'; then + echo "✓ S4 anchor read paginates: $ep" + else + echo "✗ S4 anchor read does not paginate: $ep — activity past page 1 resets no clock" + failed=1 + fi +done + +# --------------------------------------------------------------------------- +# Stub environment. Fake gh / date / sleep on PATH; per-case fixtures. +# +# Fake clock: NOW lives in $T_NOW; `date -u +%s` reads it, `sleep N` +# advances it by N (and logs the call). `date -u -d +%s` maps the +# fixture timestamps: +# OLD 2026-01-01T00:00:00Z -> 1000000 (age >= quiet at start) +# YOUNG 2026-01-01T02:00:00Z -> 1006800 (age 200s at start NOW=1007000) +# CHATTY 2026-01-01T09:09:09Z -> NOW-100 (always 100s ago: livelock) +# --------------------------------------------------------------------------- +mkdir -p "$T/bin" + +cat > "$T/bin/date" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "-u" ] && [ "$2" = "+%s" ]; then cat "$T_NOW"; exit 0; fi +if [ "$1" = "-u" ] && [ "$2" = "-d" ]; then + case "$3" in + 2026-01-01T00:00:00Z) echo 1000000 ;; + 2026-01-01T02:00:00Z) echo 1006800 ;; + 2026-01-01T09:09:09Z) echo $(( $(cat "$T_NOW") - 100 )) ;; + *) echo "date-stub: unmapped timestamp '$3'" >&2; exit 1 ;; + esac + exit 0 +fi +echo "date-stub: unexpected args: $*" >&2; exit 1 +EOF + +cat > "$T/bin/sleep" <<'EOF' +#!/usr/bin/env bash +echo "$1" >> "$T_DIR/sleep.log" +n=$(cat "$T_NOW"); echo $(( n + ${1%.*} )) > "$T_NOW" +exit 0 +EOF + +# gh stub: dispatches on the call shape; logs every call. Steered by files +# in $T_DIR: commits.json / inline.json / issue.json fixtures, checker_rc +# (detector exit code), fail_commits (make the commits read fail). +cat > "$T/bin/gh" <<'EOF' +#!/usr/bin/env bash +echo "$*" >> "$T_DIR/gh.log" +case "$*" in + *pulls/*/commits*) + [ -f "$T_DIR/fail_commits" ] && exit 1 + cat "$T_DIR/commits.json"; exit 0 ;; + *pulls/*/comments*) + cat "$T_DIR/inline.json"; exit 0 ;; + *contents/.github/scripts/unaddressed-findings.sh*) + base64 < "$T_DIR/checker_stub.sh"; exit 0 ;; + *issues/*/comments*--jq*) + # sticky-comment lookup: report no existing comment + exit 0 ;; + *issues/*/comments*) + cat "$T_DIR/issue.json"; exit 0 ;; + "pr merge --disable-auto"*) + echo "disable-auto" >> "$T_DIR/calls.log"; exit 0 ;; + "pr view"*) + echo "OFF"; exit 0 ;; + "pr comment"*) + echo "comment-posted" >> "$T_DIR/calls.log"; exit 0 ;; + "api -X PATCH"*) + echo "comment-patched" >> "$T_DIR/calls.log"; exit 0 ;; + *) + echo "gh-stub: unmatched: $*" >&2; exit 1 ;; +esac +EOF +chmod +x "$T/bin/date" "$T/bin/sleep" "$T/bin/gh" + +OLD_COMMITS='[{"parents":[{"sha":"x"}],"commit":{"committer":{"date":"2026-01-01T00:00:00Z"}}}]' +YOUNG_INLINE='[{"created_at":"2026-01-01T02:00:00Z","user":{"login":"github-actions[bot]"},"body":"[P2] something"}]' + +# new_case — fresh fixture dir with empty defaults; cases overwrite fixtures +# BEFORE exec_gate. exec_gate — run the extracted block once. +new_case() { + CASE=$(mktemp -d "$T/case.XXXXXX") + cat > "$CASE/checker_stub.sh" <<'EOF' +#!/usr/bin/env bash +echo "stub findings report line" +exit "$(cat "$T_DIR/checker_rc")" +EOF + echo 1007000 > "$CASE/now" + : > "$CASE/gh.log"; : > "$CASE/sleep.log"; : > "$CASE/calls.log"; : > "$CASE/output" + echo '[]' > "$CASE/commits.json" + echo '[]' > "$CASE/inline.json" + echo '[]' > "$CASE/issue.json" + echo 0 > "$CASE/checker_rc" +} +exec_gate() { + set +e + PATH="$T/bin:$PATH" T_DIR="$CASE" T_NOW="$CASE/now" \ + GITHUB_OUTPUT="$CASE/output" GITHUB_REPOSITORY="o/r" \ + PR=1 PR_URL="https://example.invalid/pr/1" QUIET_MINUTES="$1" \ + bash "$T/qf.sh" > "$CASE/stdout" 2>&1 + RC=$? + set -e +} + +# B1. Gate disabled: minutes=0 ⇒ clear=1 reason=disabled, ZERO gh calls. +new_case +exec_gate 0 +if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && grep -q '^reason=disabled$' "$CASE/output" \ + && [ ! -s "$CASE/gh.log" ]; then + echo "✓ B1 minutes=0 disables the gate without touching the API" +else + echo "✗ B1 minutes=0: rc=$RC output=[$(tr '\n' ' ' < "$CASE/output")] gh-calls=$(wc -l < "$CASE/gh.log" | tr -d ' ')" + failed=1 +fi + +# B2. Quiet + clean ⇒ clear=1. +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +exec_gate 20 +if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && grep -q '^reason=quiet+clean$' "$CASE/output"; then + echo "✓ B2 quiet + clean detector arms (clear=1)" +else + echo "✗ B2: rc=$RC output=[$(tr '\n' ' ' < "$CASE/output")] stdout: $(tail -3 "$CASE/stdout" | tr '\n' ' ')" + failed=1 +fi + +# B3. Quiet + findings ⇒ clear=0, disarm, sticky comment. +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +echo 1 > "$CASE/checker_rc" +exec_gate 20 +if [ "$RC" -eq 0 ] && grep -q '^clear=0$' "$CASE/output" && grep -q '^reason=findings$' "$CASE/output" \ + && grep -q 'disable-auto' "$CASE/calls.log" && grep -q 'comment-posted' "$CASE/calls.log"; then + echo "✓ B3 findings decline the arm, disarm stale arms, and post the sticky comment" +else + echo "✗ B3: rc=$RC output=[$(tr '\n' ' ' < "$CASE/output")] calls=[$(tr '\n' ' ' < "$CASE/calls.log")] stdout: $(tail -3 "$CASE/stdout" | tr '\n' ' ')" + failed=1 +fi + +# B4. Detector invocation error (rc=2) ⇒ nonzero exit (fail closed). +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +echo 2 > "$CASE/checker_rc" +exec_gate 20 +if [ "$RC" -ne 0 ]; then + echo "✓ B4 detector error fails the step (fail closed; error-revoke disarms)" +else + echo "✗ B4 detector rc=2 produced step rc=0 — an unreadable detector must never arm" + failed=1 +fi + +# B5. Young activity ⇒ sleeps the remaining window, then proceeds. +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +printf '%s' "$YOUNG_INLINE" > "$CASE/inline.json" +exec_gate 20 +if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && grep -q '^1000$' "$CASE/sleep.log"; then + echo "✓ B5 young inline comment: slept the 1000s remainder, then armed" +else + echo "✗ B5: rc=$RC sleeps=[$(tr '\n' ' ' < "$CASE/sleep.log")] output=[$(tr '\n' ' ' < "$CASE/output")]" + failed=1 +fi + +# B6. No anchor resolvable ⇒ nonzero exit (an absence is not a timestamp). +new_case +exec_gate 20 +if [ "$RC" -ne 0 ]; then + echo "✓ B6 empty commits+comments fail closed (no anchor, no arm)" +else + echo "✗ B6 empty fixtures produced rc=0 — a PR with no readable activity must not arm" + failed=1 +fi + +# B7. Our own marker comment does NOT reset the clock. +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +printf '%s' '[{"created_at":"2026-01-01T02:00:00Z","user":{"login":"github-actions[bot]"},"body":"\nAuto-merge withheld"}]' > "$CASE/issue.json" +exec_gate 20 +if [ "$RC" -eq 0 ] && grep -q '^clear=1$' "$CASE/output" && [ ! -s "$CASE/sleep.log" ]; then + echo "✓ B7 the gate's own sticky comment does not reset its clock" +else + echo "✗ B7: rc=$RC sleeps=[$(tr '\n' ' ' < "$CASE/sleep.log")] output=[$(tr '\n' ' ' < "$CASE/output")] — the gate holds itself shut" + failed=1 +fi + +# B8. Chronic chatter ⇒ quiet-cap: clear=0 reason=quiet-cap + disarm. +new_case +printf '%s' "$OLD_COMMITS" > "$CASE/commits.json" +printf '%s' '[{"created_at":"2026-01-01T09:09:09Z","user":{"login":"somebot[bot]"},"body":"chatter"}]' > "$CASE/issue.json" +exec_gate 20 +if [ "$RC" -eq 0 ] && grep -q '^clear=0$' "$CASE/output" && grep -q '^reason=quiet-cap$' "$CASE/output" \ + && grep -q 'disable-auto' "$CASE/calls.log"; then + echo "✓ B8 chronic chatter hits the cap: declined + disarmed, not livelocked" +else + echo "✗ B8: rc=$RC output=[$(tr '\n' ' ' < "$CASE/output")] calls=[$(tr '\n' ' ' < "$CASE/calls.log")]" + failed=1 +fi + +# B9. Anchor read fails 3x ⇒ nonzero exit (fail closed). +new_case +touch "$CASE/fail_commits" +exec_gate 20 +if [ "$RC" -ne 0 ]; then + echo "✓ B9 unreadable commits fail closed after retries" +else + echo "✗ B9 commits read failing 3x produced rc=0 — blind arming" + failed=1 +fi + +# --------------------------------------------------------------------------- +# V1-V2. Vendored detector twin, via its own --fixture seam. +# --------------------------------------------------------------------------- +FX="$T/fixture" +mkdir -p "$FX" +printf '%s' '{"merged_at": null, "last_commit": "2026-01-01T00:00:00Z"}' > "$FX/meta.json" +echo '[]' > "$FX/inline.json" +printf '%s' '[{"created_at":"2026-01-01T01:00:00Z","user":{"login":"github-actions[bot]"},"body":"- [P1] token validation bypassed — auth.py:41"}]' > "$FX/issue.json" +set +e +bash "$CHECKER" --fixture "$FX" o/r 1 >/dev/null 2>&1; v1=$? +echo '[]' > "$FX/issue.json" +bash "$CHECKER" --fixture "$FX" o/r 1 >/dev/null 2>&1; v2=$? +set -e +if [ "$v1" -eq 1 ]; then + echo "✓ V1 vendored detector flags a late bot P1 (rc=1)" +else + echo "✗ V1 vendored detector rc=$v1 on a late P1 — expected 1" + failed=1 +fi +if [ "$v2" -eq 0 ]; then + echo "✓ V2 vendored detector passes a clean PR (rc=0)" +else + echo "✗ V2 vendored detector rc=$v2 on a clean PR — expected 0" + failed=1 +fi + +echo +if [ "$failed" -ne 0 ]; then + echo "FAILED — see ✗ lines above" + exit 1 +fi +echo "all findings-gate cases passed"