#!/usr/bin/env bash
# Validation gate for harness_orchestrator.sh convergence loop (REC-75).
# Static + behavioral assertions on the end-of-build convergence review. Pins
# the safety-critical properties so a later edit can't silently weaken them:
#   - success/dry-run gated
#   - bounded stop-rule with explicit VERDICT contract
#   - CAP / run failure / frozen-gate touch / regressed gate fail closed
#   - codex-absent fallback is the only skip.
# Self-contained: no real codex / build needed (static source checks + dry-run).
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ORCH="$HERE/../harness_orchestrator.sh"
PASS=0; FAIL=0
ok(){ echo "  OK: $1"; PASS=$((PASS+1)); }
no(){ echo "  FAIL: $1"; FAIL=$((FAIL+1)); }
has(){ printf '%s\n' "$1" | grep -qE -- "$2"; }
has_f(){ printf '%s\n' "$1" | grep -qF -- "$2"; }
line_of(){ printf '%s\n' "$1" | awk -v pat="$2" '$0 ~ pat {print NR; exit}'; }

test -f "$ORCH" || { echo "FATAL: orchestrator not found at $ORCH"; exit 1; }

# 1. syntax
bash -n "$ORCH" && ok "orchestrator bash -n clean" || no "orchestrator syntax error"

# Extract just the convergence block (from its banner to the completion marker).
BLOCK="$(awk '/End-of-build convergence review/{f=1} f{print} /Write the completion marker/{f=0}' "$ORCH")"
LOOP_BLOCK="$(printf '%s\n' "$BLOCK" | awk '/while \[ "\$convergence_round" -le "\$CONVERGE_MAX_ROUNDS" \]/{f=1} f{print} /CONVERGE_STATUS="CAPPED"/{cap=1} cap && /CAPPED — NOT CONVERGED/{print; exit}')"
test -n "$BLOCK" && ok "convergence block present" || no "convergence block missing"

# 2. anchors
has_f "$BLOCK" "Convergence Round" \
  && has_f "$BLOCK" "CONVERGED — HUMAN-VERIFY REQUIRED" \
  && ok "convergence anchors present" || no "convergence anchors missing"

# 3. success/dry gated: runs only on a completed, non-dry build.
has_f "$BLOCK" 'if $all_done && ! $DRY_RUN' \
  && ok "convergence gated on \$all_done && !\$DRY_RUN" || no "convergence not success/dry gated"

SBX="$(mktemp -d)"; trap 'rm -rf "$SBX"' EXIT
cat > "$SBX/BUILD_SPEC.md" <<'SPEC'
# BUILD_SPEC — convergence-dry-run-test

## Phase 1: First
engine: codex
Do a thing.
Validation command: `true`

## Phase 2: Second
engine: codex
Do another thing.
Validation command: `true`
SPEC
OUT="$(cd "$SBX" && bash "$ORCH" --dry-run "$SBX/BUILD_SPEC.md" 2>&1)"
printf '%s\n' "$OUT" | grep -qi 'Parsed 2 phases' && ok "dry-run parses the spec" || no "dry-run did not parse"
printf '%s\n' "$OUT" | grep -q 'Convergence Round' && no "dry-run wrongly emitted Convergence Round" || ok "dry-run does NOT emit Convergence Round"
printf '%s\n' "$OUT" | grep -q 'CONVERGED' && no "dry-run wrongly emitted CONVERGED" || ok "dry-run does NOT emit CONVERGED"

# 3b. Behavioral regression: a real (non-dry-run) build whose phases all PASS
# must enter the end-of-build convergence path under errexit. This catches the
# Bash 3.2 command-substitution parser footgun in the frozen-validation setup
# (`case */*|*.*)` inside `$()`) that aborted after the final PASS while
# CONVERGE_STATUS was still SKIPPED.
REAL="$SBX/real"
BIN="$SBX/bin"
mkdir -p "$REAL" "$BIN"
git -C "$REAL" init -q
git -C "$REAL" config user.email "convergence-test@example.invalid"
git -C "$REAL" config user.name "Convergence Test"
printf '[project]\nname = "convergence-test"\nversion = "0.0.0"\n' > "$REAL/pyproject.toml"
printf 'initial\n' > "$REAL/README.md"
cat > "$REAL/BUILD_SPEC.md" <<'SPEC'
# BUILD_SPEC — non-dry convergence regression

## Phase 1: First
engine: claude
depends_on: none

### Validation
```bash
test -f README.md
```

## Phase 2: Second
engine: claude
depends_on: Phase 1

### Validation
```bash
test -f README.md
```
SPEC
git -C "$REAL" add pyproject.toml README.md BUILD_SPEC.md
git -C "$REAL" commit -qm "init"
cat > "$BIN/claude" <<'STUB'
#!/usr/bin/env bash
printf 'stub claude invoked\n'
exit 0
STUB
cat > "$BIN/codex" <<'STUB'
#!/usr/bin/env bash
printf 'VERDICT: CONVERGED\n'
exit 0
STUB
chmod +x "$BIN/claude" "$BIN/codex"
REAL_OUT="$SBX/non-dry-convergence.out"
PATH="$BIN:$PATH" \
HARNESS_CODEX_BIN="$BIN/codex" \
CONVERGE_MAX_ROUNDS=1 \
    bash -e "$ORCH" --coder claude --dir "$REAL" --no-codex-spec-review "$REAL/BUILD_SPEC.md" >"$REAL_OUT" 2>&1
REAL_RC=$?
[ "$REAL_RC" -eq 0 ] \
  && ok "non-dry all-PASS build exits zero after convergence" \
  || no "non-dry all-PASS build exited $REAL_RC"
grep -q 'Running end-of-build convergence review' "$REAL_OUT" \
  && ok "non-dry all-PASS build reaches convergence banner" \
  || no "non-dry all-PASS build did not reach convergence banner"
grep -q 'CONVERGED — HUMAN-VERIFY REQUIRED' "$REAL_OUT" \
  && ok "non-dry all-PASS build records converged terminal" \
  || no "non-dry all-PASS build did not converge"
grep -q 'BUILD BLOCKED' "$REAL_OUT" \
  && no "non-dry all-PASS build printed BUILD BLOCKED" \
  || ok "non-dry all-PASS build does NOT print BUILD BLOCKED"

# 4. stop-rule contract
has_f "$BLOCK" "VERDICT: CONVERGED" \
  && has_f "$BLOCK" "VERDICT: NEEDS-FIXES" \
  && ok "stop-rule verdicts referenced" || no "stop-rule verdicts missing"

# 5. CAP fails closed and does not leave all_done=true.
CAP_BLOCK="$(printf '%s\n' "$BLOCK" | awk '/CONVERGE_STATUS="CAPPED"/{f=1} f{print} /CAPPED — NOT CONVERGED/{seen=1} seen && /echo .*CAPPED — NOT CONVERGED/{exit}')"
has_f "$CAP_BLOCK" "all_done=false" \
  && ok "cap branch sets all_done=false" || no "cap branch does not fail closed"
has_f "$CAP_BLOCK" "all_done=true" \
  && no "cap branch leaves all_done=true" || ok "cap branch does NOT leave all_done=true"

# 6. review-run failure (non-zero or missing VERDICT) fails closed.
RUN_FAIL_BLOCK="$(printf '%s\n' "$BLOCK" | awk '/conv_exit -ne 0.*-z "\$verdict"/{f=1} f{print} f && /break/{exit}')"
has_f "$RUN_FAIL_BLOCK" 'conv_exit -ne 0' \
  && has_f "$RUN_FAIL_BLOCK" '-z "$verdict"' \
  && has_f "$RUN_FAIL_BLOCK" "all_done=false" \
  && ok "run failure or missing verdict sets all_done=false" || no "run failure/missing verdict does not fail closed"
has_f "$RUN_FAIL_BLOCK" 'CONVERGE_STATUS="CONVERGED"' \
  && no "missing verdict can be treated as CONVERGED" || ok "missing verdict is never treated as CONVERGED"

# 7. deterministic frozen-gate guard exists, fails closed, and runs before commit.
has_f "$BLOCK" 'frozen_validation_files' \
  && has_f "$BLOCK" 'grep -Fxq "$changed_file"' \
  && ok "validation-referenced files are frozen" || no "validation-referenced frozen files guard missing"
has_f "$LOOP_BLOCK" '(^|/)tests?/|(^|/)test_|_test\.|conftest\.py|\.feature$' \
  && has_f "$LOOP_BLOCK" "modified a frozen gate" \
  && has_f "$LOOP_BLOCK" "all_done=false" \
  && has_f "$LOOP_BLOCK" "uncommitted" \
  && ok "frozen-gate pattern fail-closes uncommitted" || no "frozen-gate guard missing fail-closed behavior"
guard_line="$(line_of "$LOOP_BLOCK" 'modified a frozen gate')"
commit_line="$(line_of "$LOOP_BLOCK" 'commit -q -m "harness: convergence round')"
if [ -n "$guard_line" ] && [ -n "$commit_line" ] && [ "$guard_line" -lt "$commit_line" ]; then
    ok "frozen-gate guard runs before round commit"
else
    no "frozen-gate guard does not run before round commit"
fi

# 8. CONVERGE_MAX_ROUNDS validation rejects non-integer and <1 values.
has_f "$BLOCK" 'CONVERGE_MAX_ROUNDS=6' \
  && has_f "$BLOCK" '*[!0-9]*' \
  && ok "CONVERGE_MAX_ROUNDS defaults and rejects non-integers" || no "CONVERGE_MAX_ROUNDS non-integer validation missing"
ROUND_LT_BLOCK="$(printf '%s\n' "$BLOCK" | awk '/CONVERGE_MAX_ROUNDS.*-lt 1/{f=1} f{print} f && /all_done=false/{exit}')"
if has_f "$BLOCK" '-ge 1' || { has_f "$ROUND_LT_BLOCK" '-lt 1' && has_f "$ROUND_LT_BLOCK" 'all_done=false'; }; then
    ok "CONVERGE_MAX_ROUNDS rejects values below 1"
else
    no "CONVERGE_MAX_ROUNDS can run zero rounds green"
fi

# 9. prompt defense-in-depth: never weaken gates/fixtures/assertions.
has_f "$BLOCK" "FROZEN-gate discipline: NEVER modify, weaken, disable, delete" \
  && has_f "$BLOCK" "validation command, test fixture, acceptance assertion" \
  && ok "prompt forbids weakening frozen gates" || no "prompt missing never-weaken instruction"

# 10. regressed-gate fail-closed path is preserved.
REGRESSED_BLOCK="$(printf '%s\n' "$LOOP_BLOCK" | awk '/REGRESSED/{f=1} f{print} /review_ok=false/{seen=1} seen && /fi/{exit}')"
has_f "$REGRESSED_BLOCK" "all_done=false" \
  && has_f "$REGRESSED_BLOCK" "uncommitted" \
  && ok "regressed gate fails closed and leaves fix uncommitted" || no "regressed gate fail-closed path missing"

# 11. codex-binary-absent fallback to legacy single-pass Claude review exists.
has_f "$BLOCK" 'resolve_codex_binary' \
  && has_f "$BLOCK" 'CONVERGE_STATUS="FALLBACK"' \
  && has_f "$BLOCK" 'dispatch_phase "/code-review high --fix" "claude"' \
  && ok "codex-absent fallback path exists" || no "codex-absent fallback path missing"

# 12. terminal invariant: convergence loop never sets all_done=true; only
# explicit CONVERGED reaches the green terminal without flipping all_done false.
has_f "$LOOP_BLOCK" "all_done=true" \
  && no "convergence loop contains an all_done=true terminal" || ok "convergence loop never sets all_done=true"
has_f "$LOOP_BLOCK" 'CONVERGE_STATUS="CONVERGED"' \
  && has_f "$LOOP_BLOCK" "CONVERGED — HUMAN-VERIFY REQUIRED" \
  && ok "CONVERGED terminal is explicit" || no "CONVERGED terminal missing"
has_f "$LOOP_BLOCK" "convergence review did not run" \
  && has_f "$LOOP_BLOCK" "modified a frozen gate" \
  && has_f "$LOOP_BLOCK" "REGRESSED" \
  && has_f "$LOOP_BLOCK" "commit FAILED" \
  && has_f "$LOOP_BLOCK" "CAPPED — NOT CONVERGED" \
  && ok "non-converged terminals are encoded as failures" || no "missing non-converged failure terminal"

echo "--------"; echo "PASS=$PASS  FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
