diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 240e7139d..af3d2774d 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -89,6 +89,26 @@ jobs: persist-credentials: false ref: ${{ github.event.pull_request.head.sha }} + - name: Wait for GitHub API before CodeQL init + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + attempt=1 + max_attempts=8 + sleep_seconds=15 + while [ "$attempt" -le "$max_attempts" ]; do + if gh api rate_limit --jq '.resources.core.limit' >/dev/null; then + echo "GitHub API is reachable on attempt ${attempt}." + exit 0 + fi + echo "GitHub API was unavailable on attempt ${attempt}; retrying in ${sleep_seconds}s." + sleep "$sleep_seconds" + attempt=$((attempt + 1)) + done + echo "::error::GitHub API stayed unavailable; CodeQL init cannot determine feature enablement." + exit 1 + - name: Initialize CodeQL uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: @@ -196,6 +216,26 @@ jobs: persist-credentials: false ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} + - name: Wait for GitHub API before CodeQL init + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + attempt=1 + max_attempts=8 + sleep_seconds=15 + while [ "$attempt" -le "$max_attempts" ]; do + if gh api rate_limit --jq '.resources.core.limit' >/dev/null; then + echo "GitHub API is reachable on attempt ${attempt}." + exit 0 + fi + echo "GitHub API was unavailable on attempt ${attempt}; retrying in ${sleep_seconds}s." + sleep "$sleep_seconds" + attempt=$((attempt + 1)) + done + echo "::error::GitHub API stayed unavailable; CodeQL init cannot determine feature enablement." + exit 1 + - name: Initialize CodeQL uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 with: diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 59b25e343..bc75ea3dc 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -287,15 +287,27 @@ jobs: echo "::error::Noema reviewer credential selection succeeded but no token was minted; review cannot submit a verdict." exit 1 fi - if [ "$TARGET_REPOSITORY_PRIVATE" = "false" ] && [ -n "${NVIDIA_NIM_API_KEY:-}" ] && [ -z "${NOEMA_LLM_API_URL:-}" ] && [ -z "${NOEMA_LLM_MODEL:-}" ]; then - export NOEMA_LLM_API_URL="https://integrate.api.nvidia.com/v1/chat/completions" - export NOEMA_LLM_MODEL="nvidia/nemotron-3-ultra-550b-a55b" - export NOEMA_LLM_API_KEY="${NVIDIA_NIM_API_KEY:-}" - fi - if [ -z "${NOEMA_LLM_API_URL:-}" ] || [ -z "${NOEMA_LLM_MODEL:-}" ] || [ -z "${NOEMA_LLM_API_KEY:-}" ]; then - echo "::error::Noema LLM is unconfigured: NOEMA_LLM_API_URL, NOEMA_LLM_MODEL, and NOEMA_LLM_API_KEY (or OPENAI_API_KEY) are required." - exit 1 - fi + case "$TARGET_REPOSITORY_PRIVATE" in + false) + if [ -z "${NVIDIA_NIM_API_KEY:-}" ]; then + echo "::error::Noema LLM is unconfigured: NVIDIA_NIM_API_KEY is required so a green public-repository Noema check is a real NIM review." + exit 1 + fi + export NOEMA_LLM_API_URL="https://integrate.api.nvidia.com/v1/chat/completions" + export NOEMA_LLM_MODEL="nvidia/nemotron-3-ultra-550b-a55b" + export NOEMA_LLM_API_KEY="${NVIDIA_NIM_API_KEY}" + ;; + true) + if [ -z "${NOEMA_LLM_API_URL:-}" ] || [ -z "${NOEMA_LLM_MODEL:-}" ] || [ -z "${NOEMA_LLM_API_KEY:-}" ]; then + echo "::error::Noema LLM is unconfigured: a private repository requires an explicitly configured trusted NOEMA_LLM_API_URL, NOEMA_LLM_MODEL, and NOEMA_LLM_API_KEY. Private diff evidence is not sent to the hosted NVIDIA NIM endpoint." + exit 1 + fi + ;; + *) + echo "::error::Noema target repository visibility was missing or invalid; failing closed." + exit 1 + ;; + esac python3 scripts/ci/noema_review_gate.py \ --repo "$TARGET_REPOSITORY" \ --pr-number "$PR_NUMBER" diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index ed3f7b44f..18e9c181d 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -633,6 +633,10 @@ jobs: --base-sha "$PR_BASE_SHA" \ --head-sha "$PR_HEAD_SHA" \ --output-dir "$coverage_build_dir/base-javascript-packages" + python3 -I "$GITHUB_WORKSPACE/scripts/ci/materialize_base_rust_toolchain.py" \ + --repo-root "$COVERAGE_SOURCE_WORKDIR" \ + --base-sha "$PR_BASE_SHA" \ + --output-dir "$coverage_build_dir/base-rust" cat >"$coverage_build_dir/Dockerfile" <<'DOCKERFILE' FROM docker.io/library/python:3.14-slim@sha256:b877e50bd90de10af8d82c57a022fc2e0dc731c5320d762a27986facfc3355c1 ENV DEBIAN_FRONTEND=noninteractive @@ -646,6 +650,7 @@ jobs: libcurl4-openssl-dev \ libssl-dev \ libxml2-dev \ + llvm \ mesa-vulkan-drivers \ libvulkan1 \ pkg-config \ @@ -799,6 +804,39 @@ jobs: --requirements-root /tmp/base-python-requirements \ && rm -rf /tmp/base-python-requirements \ && rm -f /usr/local/libexec/install-base-python-locks.py + COPY base-rust /tmp/base-rust + RUN set -eu; \ + if [ ! -f /tmp/base-rust/manifest.json ]; then \ + echo "Rust coverage manifest.json must exist in the trusted build context." >&2; \ + exit 1; \ + fi; \ + channel="$(jq -r '.rustup_channel // empty' /tmp/base-rust/manifest.json)"; \ + if [ -n "$channel" ]; then \ + curl --proto '=https' --tlsv1.2 -fsSLo /tmp/rustup-init \ + https://static.rust-lang.org/rustup/archive/1.28.2/x86_64-unknown-linux-gnu/rustup-init; \ + echo '20a06e644b0d9bd2fbdbfd52d42540bdde820ea7df86e92e533c073da0cdd43c /tmp/rustup-init' | sha256sum -c -; \ + chmod 0755 /tmp/rustup-init; \ + RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo \ + /tmp/rustup-init -y --no-modify-path --profile minimal --default-toolchain none; \ + RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo \ + /opt/cargo/bin/rustup toolchain install "$channel" --component llvm-tools-preview --profile minimal; \ + RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo \ + /opt/cargo/bin/rustup default "$channel"; \ + chmod -R a+rX /opt/rustup /opt/cargo; \ + rm -f /tmp/rustup-init; \ + fi; \ + if [ -f /tmp/base-rust/Cargo.toml ] && [ -f /tmp/base-rust/Cargo.lock ]; then \ + mkdir -p /opt/cargo; \ + if [ -x /opt/cargo/bin/cargo ]; then \ + RUSTUP_HOME=/opt/rustup CARGO_HOME=/opt/cargo \ + /opt/cargo/bin/cargo fetch --locked --manifest-path /tmp/base-rust/Cargo.toml; \ + else \ + CARGO_HOME=/opt/cargo \ + cargo fetch --locked --manifest-path /tmp/base-rust/Cargo.toml; \ + fi; \ + chmod -R a+rX /opt/cargo; \ + fi; \ + rm -rf /tmp/base-rust DOCKERFILE if ! docker build --pull --no-cache --network=default \ --tag "$coverage_tool_image" \ @@ -955,7 +993,9 @@ jobs: HOME=/work/.opencode-sandbox-home \ XDG_CACHE_HOME=/work/.opencode-sandbox-cache \ CARGO_HOME=/work/.opencode-sandbox-home/.cargo \ - PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + RUSTUP_HOME=/opt/rustup \ + CARGO_NET_OFFLINE=true \ + PATH="/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ "$@" >"$log_file" 2>&1 local rc=$? set -e @@ -1471,7 +1511,7 @@ jobs: return 1 } if [ "$base_blob" != "$head_blob" ] || [ "$head_blob" != "$worktree_blob" ]; then - echo "::error::Current pnpm lock ${relative_lock} differs from the validated base; refusing --trust-lockfile for PR-controlled dependency resolution." + echo "::error::Current pnpm lock ${relative_lock} differs from the validated base; refusing PR-controlled dependency resolution." return 1 fi } @@ -1850,6 +1890,18 @@ jobs: fi } + prepare_writable_cargo_home() { + if [ -d /opt/cargo ] && [ ! -L /opt/cargo ]; then + mkdir -p /work/.opencode-sandbox-home/.cargo + cp -R /opt/cargo/. /work/.opencode-sandbox-home/.cargo/ + fi + mkdir -p /work/.opencode-sandbox-home/.cargo + chown -R --no-dereference \ + "$OPENCODE_SANDBOX_UID:$OPENCODE_SANDBOX_GID" \ + /work/.opencode-sandbox-home/.cargo + chmod -R u+rwX,go-rwx /work/.opencode-sandbox-home/.cargo + } + ensure_rust_toolchain() { if [ "${LLVM_COV:-}" != "/usr/bin/llvm-cov-19" ] || \ [ "${LLVM_PROFDATA:-}" != "/usr/bin/llvm-profdata-19" ] || \ @@ -1863,6 +1915,10 @@ jobs: failures=$((failures + 1)) return 1 fi + if [ -x /opt/cargo/bin/cargo ]; then + export PATH="/opt/cargo/bin:${PATH}" + export RUSTUP_HOME=/opt/rustup + fi if ! command -v cargo >/dev/null 2>&1; then append "### Rust coverage toolchain" append "" @@ -1883,6 +1939,17 @@ jobs: failures=$((failures + 1)) return 1 fi + if [ ! -x /opt/cargo/bin/cargo ] && [ ! -x /usr/bin/llvm-cov ]; then + append "### Rust coverage toolchain" + append "" + append "- Result: FAIL" + append "- Reason: llvm-tools-preview is unavailable and /usr/bin/llvm-cov is missing, so cargo llvm-cov cannot measure coverage." + append "- Fix: rebuild the trusted coverage image with rustup llvm-tools-preview or the distribution llvm package." + append "" + failures=$((failures + 1)) + return 1 + fi + prepare_writable_cargo_home ensure_rust_gpu_adapter ensure_rust_desktop_deps } @@ -1919,11 +1986,26 @@ jobs: python3 "${GITHUB_WORKSPACE}/scripts/ci/rust_coverage_threshold.py" "$manifest" } + rust_coverage_plan_line() { + local manifest="$1" + python3 "${GITHUB_WORKSPACE}/scripts/ci/rust_coverage_policy.py" \ + --repo-root . \ + --manifest "$manifest" + } + run_rust_test_coverage() { local manifests if ! ensure_rust_toolchain; then return 0 fi + append "### Rust toolchain identity" + append "" + append "- rustc: $(rustc --version 2>/dev/null || printf 'unavailable')" + append "- cargo: $(cargo --version 2>/dev/null || printf 'unavailable')" + if command -v rustup >/dev/null 2>&1; then + append "- rustup active: $(rustup show active-toolchain 2>/dev/null || printf 'unavailable')" + fi + append "" if ! command -v cargo >/dev/null 2>&1; then append "### Rust test coverage" append "" @@ -1936,7 +2018,39 @@ jobs: manifests="$(rust_coverage_manifests)" if [ -n "$manifests" ]; then while IFS= read -r manifest; do - local threshold + local plan_line rust_cov_mode rust_cov_fail_under rust_cov_verifier threshold + if ! plan_line="$(rust_coverage_plan_line "$manifest")"; then + append "### Rust coverage policy (${manifest})" + append "" + append "- Result: FAIL" + append "- Reason: ${manifest} defines an invalid package.metadata.opencode.coverage.minimum_lines or workspace.metadata.opencode.coverage.minimum_lines value." + append "- Fix: set the matching package or workspace metadata key to a numeric line-coverage percentage from 0 to 100, or ship scripts/ci/verify_coverage.py." + append "" + failures=$((failures + 1)) + continue + fi + IFS=$'\t' read -r rust_cov_mode rust_cov_fail_under rust_cov_verifier <<<"$plan_line" + if [ "$rust_cov_mode" = "repo-verifier" ]; then + append "### Rust coverage policy (${manifest})" + append "" + append "- Result: PASS" + append "- Reason: ${manifest} has no workspace.metadata.opencode.coverage.minimum_lines, and the repository ships ${rust_cov_verifier}; central review runs that verifier instead of defaulting to --fail-under-lines 100." + append "" + if ! ensure_tauri_frontend_dist "$manifest"; then + continue + fi + case "$rust_cov_verifier" in + *.py) + run_and_capture "Rust repository coverage verifier (${manifest})" \ + python3 "$rust_cov_verifier" + ;; + *) + run_and_capture "Rust repository coverage verifier (${manifest})" \ + bash "$rust_cov_verifier" + ;; + esac + continue + fi if ! threshold="$(rust_coverage_fail_under_lines "$manifest")"; then append "### Rust coverage threshold (${manifest})" append "" @@ -1947,24 +2061,24 @@ jobs: failures=$((failures + 1)) continue fi - if [ -z "$threshold" ]; then - threshold=100 - else + if [ -n "$threshold" ]; then append "### Rust coverage threshold (${manifest})" append "" append "- Result: PASS" append "- Reason: ${manifest} sets a package/workspace opencode coverage minimum_lines value to ${threshold}%, so Rust coverage enforces the repository-owned baseline instead of the central default." append "" + else + threshold="${rust_cov_fail_under:-100}" fi if ! ensure_tauri_frontend_dist "$manifest"; then continue fi if [ "$manifest" = "Cargo.toml" ]; then run_and_capture "Rust coverage with missing-line report (${manifest})" \ - cargo llvm-cov --workspace --all-features --fail-under-lines "$threshold" --show-missing-lines + cargo llvm-cov --offline --locked --workspace --all-features --fail-under-lines "$threshold" --show-missing-lines else run_and_capture "Rust coverage with missing-line report (${manifest})" \ - cargo llvm-cov --manifest-path "$manifest" --all-features --fail-under-lines "$threshold" --show-missing-lines + cargo llvm-cov --offline --locked --manifest-path "$manifest" --all-features --fail-under-lines "$threshold" --show-missing-lines fi done <<<"$manifests" else @@ -2169,15 +2283,26 @@ jobs: fi coverage_output_file="$(mktemp)" - awk ' - /^## Coverage Decision$/ { emit = 1 } - emit { print } - ' "$summary_file" >"$coverage_output_file" - if [ ! -s "$coverage_output_file" ]; then + if [ -s "$summary_file" ]; then + # The trusted host validator rejects a sandbox output larger than + # 262144 bytes. Keep the published excerpt below that limit so a + # long measurement log cannot turn a passing gate into a blocker. + coverage_output_max_bytes=200000 + summary_bytes="$(wc -c <"$summary_file" | tr -d '[:space:]')" + if [ "${summary_bytes:-0}" -le "$coverage_output_max_bytes" ]; then + cp "$summary_file" "$coverage_output_file" + else + { + head -c 120000 "$summary_file" + printf '\n\n... coverage log truncated: showing first 120000 and last 60000 of %s bytes; the complete log is in the job log and step summary ...\n\n' "$summary_bytes" + tail -c 60000 "$summary_file" + } >"$coverage_output_file" + fi + else { printf '## Coverage Decision\n\n' printf -- '- Result: FAIL\n' - printf -- '- Reason: compact coverage decision could not be extracted from the full measurement log.\n' + printf -- '- Reason: the full rust/python/js measurement log was empty.\n' } >"$coverage_output_file" failures=$((failures + 1)) fi @@ -2194,7 +2319,7 @@ jobs: cat "$summary_output_file" printf '%s\n' "$coverage_output_delimiter" } >>"$GITHUB_OUTPUT" - printf 'Published compact coverage decision output after sanitization (%s bytes); full command logs remain in the job log and step summary.\n' \ + printf 'Published full rust/python/js coverage measurement log after sanitization (%s bytes), including rustc/cargo identity; the status comment stays short.\n' \ "$(wc -c <"$summary_output_file" | tr -d ' ')" cat "$summary_file" @@ -2230,7 +2355,6 @@ jobs: id-token: write contents: read security-events: read - models: read statuses: write deployments: read pull-requests: write @@ -2462,7 +2586,7 @@ jobs: - name: Detect central review-process scope id: central_review_process_fallback_scope - if: needs.coverage-evidence.result == 'success' + if: needs.coverage-evidence.result != 'cancelled' env: GH_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || github.token }} GH_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }} @@ -2708,6 +2832,7 @@ jobs: OPENCODE_EVIDENCE_FILE: ${{ runner.temp }}/opencode-review-evidence.md OPENCODE_FAILED_CHECK_EVIDENCE_FILE: ${{ runner.temp }}/opencode-failed-check-evidence.md OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt + COVERAGE_EVIDENCE_RESULT: ${{ needs.coverage-evidence.result || 'skipped' }} COVERAGE_EVIDENCE_SUMMARY: ${{ needs.coverage-evidence.outputs.coverage_summary || 'Coverage evidence job did not run or did not publish coverage evidence.' }} FAILED_CHECK_EVIDENCE_ATTEMPTS: "6" FAILED_CHECK_EVIDENCE_SLEEP_SECONDS: "5" @@ -2720,6 +2845,12 @@ jobs: --env-file "$context_env_file" # shellcheck source=/dev/null . "$context_env_file" + quoted_coverage="${COVERAGE_EVIDENCE_RESULT:-}" + COVERAGE_EVIDENCE_RESULT="$(python3 scripts/ci/opencode_coverage_identity.py \ + --repo "$GH_REPOSITORY" \ + --head-sha "$PR_HEAD_SHA" \ + --quoted-result "$quoted_coverage")" + export COVERAGE_EVIDENCE_RESULT printf 'Resolved bounded OpenCode review context for %s#%s at %s.\n' \ "$GH_REPOSITORY" "$PR_NUMBER" "$PR_HEAD_SHA" @@ -3285,7 +3416,13 @@ jobs: emit_all_reviews_and_comments_evidence printf '\n' - printf '## Coverage execution evidence\n\n' + printf '## Coverage gate\n\n' + printf -- '- coverage-evidence result: `%s`\n' "${COVERAGE_EVIDENCE_RESULT:-unknown}" + printf -- '- Approval blocker: coverage is a gate, not a review skip. Review the product diff even when this result is not success.\n' + if [ "${COVERAGE_EVIDENCE_RESULT:-unknown}" != "success" ]; then + printf -- '- Gate status: coverage-evidence did not pass; do not approve. Still review the changed product files.\n' + fi + printf '\n## Coverage execution evidence\n\n' printf '%s\n\n' "$COVERAGE_EVIDENCE_SUMMARY" printf '## Recent deployment evidence\n\n' @@ -3405,6 +3542,10 @@ jobs: OPENCODE_FAILED_CHECK_EVIDENCE_FILE: ${{ runner.temp }}/opencode-failed-check-evidence.md OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head + # Optional. Unset keeps NIM-direct. When set, attach one + # OpenAI-compatible provider pointing at ContextualWisdomLab/contextual-orchestrator. + # Do not start the sidecar in this job; GitHub Models is never a fallback. + CONTEXTUAL_ORCHESTRATOR_URL: ${{ vars.CONTEXTUAL_ORCHESTRATOR_URL || '' }} run: | set -euo pipefail mkdir -p "$OPENCODE_REVIEW_WORKDIR" @@ -3441,6 +3582,7 @@ jobs: append_evidence_section "Current-head authority order" 3000 append_evidence_section "Other unresolved review thread evidence" 5000 append_evidence_section "Failed GitHub Check evidence" 7000 + append_evidence_section "Coverage gate" 2000 append_evidence_section "Coverage execution evidence" 7000 append_evidence_section "Changed files" 7000 append_evidence_section "Adversarial probe source-line receipts" 9000 @@ -3751,7 +3893,7 @@ jobs: "$schema": "https://opencode.ai/config.json", "model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", "small_model": "nvidia-nim/meta/llama-3.3-70b-instruct", - "enabled_providers": ["nvidia-nim", "opencode-free", "opencode", "openai", "openrouter", "github-models"], + "enabled_providers": ["nvidia-nim", "opencode-free", "opencode", "openai", "openrouter"], "lsp": false, "mcp": {}, "permission": { @@ -4226,234 +4368,36 @@ jobs: } } } - }, - "github-models": { - "npm": "@ai-sdk/openai-compatible", - "name": "GitHub Models", - "options": { - "baseURL": "https://models.github.ai/inference", - "apiKey": "{env:STRIX_GITHUB_MODELS_TOKEN}" - }, - "models": { - "openai/gpt-4.1": { - "name": "OpenAI GPT-4.1", - "tool_call": true, - "limit": { - "context": 1048576, - "output": 32768 - } - }, - "openai/gpt-5": { - "name": "OpenAI GPT-5", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-chat": { - "name": "OpenAI GPT-5 Chat", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-mini": { - "name": "OpenAI GPT-5 Mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-nano": { - "name": "OpenAI GPT-5 Nano", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "deepseek/deepseek-r1": { - "name": "DeepSeek R1", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "deepseek/deepseek-r1-0528": { - "name": "DeepSeek R1 0528", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "deepseek/deepseek-v3-0324": { - "name": "DeepSeek V3 0324", - "tool_call": true, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "openai/o3": { - "name": "OpenAI o3", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/o3-mini": { - "name": "OpenAI o3-mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/o4-mini": { - "name": "OpenAI o4-mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "mistral-ai/mistral-medium-2505": { - "name": "Mistral Medium 3 25.05", - "tool_call": true, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "meta/llama-4-maverick-17b-128e-instruct-fp8": { - "name": "Llama 4 Maverick 17B 128E Instruct FP8", - "tool_call": true, - "limit": { - "context": 1000000, - "output": 4096 - } - }, - "meta/llama-4-scout-17b-16e-instruct": { - "name": "Llama 4 Scout 17B 16E Instruct", - "tool_call": true, - "limit": { - "context": 1000000, - "output": 4096 - } - } - } } + } }' >"${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc" + python3 "$GITHUB_WORKSPACE/scripts/ci/attach_contextual_orchestrator_provider.py" \ + "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc" if ! grep -Fq 'nvidia-nim' "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc" \ || ! grep -Fq 'integrate.api.nvidia.com' "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc"; then echo '::error::Generated isolated opencode.jsonc is missing the nvidia-nim provider; refusing to run the model pool without NIM priority.' exit 1 fi + if grep -Fq 'github-models' "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc" \ + || grep -Fq 'STRIX_GITHUB_MODELS_TOKEN' "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc" \ + || grep -Fq 'models.github.ai' "${OPENCODE_REVIEW_WORKDIR}/opencode.jsonc"; then + echo '::error::Generated isolated opencode.jsonc still names GitHub Models; refusing to run the model pool.' + exit 1 + fi printf 'Prepared isolated OpenCode review workspace: %s\n' "$OPENCODE_REVIEW_WORKDIR" - name: Run OpenCode PR Review model pool id: opencode_review_model_pool - if: needs.coverage-evidence.result == 'success' + if: needs.coverage-evidence.result != 'cancelled' timeout-minutes: 205 continue-on-error: true env: - STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} - # Native OpenAI backend for the lead review model. GitHub Models - # rate-limits every request and caps bodies at ~4000 tokens, so the - # rate-starved shared pool never returned a verdict; hitting - # api.openai.com directly with the org OPENAI_API_KEY gives the lead - # model a working, un-throttled backend. Resolves {env:OPENAI_API_KEY} - # in the opencode.jsonc "openai" provider block. + # Native OpenAI backend for post-NIM keyed fallbacks only. GitHub + # Models is not used. Resolves {env:OPENAI_API_KEY} in the isolated + # opencode.jsonc "openai" provider block. OPENCODE_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # The scoped NVIDIA_NIM_API_KEY is the only NIM credential source. @@ -4464,29 +4408,23 @@ jobs: SHARE: "false" NPM_CONFIG_IGNORE_SCRIPTS: "true" NO_COLOR: "1" - # High-sensitivity review candidates only. Public repositories first - # try NVIDIA NIM when its scoped secret is available, then OpenCode - # Zen's anonymous active, zero-cost models, followed by the existing - # provider fallbacks. Trial/free-period data may be logged, retained, - # or used for product/model improvement, so private repositories - # include neither NIM nor anonymous free candidates and start at the - # existing keyed fallback list: OpenCode Zen GPT-5.6 Terra, DeepSeek - # V3, the direct GPT-5.4 slot, and pinned PAID - # OpenRouter coder models (free-tier candidates hit the shared - # free-models-per-day cap and hung for the full candidate timeout, - # so the OpenRouter slots use cheap paid models billed against the - # org's OpenRouter credits), then the full-size GPT-4.1 long-context - # endpoint and provider-specific GPT/o3 fallbacks. - OPENCODE_MODEL_CANDIDATES: "${{ needs.validate-pr-metadata.outputs.is_private == 'false' && 'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 nvidia-nim/nvidia/nemotron-3-super-120b-a12b nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b nvidia-nim/meta/llama-3.3-70b-instruct nvidia-nim/deepseek-ai/deepseek-v4-pro nvidia-nim/mistralai/codestral-22b-instruct-v0.1 opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free ' || '' }}opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5 github-models/openai/gpt-5-chat github-models/openai/o3 github-models/deepseek/deepseek-r1-0528 github-models/deepseek/deepseek-r1" + # NIM-first review pool. GitHub Models and Copilot-class OpenCode Zen + # GPT-5.6 Terra are omitted entirely. If NVIDIA_NIM_API_KEY is unset + # the pool fails closed (skip / REQUEST_CHANGES / status) instead of + # falling through to GitHub Models. Free-tier, Luna, and OpenRouter + # run only after a configured NIM attempt. Trial/free-period NIM data + # may be logged, retained, or used for product/model improvement. + OPENCODE_MODEL_CANDIDATES: "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 nvidia-nim/nvidia/nemotron-3-super-120b-a12b nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b nvidia-nim/meta/llama-3.3-70b-instruct nvidia-nim/deepseek-ai/deepseek-v4-pro nvidia-nim/mistralai/codestral-22b-instruct-v0.1 opencode-free/nemotron-3-ultra-free opencode-free/deepseek-v4-flash-free opencode-free/north-mini-code-free opencode-free/laguna-s-2.1-free opencode-free/ling-3.0-flash-free opencode-free/big-pickle opencode-free/mimo-v2.5-free opencode-free/hy3-free opencode-free/minimax-m3-free opencode-free/glm-5-free opencode-free/kimi-k2.5-free opencode-free/qwen3.6-plus-free openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder" # One attempt per model, then fall through to the next model. Retrying # the SAME model 5x let a rate-limited/hung leader consume the whole # step, so the pool never reached a healthy fallback model. OPENCODE_MODEL_ATTEMPTS: "1" - # Preserve reviews that legitimately need tens of minutes to inspect a - # large repository. Changed-file count is not a repository-complexity - # proxy, so every cadence class gets 90 minutes per candidate while the - # bounded provider-pool watchdog remains the outer guard. - OPENCODE_RUN_TIMEOUT_SECONDS: "5400" + # Preserve reviews that legitimately need a two-hour NIM session + # (ContextualWisdomLab/fast-mlsirm#290). Changed-file count is not a + # repository-complexity proxy, so every cadence class gets 7200s per + # candidate while the bounded provider-pool watchdog remains the + # outer guard. GPT-5 stays at 45s and free-tier at 3600s. + OPENCODE_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_EXPORT_TIMEOUT_SECONDS: "180" OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700" OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000" @@ -4498,29 +4436,24 @@ jobs: OPENCODE_DYNAMIC_REVIEW_CADENCE: "true" OPENCODE_SMALL_CHANGE_FILE_THRESHOLD: "3" OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD: "20" - OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "11700" - OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700" - OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700" - OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700" - OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "5400" + OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "7200" OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "11700" OPENCODE_DYNAMIC_MAX_CYCLES_CAP: "1" - OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "180" - OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "900" + OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "7200" + OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "7200" OPENCODE_FREE_RUN_TIMEOUT_SECONDS: "3600" - # This installation currently reports a 4k request-body limit for - # GitHub Models GPT-5 endpoints even though the public catalog is - # larger. Keep the exact runtime failure visible without spending a - # full medium/large cadence slot after the long-context candidate. - OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS: "45" OPENCODE_DYNAMIC_MAX_CYCLES: "1" CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE: ${{ steps.central_review_process_fallback_scope.outputs.eligible || 'false' }} CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL: ${{ steps.central_review_process_fallback_scope.outputs.scope_label || 'unsupported' }} - OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "5400" + OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "7200" OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "11700" OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES: "1" OPENCODE_BACKOFF_INITIAL_SECONDS: "30" @@ -4659,6 +4592,8 @@ jobs: OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} PR_HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} + COVERAGE_EVIDENCE_RESULT: ${{ needs.coverage-evidence.result || 'skipped' }} + COVERAGE_EVIDENCE_SUMMARY: ${{ needs.coverage-evidence.outputs.coverage_summary || 'Coverage evidence job did not run or did not publish coverage evidence.' }} run: | set -euo pipefail @@ -4730,15 +4665,14 @@ jobs: fi { - printf '\n' - printf '## OpenCode Review Overview\n\n' - printf -- "- Head SHA: \`%s\`\n" "$HEAD_SHA" - printf -- '- Workflow run: %s\n' "$RUN_ID" - printf -- '- Workflow attempt: %s\n' "$RUN_ATTEMPT" - printf -- "- Gate result: \`%s\` (exit %s)\n\n" "${gate_result:-UNKNOWN}" "$gate_status" - cat "$comment_body_file" - append_mermaid_review_graph - append_merge_conflict_guidance + python3 scripts/ci/opencode_review_surfaces.py build-status \ + --result "${gate_result:-UNKNOWN}" \ + --head-sha "$HEAD_SHA" \ + --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-unknown}" \ + --model-pool-outcome "${OPENCODE_MODEL_POOL_OUTCOME:-unknown}" \ + --verdict "${gate_result:-UNKNOWN}" } >"$overview_body_file" live_head="$(gh api -X GET "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha // empty' 2>"$gh_error_file" || true)" @@ -5110,7 +5044,6 @@ jobs: CONFIGURED_REVIEW_WRITE_TOKEN_SOURCE: ${{ steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'github-token' }} CODE_SCANNING_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'github-token' }} GH_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }} - STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} # Exposed so the "openai" provider in opencode.jsonc resolves during the # failed-check diagnosis opencode run that shares this config. OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -5129,7 +5062,7 @@ jobs: COVERAGE_EVIDENCE_SUMMARY: ${{ needs.coverage-evidence.outputs.coverage_summary || 'Coverage evidence job did not run or did not publish coverage evidence.' }} OPENCODE_REVIEW_WORKDIR: ${{ runner.temp }}/opencode-review-project OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head - MODEL: github-models/deepseek/deepseek-v3-0324 + MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 USE_GITHUB_TOKEN: "true" NPM_CONFIG_IGNORE_SCRIPTS: "true" NO_COLOR: "1" @@ -5164,6 +5097,12 @@ jobs: OPENCODE_EXPORT_TIMEOUT_SECONDS: "60" run: | set -euo pipefail + quoted_coverage="${COVERAGE_EVIDENCE_RESULT:-}" + COVERAGE_EVIDENCE_RESULT="$(python3 scripts/ci/opencode_coverage_identity.py \ + --repo "$GH_REPOSITORY" \ + --head-sha "$HEAD_SHA" \ + --quoted-result "$quoted_coverage")" + export COVERAGE_EVIDENCE_RESULT echo "::group::OpenCode Review Approval Gate" echo "PR=#${PR_NUMBER} head_sha=${HEAD_SHA} run_id=${RUN_ID} run_attempt=${RUN_ATTEMPT}" configured_review_write_token="${GH_TOKEN:-}" @@ -5364,7 +5303,7 @@ jobs: . scripts/ci/opencode_review_comment_helpers.sh update_review_overview() { - local result="$1" body="$2" + local result="$1" local gh_error_file local overview_body_file local overview_comment_id @@ -5386,17 +5325,15 @@ jobs: return 1 fi { - printf '\n' - printf '## OpenCode Review Overview\n\n' - printf -- "- Head SHA: \`%s\`\n" "$HEAD_SHA" - printf -- '- Workflow run: %s\n' "$RUN_ID" - printf -- '- Workflow attempt: %s\n' "$RUN_ATTEMPT" - printf -- "- Gate result: \`%s\` (approval step)\n\n" "$result" - printf '%s\n' "$body" - if ! grep -Fq "## Changed-File Evidence Map" <<<"$body"; then - append_mermaid_review_graph - fi - append_merge_conflict_guidance + python3 scripts/ci/opencode_review_surfaces.py build-status \ + --result "$result" \ + --head-sha "$HEAD_SHA" \ + --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-unknown}" \ + --model-pool-outcome "${OPENCODE_MODEL_POOL_OUTCOME:-unknown}" \ + --verdict "$result" \ + --formal-review-url "${FORMAL_REVIEW_URL:-}" } >"$overview_body_file" if ! overview_comment_id="$( @@ -5454,6 +5391,19 @@ jobs: review_payload_file="$(mktemp)" review_response_file="$(mktemp)" if [ "$event" = "APPROVE" ]; then + local live_draft + if ! live_draft="$( + timeout "${REVIEW_PUBLISH_GH_API_TIMEOUT_SECONDS:-120}s" \ + env GH_TOKEN="$review_head_guard_token" \ + gh api "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.draft' + )"; then + printf '::error::draft state could not be read before APPROVE for head %s.\n' "$HEAD_SHA" + return 1 + fi + if [ "$live_draft" != "false" ]; then + printf '::error::draft must never receive bot APPROVE for head %s.\n' "$HEAD_SHA" + return 1 + fi printf '::notice::OpenCode APPROVE review skips the non-authoritative changed-file graph before publication so the required approval check can finish promptly.\n' else body="$(ensure_review_body_has_change_graph "$body")" @@ -5471,7 +5421,7 @@ jobs: printf '::notice::OpenCode review publication stopped because PR head advanced beyond %s; current-head run remains authoritative.\n' "$HEAD_SHA" return 0 fi - update_review_overview "$event" "$body" || true + update_review_overview "$event" || true if [ "$event" = "APPROVE" ]; then if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { @@ -5494,12 +5444,19 @@ jobs: esac exit 1 fi + FORMAL_REVIEW_URL="$(jq -r --arg repo "$GH_REPOSITORY" --arg pr "$PR_NUMBER" ' + if (.id // 0) > 0 then + "https://github.com/\($repo)/pull/\($pr)#pullrequestreview-\(.id)" + else + empty + end + ' "$review_response_file")" rm -f "$gh_error_file" "$review_payload_file" "$review_response_file" if [ "$event" = "APPROVE" ]; then printf '::notice::OpenCode approve review was published for head %s; skipping non-authoritative overview comment mutation so the required approval check can finish promptly.\n' "$HEAD_SHA" return 0 fi - update_review_overview "$event" "$body" + update_review_overview "$event" } emit_review_body_to_action_log() { @@ -5706,7 +5663,7 @@ jobs: "" \ "## Findings" \ "" \ - "### 1. HIGH .github/workflows/opencode-review.yml:1 - Unresolved reviewer thread blocks automated approval" \ + "### 1. HIGH Review process - Unresolved reviewer thread blocks automated approval" \ "- Problem: OpenCode reached an APPROVE control result, but the approval step found unresolved, non-outdated human or review-agent thread evidence on the current pull request." \ "- Root cause: Reviewer and review-agent feedback can arrive after bounded model evidence is prepared, so the approval step must re-query GitHub immediately before publishing an approval." \ "- Fix: Address or resolve the listed reviewer thread(s), then re-run OpenCode on the current head." \ @@ -5735,7 +5692,7 @@ jobs: "" \ "## Findings" \ "" \ - "### 1. HIGH .github/workflows/opencode-review.yml:1 - Review thread lookup could not be read before approval" \ + "### 1. HIGH Review process - Review thread lookup could not be read before approval" \ "- Problem: GitHub reviewThreads could not be read for the current pull request immediately before approval." \ "- Root cause: OpenCode cannot safely approve without verifying whether newer unresolved reviewer or review-agent feedback exists." \ "- Fix: Re-run OpenCode after GitHub reviewThreads are readable." \ @@ -5751,40 +5708,45 @@ jobs: build_coverage_evidence_check_failure_body() { local body_file="$1" - { - printf '%s\n' \ - "## Pull request overview" \ - "" \ - "OpenCode cannot approve yet because required coverage evidence did not pass." \ - "" \ - "## Review outcome" \ - "" \ - "### 1. HIGH .github/workflows/opencode-review.yml:1 - Coverage evidence did not prove required test/docstring evidence" \ - "- Problem: The required coverage-evidence job result was \`${COVERAGE_EVIDENCE_RESULT:-unknown}\`, so OpenCode cannot establish approval sufficiency for this head." \ - "- Root cause: Automated approval is only valid when the same-head coverage-evidence job proves supported repository test suites passed and configured docstring gates passed or were advisory, or reports not applicable because no supported source files or package manifests exist. Missing, failed, skipped, unavailable, or unsupported-tooling test evidence is a blocker." \ - "- Fix: Install or configure the repository test/docstring evidence tooling when source files or package manifests exist, rerun the current-head coverage-evidence job, and approve only after it reports \`success\` with required evidence or explicit no-source not-applicable evidence." \ - "- Regression test: Keep the approval branch checking \`needs.coverage-evidence.result == success\` before posting APPROVE, and publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present." \ - "" \ - "- Result: REQUEST_CHANGES" \ - "- Reason: coverage-evidence result was \`${COVERAGE_EVIDENCE_RESULT:-unknown}\`, so required test/docstring evidence was not proven for current head \`${HEAD_SHA}\`." \ - "- Head SHA: \`${HEAD_SHA}\`" \ - "- Workflow run: ${RUN_ID}" \ - "- Workflow attempt: ${RUN_ATTEMPT}" \ - "" \ - "## Coverage evidence" \ - "" - printf '%s\n' "${COVERAGE_EVIDENCE_SUMMARY:-Coverage evidence summary was unavailable.}" | sed -n '1,240p' - } >"$body_file" + python3 scripts/ci/opencode_review_surfaces.py build-status \ + --result "COVERAGE_BLOCKED" \ + --head-sha "$HEAD_SHA" \ + --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-unknown}" \ + --coverage-summary "${COVERAGE_EVIDENCE_SUMMARY:-Coverage evidence summary was unavailable.}" \ + >"$body_file" + } + + publish_fallback_diff_review() { + local body_file event + body_file="$(mktemp)" + event="COMMENT" + python3 scripts/ci/opencode_review_surfaces.py build-fallback-review \ + --changed-files-file "${OPENCODE_CHANGED_FILES_FILE}" \ + --source-root "${OPENCODE_SOURCE_WORKDIR}" \ + --head-sha "$HEAD_SHA" \ + --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" \ + --coverage-result "${COVERAGE_EVIDENCE_RESULT:-unknown}" \ + >"$body_file" + printf '\n%s\n\n%s\n' "## Review outcome" "Coverage is a gate, not the review. This body reviews the changed product files." >>"$body_file" + create_pull_review "$event" "$(cat "$body_file")" + # create_pull_review COMMENT rewrites the status comment to Gate + # result: COMMENT. Restore the coverage gate so a miss never looks + # finished; next action stays "fix coverage evidence, then rerun". + request_changes_for_coverage_evidence_failure + rm -f "$body_file" } request_changes_for_coverage_evidence_failure() { local body_file body_file="$(mktemp)" build_coverage_evidence_check_failure_body "$body_file" - create_pull_review "REQUEST_CHANGES" "$(cat "$body_file")" + printf '::notice::Coverage evidence did not pass (%s); approval is blocked. A source-backed review of changed product files is still published. record coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence in the status comment and refuse APPROVE while still publishing a source-backed review of changed product files.\n' \ + "${COVERAGE_EVIDENCE_RESULT:-unknown}" + update_review_overview "COVERAGE_BLOCKED" rm -f "$body_file" - echo "::endgroup::" - exit 0 } create_pull_review_with_payload() { @@ -5810,14 +5772,21 @@ jobs: return 1 fi if [ -s "$fallback_body_file" ]; then - update_review_overview "INLINE_COMMENT_PUBLISH_FAILED" "$(cat "$fallback_body_file")" + update_review_overview "INLINE_COMMENT_PUBLISH_FAILED" else - update_review_overview "INLINE_COMMENT_PUBLISH_FAILED" "$body" + update_review_overview "INLINE_COMMENT_PUBLISH_FAILED" fi return 1 fi + FORMAL_REVIEW_URL="$(jq -r --arg repo "$GH_REPOSITORY" --arg pr "$PR_NUMBER" ' + if (.id // 0) > 0 then + "https://github.com/\($repo)/pull/\($pr)#pullrequestreview-\(.id)" + else + empty + end + ' "$review_response_file")" rm -f "$gh_error_file" "$review_response_file" - update_review_overview "$event" "$body" + update_review_overview "$event" } request_changes_for_gate_failure() { @@ -5830,7 +5799,7 @@ jobs: "" \ "## Findings" \ "" \ - "### 1. HIGH .github/workflows/opencode-review.yml:1 - OpenCode review evidence was missing or invalid" \ + "### 1. HIGH Review process - OpenCode review evidence was missing or invalid" \ "- Problem: OpenCode review evidence was missing or invalid." \ "- Root cause: ${reason}" \ "- Fix: Re-run the OpenCode review after the current-head evidence and control block are available." \ @@ -5847,49 +5816,31 @@ jobs: format_request_changes_body() { local control_json="$1" local body_file="$2" - local summary + local model_body_file="${3:-}" + local findings_json_file local reason - local findings - local adversarial_evidence + local format_args - summary="$(jq -r '.summary // ""' "$control_json")" + findings_json_file="$(mktemp)" + jq -c '.findings // []' "$control_json" >"$findings_json_file" reason="$(jq -r '.reason // ""' "$control_json")" - adversarial_evidence="$(jq -c '.adversarial_validation' "$control_json")" - findings="$( - # shellcheck disable=SC2016 - jq -r ' - (.findings // []) - | to_entries - | map( - "### " + ((.key + 1) | tostring) + ". " + ((.value.severity // "severity") | ascii_upcase) + " " + (.value.path // "unknown") + ":" + ((.value.line // 0) | tostring) + " - " + (.value.title // "Finding") + "\n" - + "- Problem: " + (.value.problem // "") + "\n" - + "- Root cause: " + (.value.root_cause // "") + "\n" - + "- Fix: " + (.value.fix_direction // "") + "\n" - + "- Regression test: " + (.value.regression_test_direction // "") + "\n" - + "- Suggested diff: posted in this finding'\''s inline review thread." - ) - | join("\n\n") - ' "$control_json" - )" - if [ -z "$findings" ]; then - findings="OpenCode returned REQUEST_CHANGES without structured line-specific findings. Re-run the review after fixing the control payload." + format_args=( + --head-sha "$HEAD_SHA" + --run-id "$RUN_ID" + --run-attempt "$RUN_ATTEMPT" + --reason "$reason" + --findings-json-file "$findings_json_file" + ) + if [ -n "$model_body_file" ] && [ -s "$model_body_file" ]; then + format_args+=(--model-body-file "$model_body_file") fi - - { - printf '## Pull request overview\n\n' - printf 'OpenCode reviewed the current-head bounded evidence and requested changes before merge.\n\n' - printf '## Findings\n\n' - printf '%s\n\n' "$findings" - printf '## Summary\n\n' - printf '%s\n\n' "$summary" - printf '## Adversarial validation\n\n' - printf '```json\n%s\n```\n\n' "$adversarial_evidence" - printf -- '- Result: REQUEST_CHANGES\n' - printf -- '- Reason: %s\n\n' "$reason" - printf -- "- Head SHA: \`%s\`\n" "$HEAD_SHA" - printf -- '- Workflow run: %s\n' "$RUN_ID" - printf -- '- Workflow attempt: %s\n' "$RUN_ATTEMPT" - } >"$body_file" + if [ -n "${OPENCODE_CHANGED_FILES_FILE:-}" ] && [ -s "${OPENCODE_CHANGED_FILES_FILE}" ]; then + format_args+=(--changed-files-file "$OPENCODE_CHANGED_FILES_FILE") + fi + python3 scripts/ci/opencode_review_surfaces.py format-request-changes \ + "${format_args[@]}" \ + >"$body_file" + rm -f "$findings_json_file" } build_request_changes_review_payload() { @@ -5940,6 +5891,7 @@ jobs: publish_request_changes_from_control() { local control_json="$1" + local model_body_file="${2:-}" local body_file local payload_file local fallback_body_file @@ -5947,7 +5899,7 @@ jobs: body_file="$(mktemp)" payload_file="$(mktemp)" fallback_body_file="$(mktemp)" - format_request_changes_body "$control_json" "$body_file" + format_request_changes_body "$control_json" "$body_file" "$model_body_file" build_request_changes_review_payload "$control_json" "$body_file" "$payload_file" build_inline_comment_failure_body "$body_file" "$fallback_body_file" create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$body_file")" "$payload_file" "$fallback_body_file" @@ -6067,18 +6019,18 @@ jobs: } emit_known_missing_string_finding \ - "github.event.client_payload.strix_llm || 'openai/gpt-5'" \ - "Strix PR scans must default to GitHub Models GPT-5" \ + "github.event.client_payload.strix_llm || 'nvidia_nim/nvidia/nemotron-3-super-120b-a12b'" \ + "Strix PR scans must default to NVIDIA NIM Nemotron" \ ".github/workflows/strix.yml" \ "scripts/ci/test_strix_quick_gate.sh" emit_known_missing_string_finding \ - "STRIX_LLM must select GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model" \ + "STRIX_LLM must select NVIDIA NIM Nemotron, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model" \ "Strix unsupported-model errors must name the allowed providers" \ ".github/workflows/strix.yml" \ "scripts/ci/test_strix_quick_gate.sh" emit_known_missing_string_finding \ - "MODEL: github-models/deepseek/deepseek-v3-0324" \ - "OpenCode failed-check diagnosis must prefer DeepSeek V3" \ + "MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" \ + "OpenCode failed-check diagnosis must use NVIDIA NIM" \ ".github/workflows/opencode-review.yml" \ "scripts/ci/test_strix_quick_gate.sh" @@ -6317,12 +6269,11 @@ jobs: local evidence_file="$1" local source_root="${OPENCODE_SOURCE_WORKDIR:-${GITHUB_WORKSPACE:-$PWD}}" local diff_status + local base_prefix if self_healed_strix_dependency_base_failure "$evidence_file"; then return 0 fi - grep -Fq "Self-test Strix gate script" "$evidence_file" || return 1 - grep -Fq "opencode.jsonc: No such file or directory" "$evidence_file" || return 1 if [ -z "${PR_BASE_SHA:-}" ] || [ -z "${PR_HEAD_SHA:-}" ]; then return 1 fi @@ -6330,6 +6281,30 @@ jobs: ! git -C "$source_root" rev-parse --verify "${PR_HEAD_SHA}^{commit}" >/dev/null 2>&1; then return 1 fi + base_prefix="${PR_BASE_SHA:0:7}" + + # A pull_request_target Strix run executes the gate from the + # protected base. Authenticate that predecessor identity from the + # runner-owned checkout lines before treating its infrastructure + # failure as evidence about the old gate instead of PR source. + grep -Eq "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^[:space:]]+Z \[command\]/usr/bin/git checkout --progress --force ${PR_BASE_SHA}$" \ + "$evidence_file" || return 1 + grep -Eq "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^[:space:]]+Z HEAD is now at ${base_prefix}([[:space:]]|$)" \ + "$evidence_file" || return 1 + + # A real vulnerability report remains authoritative even when a + # PR also edits the trusted Strix gate. Never reclassify source + # findings as predecessor infrastructure evidence. + if grep -Eq "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^[:space:]]+Z .*Vulnerabilities[[:space:]]+[1-9][0-9]*([[:space:]]|$)|^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^[:space:]]+Z .*Severity:[[:space:]]*(CRITICAL|HIGH|MEDIUM|LOW)([[:space:]]|$)" \ + "$evidence_file"; then + return 1 + fi + + if ! grep -Eq "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^[:space:]]+Z (.*Strix run failed for model|.*emitted provider infrastructure or failure-signal output|.*LLM CONNECTION FAILED|.*Configured (model and fallback models|Vertex model and fallback models) were unavailable)" \ + "$evidence_file"; then + grep -Fq "Self-test Strix gate script" "$evidence_file" || return 1 + grep -Fq "opencode.jsonc: No such file or directory" "$evidence_file" || return 1 + fi set +e git -C "$source_root" diff --quiet "${PR_BASE_SHA}...${PR_HEAD_SHA}" -- \ @@ -6451,7 +6426,7 @@ jobs: printf 'Skipping publish-step failed-check OpenCode diagnosis for central review-process self-repair; using collected current-head failed-check logs/SARIF fallback so the publish step stays bounded.\n' >&2 return 1 fi - if [ -z "${STRIX_GITHUB_MODELS_TOKEN:-}" ]; then + if [ -z "${NVIDIA_NIM_API_KEY:-}" ]; then return 1 fi if ! python3 "$GITHUB_WORKSPACE/scripts/ci/assert_opencode_reasoning_effort.py" \ @@ -6478,15 +6453,15 @@ jobs: printf 'Bounded PR evidence:\n\n' sed -n '1,500p' "$OPENCODE_EVIDENCE_FILE" printf '\n\n\n' - printf 'First line exactly:\n' + printf 'Then, after the review body, one line exactly:\n' printf '\n' "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" printf 'Then exactly one control block:\n' printf '\n' - printf 'Do not include analysis, planning, tool-call narration, placeholders, or prose before the sentinel.\n' + printf 'Write the Verdict / Findings / Test Gaps review first, then append the sentinel and control JSON. Do not include analysis, planning, tool-call narration, placeholders, or prose that is not part of that review structure.\n' printf 'The JSON control block must be literal parseable JSON. The result must be REQUEST_CHANGES.\n' - printf 'Return only the review body.\n' + printf 'Return the review body, then the control block.\n' } >"$prompt_file" cd "$OPENCODE_REVIEW_WORKDIR" @@ -6524,7 +6499,7 @@ jobs: if [ "$gate_result" != "REQUEST_CHANGES" ]; then return 1 fi - format_request_changes_body "$control_json" "$body_file" + format_request_changes_body "$control_json" "$body_file" "$opencode_output_file" if [ -n "$review_payload_file" ]; then build_request_changes_review_payload "$control_json" "$body_file" "$review_payload_file" fi @@ -7606,10 +7581,6 @@ jobs: exit 0 fi - if [ "${COVERAGE_EVIDENCE_RESULT:-skipped}" != "success" ]; then - request_changes_for_coverage_evidence_failure - fi - opencode_review_outcome="${OPENCODE_MODEL_POOL_OUTCOME:-unknown}" printf 'OpenCode model-pool outcome=%s model=%s; publish stage performs no duplicate model-catalog pass.\n' \ "$opencode_review_outcome" "${OPENCODE_MODEL_POOL_MODEL:-none}" @@ -7622,6 +7593,11 @@ jobs: echo "::endgroup::" exit 0 fi + if [ "${COVERAGE_EVIDENCE_RESULT:-skipped}" != "success" ]; then + publish_fallback_diff_review + echo "::endgroup::" + exit 1 + fi stop_without_review_after_model_unavailable fi @@ -7709,7 +7685,22 @@ jobs: case "$gate_result" in APPROVE) if [ "${COVERAGE_EVIDENCE_RESULT:-skipped}" != "success" ]; then + if [ -s "$tmp_body" ]; then + model_prose_file="$(mktemp)" + python3 scripts/ci/opencode_review_surfaces.py extract-prose \ + --model-body-file "$tmp_body" >"$model_prose_file" + if [ -s "$model_prose_file" ]; then + create_pull_review "COMMENT" "$(cat "$model_prose_file")" + else + publish_fallback_diff_review + fi + rm -f "$model_prose_file" + else + publish_fallback_diff_review + fi request_changes_for_coverage_evidence_failure + echo "::endgroup::" + exit 1 fi if request_changes_for_merge_conflict_if_present; then echo "::endgroup::" @@ -7927,7 +7918,7 @@ jobs: exit 0 fi if scripts/ci/validate_opencode_failed_check_review.sh "$control_json" "$failed_checks_file" "$failed_check_evidence_file"; then - publish_request_changes_from_control "$control_json" + publish_request_changes_from_control "$control_json" "$tmp_body" elif run_failed_check_diagnosis "$failed_checks_file" "$failed_check_evidence_file" "$failed_check_review_body_file" "$failed_check_review_payload_file" "$failed_check_inline_failure_body_file"; then create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$failed_check_review_body_file")" "$failed_check_review_payload_file" "$failed_check_inline_failure_body_file" elif build_failed_check_fallback_body "$failed_checks_file" "$failed_check_evidence_file" "$failed_check_review_body_file"; then @@ -7936,7 +7927,7 @@ jobs: stop_failed_check_fallback_unavailable fi else - publish_request_changes_from_control "$control_json" + publish_request_changes_from_control "$control_json" "$tmp_body" fi ;; *) @@ -7982,11 +7973,20 @@ jobs: fi elif request_changes_for_merge_conflict_if_present; then : + elif [ "${COVERAGE_EVIDENCE_RESULT:-skipped}" != "success" ]; then + publish_fallback_diff_review + echo "::endgroup::" + exit 1 else stop_without_review_after_model_unavailable fi ;; esac + if [ "${COVERAGE_EVIDENCE_RESULT:-skipped}" != "success" ]; then + request_changes_for_coverage_evidence_failure + echo "::endgroup::" + exit 1 + fi echo "::endgroup::" - name: Publish repository_dispatch OpenCode status diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 7f1ad6d00..d96639fc3 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -53,7 +53,49 @@ jobs: name: opencode-review needs: [coverage-evidence] runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: - - run: >- - echo "Review approval remains a separate current-head PR review - requirement produced by the authenticated dispatch workflow." + - name: Verify current-head formal OpenCode review receipt + env: + GH_TOKEN: ${{ github.token }} + GH_PAGER: cat + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || '' }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + EVENT_ACTION: ${{ github.event.action }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + echo "Review approval remains a separate current-head PR review requirement produced by the authenticated dispatch workflow." + if [ "${EVENT_ACTION:-}" = "closed" ] || [ -z "${PR_NUMBER:-}" ]; then + echo "No open pull request receipt is required for this event." + exit 0 + fi + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$EXPECTED_HEAD" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$WORKFLOW_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Required OpenCode receipt gate rejected malformed live pull request or workflow identity." + exit 1 + fi + trusted_archive="${RUNNER_TEMP:-/tmp}/trusted-opencode-source.tar.gz" + api_url="${GITHUB_API_URL:-https://api.github.com}" + curl -fsSL \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + -o "$trusted_archive" \ + "${api_url}/repos/ContextualWisdomLab/.github/tarball/${WORKFLOW_SHA}" + tar -xzf "$trusted_archive" -C "${GITHUB_WORKSPACE:-.}" --strip-components=1 + test -f scripts/ci/opencode_review_receipt_gate.py + draft_args=() + if [ "${IS_DRAFT:-false}" = "true" ]; then + draft_args=(--draft) + fi + python3 scripts/ci/opencode_review_receipt_gate.py \ + --repo "$TARGET_REPOSITORY" \ + --pr-number "$PR_NUMBER" \ + --head-sha "$EXPECTED_HEAD" \ + "${draft_args[@]}" diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml index 00bbf2c81..55aba85de 100644 --- a/.github/workflows/osv-scanner-pr.yml +++ b/.github/workflows/osv-scanner-pr.yml @@ -1,7 +1,7 @@ -# Keeps the upstream OSV base/head diff check available on every PR. The -# central Security Scan workflow owns the blocking OSV result, finding logs, -# and SARIF upload so this supplemental check does not duplicate installation -# API calls or fail an otherwise clean PR when GitHub's upload quota is spent. +# Keeps a supplemental current-head OSV scan on every PR. The central Security +# Scan workflow owns the blocking OSV result, finding logs, and SARIF upload so +# this check does not duplicate installation API calls or fail an otherwise +# clean PR when GitHub rate-limits action downloads. name: OSV-Scanner PR on: @@ -18,9 +18,7 @@ concurrency: permissions: # Scorecard Token-Permissions (alert #41): keep the workflow-level token - # read-only. SARIF upload needs security-events:write, but the osv-scan job - # below already grants it at job scope, so it is redundant (and over-broad) - # here. + # read-only. This supplemental job never uploads SARIF. actions: read contents: read @@ -33,35 +31,40 @@ jobs: osv-scan: if: github.event.action != 'closed' - # ponytail: use upstream reusable PR workflow, don't hand-roll the diff scan - # Pinned to v2.3.8 + 1 commit (3a7550f) which gates the JSON job outputs - # behind the new `export-results` input (default false). v2.3.8 dumped the - # full old/new osv-scanner JSON into job outputs unconditionally, tripping - # GitHub's 1,048,576-byte job-outputs cap and failing the run. Same nested - # action pins as v2.3.8; only the Export step is now conditional. - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@3a7550f43ba5b58905a821ce3a0ed24c4858b3f4 # v2.3.8 + export-results gate + runs-on: ubuntu-latest permissions: actions: read contents: read - # The pinned upstream reusable workflow declares this permission at its - # top level, so GitHub validates it even when upload-sarif is false. - security-events: write - with: - # Keep the PR code-scanning upload deterministic: direct manifest - # vulnerabilities are uploaded, but public registry rate limits cannot - # make the required upload check fail before SARIF reaches GitHub. - # The security-scan workflow still performs the full base/head OSV pass - # first and logs its --no-resolve fallback reason when registries are - # transiently unavailable. - scan-args: |- - --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 - --no-resolve - -r - ./ - # The required central security-scan.yml job uploads the comprehensive - # current-head OSV SARIF. Avoid a second upload through the reusable - # workflow because installation rate-limit failures are not findings. - upload-sarif: false - # Merge gating is done by central security-scan.yml with - # --fail-on-vuln=true after printing package, version, OSV ID and aliases. - fail-on-vuln: false + steps: + - name: Checkout current PR head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Local scanner pin matches security-scan.yml. Do not call the upstream + # reusable PR workflow: it downloads the reporter action at job setup + # even when SARIF upload is disabled, and a GitHub 429 then fails this + # supplemental check before a non-blocking scan setting can apply. + - name: Scan current head with OSV + id: osv_head + continue-on-error: true + uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + with: + # Keep the PR scan deterministic: public registry rate limits cannot + # make this supplemental check fail. The security-scan workflow still + # performs the full base/head OSV pass first and logs its --no-resolve + # fallback reason when registries are transiently unavailable. + scan-args: |- + --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 + --no-resolve + -r + ./ + + - name: Defer merge gating to central Security Scan + run: | + set -euo pipefail + if [ "${{ steps.osv_head.outcome }}" = "failure" ]; then + echo "::warning::Supplemental OSV PR scan did not finish. Merge gating stays on security-scan.yml with --fail-on-vuln=true after printing package, version, OSV ID and aliases. Action-download or registry rate limits are not findings." + else + echo "Supplemental OSV PR scan finished. Merge gating stays on security-scan.yml." + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 312d6d33b..0f10e6372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Semantic Versioning where the repository publishes a release. ## [Unreleased] +- Consolidate the already-supported `openai_direct/` and + `openai-direct/` fallback aliases into one normalization arm in + `child_model_for_api_base`. Both predecessor arms already emitted the same + `openai/` child identifier; this is behavior-preserving cleanup that + keeps the two accepted spellings synchronized. - Honor each trusted base project's exact, integrity-bearing pnpm `packageManager` specification in OpenCode coverage images through the pinned Node distribution's Corepack runtime, instead of admitting the specification @@ -52,6 +57,8 @@ Semantic Versioning where the repository publishes a release. ### Changed +- Split central OpenCode publication into distinct surfaces: the formal pull-request review is a source-backed walkthrough of the actual diff, and the issue comment is gate/status only (head SHA, run id/attempt, coverage result, model-pool outcome, verdict, and a link to the formal review). Coverage-evidence failure no longer replaces the review or cites `.github/workflows/opencode-review.yml:1` on a product repository that did not change that file. The model pool still reviews the diff when coverage fails; REQUEST_CHANGES keeps model prose plus structured findings. +- Raise NVIDIA NIM and matching central-review run timeouts from 180s/5400s to 7200s (combined NIM budget also 7200s so one two-hour NIM attempt cannot stack seven times), raise the dynamic run-timeout cap to 7200s, and keep free-tier at 3600s. GitHub Models is removed from `opencode.jsonc` and the isolated OpenCode review catalog: no `github-models` review provider, no GPT-5 45s review path, and no review fallback when `NVIDIA_NIM_API_KEY` is unset. NIM-direct remains the OpenCode default; dispatch may attach one optional ContextualWisdomLab/contextual-orchestrator provider when `CONTEXTUAL_ORCHESTRATOR_URL` is set, without starting the sidecar or adding a GitHub Models review fallback. Strix retains protected main's independently governed, authenticated multi-provider fail-closed contract. PR-number concurrency and `cancel-in-progress: true` are unchanged. - Emit completed repository pull-list requests as they finish in the five-minute agent-mention sweep, while retaining the four-worker ceiling, rotation, and exact-name dispatch ledger, so one slow repository cannot hide ready sibling diff --git a/ci-review-prompt.md b/ci-review-prompt.md index ad4c54ba4..d1ae8de03 100644 --- a/ci-review-prompt.md +++ b/ci-review-prompt.md @@ -118,6 +118,15 @@ green checks are not runtime-tool receipts. When no receipt exists, describe only the source trace or explicit execution limitation; fabricating browser or DevTools evidence invalidates the entire control block. +The formal review must name the actual changed files and what they do, +include file/line findings on the current-head diff or an explicit APPROVE +with a real walkthrough, and draw a useful sequence/class/state diagram of +the changed API rather than a generic `Changed file (N files)` inventory. +Coverage is a gate, not the review: cite coverage evidence in the status +surface and never replace the product-file walkthrough with a coverage +blocker. Never cite `.github/workflows/opencode-review.yml:1` unless that +file is in the current-head diff. + Review the diff first, then inspect surrounding code only when needed to understand impact. Evaluate correctness, API compatibility, security/privacy, data integrity, concurrency, error handling, observability, performance, @@ -203,8 +212,48 @@ relevant source location, concrete evidence, impact, remediation, and suggested verification. If no material issue exists, approve instead of manufacturing comments. +Write the human-readable review first in this structure, then append the +sentinel and exactly one `opencode-review-control-v1` control block. Do not +include analysis, planning, tool-call narration, or placeholders that are not +part of this review structure. + +```markdown +## Verdict + +APPROVE | APPROVE_WITH_NITS | REQUEST_CHANGES | COMMENT | NEEDS_INFO + +- **Confidence:** High | Medium | Low +- **Scope reviewed:** short summary of files/areas inspected +- **Commands run:** commands and brief results, or `None` +- **Risk profile:** Low | Medium | High, with one short reason + +## Findings + +No material issues found in the reviewed diff. +``` + +For each finding: + +```markdown +### [P0/P1/P2/P3/Nit/FYI] Short title + +- **Location:** `path/to/file.ext:line` +- **Evidence:** What in the code or command output supports this +- **Impact:** What can go wrong +- **Recommendation:** Concrete fix or direction +- **Suggested verification:** Test, command, or scenario confirming the fix +``` + +Then: + +```markdown +## Test Gaps + +No significant test gaps identified. +``` + The final OpenCode output must still satisfy the existing `opencode-review-control-v1` JSON contract required by the approval gate. Use -the reviewer rubric above for analysis and human-readable review quality, but -return the sentinel and control block exactly as requested by the workflow +the reviewer rubric above for analysis and human-readable review quality, then +append the sentinel and control block exactly as requested by the workflow prompt, including the mandatory structured `adversarial_validation` evidence. diff --git a/code-reviewer-prompt.md b/code-reviewer-prompt.md index 9daf0c913..775af74f0 100644 --- a/code-reviewer-prompt.md +++ b/code-reviewer-prompt.md @@ -240,3 +240,7 @@ No open questions. Use Korean by default for human-facing prose. Keep code identifiers, file paths, commands, error messages, and API names in their original language. + +When this prompt is used from CI, write the Verdict / Findings / Test Gaps +review first, then append the workflow sentinel and `opencode-review-control-v1` +JSON. Do not omit the human review body in favor of control JSON alone. diff --git a/docs/doctoring/opencode-contextual-orchestrator-sidecar.md b/docs/doctoring/opencode-contextual-orchestrator-sidecar.md new file mode 100644 index 000000000..51631509a --- /dev/null +++ b/docs/doctoring/opencode-contextual-orchestrator-sidecar.md @@ -0,0 +1,38 @@ +# OpenCode → contextual-orchestrator sidecar (next step) + +검토 기준일: **2026-08-17** + +## Decision + +GitHub Models stays unused. The intended long-term OpenCode provider is +ContextualWisdomLab/contextual-orchestrator, an OpenAI-compatible +`/v1/chat/completions` hub. Until that sidecar exists, central review keeps +**NIM-direct** as the default (`NVIDIA_NIM_API_KEY` → `NVIDIA_API_KEY`). +`COPILOT_GITHUB_TOKEN` is not introduced. + +This pull request does not start the sidecar and does not block OriginWeave +#47 quality fixes or the 7200s NIM timeout on it. + +## Optional path already in dispatch + +If `vars.CONTEXTUAL_ORCHESTRATOR_URL` is set, +`scripts/ci/attach_contextual_orchestrator_provider.py` attaches one +OpenAI-compatible `contextual-orchestrator` provider block to the isolated +catalog. The helper fails closed on GitHub Models hosts, embedded +credentials, non-http(s) URLs, and non-loopback `http`. Unset URL is a +no-op. Default `model` / `small_model` and `OPENCODE_MODEL_CANDIDATES` +stay NIM-direct. + +## Next step (do not do it in this PR) + +1. The review job starts a ContextualWisdomLab/contextual-orchestrator sidecar. +2. The sidecar registers these five organization secrets into its KV: + NIM, NIM_SUB, OpenAI, OpenRouter, and Bytez. +3. OpenCode talks only to that sidecar URL. It does not receive the five + upstream secrets and does not fall back to GitHub Models. + +## References + +ContextualWisdomLab/contextual-orchestrator is the org LLM routing hub +(LiteLLM-plus). See [`docs/CWL-MASTER-CONTEXT.md`](../CWL-MASTER-CONTEXT.md) +§3 and [`docs/nvidia-nim-opencode-hotfix.md`](../nvidia-nim-opencode-hotfix.md). diff --git a/docs/doctoring/opencode-review-surfaces-originweave-47.md b/docs/doctoring/opencode-review-surfaces-originweave-47.md new file mode 100644 index 000000000..19b3ec85f --- /dev/null +++ b/docs/doctoring/opencode-review-surfaces-originweave-47.md @@ -0,0 +1,118 @@ +# OpenCode review surfaces and OriginWeave coverage sandbox + +검토 기준일: **2026-08-16** + +## Incident + +ContextualWisdomLab/OriginWeave#47, head +`79cf275686e2376a51783a2d03128eca21e7c0e5`, workflow run `31951179896`, +published the same body as both the formal pull-request review and the issue +comment: a generic overview plus one HIGH finding on +`.github/workflows/opencode-review.yml:1` saying coverage-evidence failed. The +pull request actually changed +`crates/originweave-destination/src/lib.rs`, `resolution.rs`, and +`tests/resolution_freshness.rs` (FreshResolutionSnapshot / DNS-rebinding +TOCTOU). The mermaid inventory said `Changed file (3 files)` because unknown +paths, including `crates/`, were bucketed as "Changed file". Repository CI on +that head passed. The central isolated coverage job failed and replaced the +entire review. + +## Root cause + +When `needs.coverage-evidence.result != success`, the publisher synthesized +`REQUEST_CHANGES`, posted it with `gh pr review` and again as an issue comment, +and exited before the model pool could review the diff. The coverage sandbox +false blocker was Debian rustc 1.85 plus cargo-llvm-cov 0.8.7 without +`llvm-tools-preview`, in a `--network=none` image, against a workspace that +declares `rust-version = "1.97"` and `edition = "2024"`. The job log was +`failed to find llvm-tools-preview`. The default 100% line threshold was not +the false blocker: OriginWeave also requires 100% in repository CI. + +## Decision + +Coverage remains a fail-closed gate. It is no longer the review. + +1. The formal pull-request review is a source-backed walkthrough of the + current-head product diff, including a fallback review that names the + changed crate files when the model pool did not emit a control block. +2. The issue comment is gate/status only: head SHA, run id/attempt, coverage + result, model-pool outcome, verdict, and a link to the formal review. It + must not repeat `## Pull request overview`, `## Findings`, mermaid, or the + model walkthrough. +3. A coverage miss, skip, or unsupported-tooling result blocks approval and + fails the required review job after the diff review is published. It must + not cite `.github/workflows/opencode-review.yml:1` unless that file is in + the pull-request diff. +4. The trusted coverage image materializes bounded `Cargo.toml` / + `Cargo.lock` / `rust-toolchain.toml` / workspace member manifests, installs + the declared rustup channel with `llvm-tools-preview` when it is newer than + Debian rustc 1.85, prefetches the lockfile, and runs + `cargo llvm-cov --offline --locked`. Repos that ship + `scripts/ci/verify_coverage.py` without + `workspace.metadata.opencode.coverage` run that verifier instead of the + canned `--fail-under-lines 100` default. rustc/cargo identity and the full + rust/python/js measure log are published in `coverage_summary`. A real + coverage miss still fails. +5. Coverage-evidence failure is injected into `bounded-review-evidence.md` as + a `## Coverage gate` section. The model pool still runs. The publisher does + not early-return before the model path. `format_request_changes_body` keeps + model walkthrough/diagrams and appends structured findings. + +Read-only review-agent permissions, NVIDIA NIM-first routing +(`NVIDIA_NIM_API_KEY` bound into `NVIDIA_API_KEY`), OpenCode CLI 1.17.13, and +the existing review-bot identity are unchanged. `COPILOT_GITHUB_TOKEN` is not +introduced. The same dispatch file now gives NIM (and matching cadence / +dynamic-cap) a 7200s run window instead of the 180s kill that skipped +reviews on ContextualWisdomLab/fast-mlsirm#290, keeps free-tier short, +and removes GitHub Models entirely. If `NVIDIA_NIM_API_KEY` is +unset, the pool and Strix fail closed instead of falling through to +GitHub Models or Luna. Concurrency remains PR-number scoped with +`cancel-in-progress: true`. NIM-direct remains the default until a later +change starts the ContextualWisdomLab/contextual-orchestrator sidecar; +see [`opencode-contextual-orchestrator-sidecar.md`](opencode-contextual-orchestrator-sidecar.md). + +## Verification contract + +Regression tests prove that: + +1. the formal review body is not equal to the status comment; +2. a coverage-gate failure still produces a review that names the changed + crate files; +3. no finding is anchored to `opencode-review.yml:1` unless that file is in + the diff; +4. mermaid labels a `crates/...` change as a Rust crate surface, not + `Changed file (3 files)`; +5. the publisher function + `request_changes_for_coverage_evidence_failure` updates the status comment + and does not call `create_pull_review`; +6. the model pool still runs when coverage-evidence failed (`!= cancelled`); +7. bounded Rust toolchain materialization copies manifests only, selects + rustup 1.97 for OriginWeave-style workspaces, and rejects parent-directory + members and symlinks; and +8. a rust-version 1.97 workspace without opencode coverage metadata does not + publish the canned coverage review as the entire PR review. Repos that + ship `scripts/ci/verify_coverage.py` use that verifier instead of default + `--fail-under-lines 100`; +9. `publish_fallback_diff_review` restores `COVERAGE_BLOCKED` on the status + comment after the COMMENT product-file review, so a coverage miss never + looks finished as `Gate result: COMMENT`; and +10. mermaid class diagrams list extracted public Rust API names only and do + not invent a `FirstType --> SecondType` class edge. + +## Limitations + +A later rustup or llvm-tools catalog change can still fail the image build. +That failure remains a coverage-gate failure, not a synthesized product-file +finding. The sandbox does not weaken a genuine below-threshold coverage miss. + +## References + +GitHub, Inc. (2026). *REST API endpoints for pull request reviews*. GitHub +Docs. +https://docs.github.com/en/rest/pulls/reviews + +Rust Project Developers. (2026). *The rustup book*. Rust Project. +https://rust-lang.github.io/rustup/ + +Taiki Endo. (2026). *cargo-llvm-cov*. GitHub. +https://github.com/taiki-e/cargo-llvm-cov diff --git a/docs/nvidia-nim-opencode-hotfix.md b/docs/nvidia-nim-opencode-hotfix.md index df8c193b2..58c188179 100644 --- a/docs/nvidia-nim-opencode-hotfix.md +++ b/docs/nvidia-nim-opencode-hotfix.md @@ -6,20 +6,23 @@ OpenCode Agent failed to produce a usable review on the PR thread starting at ContextualWisdomLab/fast-mlsirm#290 (`opencode-review` check **skipped**, no `opencode-agent[bot]` review comment). Central review therefore prioritizes **NVIDIA NIM** models as additional catalog candidates so the model pool can -still emit APPROVE / REQUEST_CHANGES when GitHub Models / free tiers stall. +still emit APPROVE / REQUEST_CHANGES when a hosted NIM session can complete. ## Changes 1. `opencode.jsonc` - - `enabled_providers`: `nvidia-nim` first, then `github-models` - - default `model` / `small_model` prefer NIM Nemotron / Llama 3.3 - - new OpenAI-compatible provider `nvidia-nim` → `https://integrate.api.nvidia.com/v1` + - `enabled_providers`: `nvidia-nim` only + - default `model` / `small_model` are NIM Nemotron / Llama 3.3 + - OpenAI-compatible provider `nvidia-nim` → `https://integrate.api.nvidia.com/v1` with `apiKey: {env:NVIDIA_API_KEY}` + - no `github-models` provider and no `STRIX_GITHUB_MODELS_TOKEN` 2. `.github/workflows/opencode-review-dispatch.yml` - - `OPENCODE_MODEL_CANDIDATES` prefixes six NIM models before existing pool - - binds `NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}` + - `OPENCODE_MODEL_CANDIDATES` is NIM-first; GitHub Models and Terra are omitted + - binds `NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}` + - if `NVIDIA_NIM_API_KEY` is unset the pool fails closed 3. `scripts/ci/run_opencode_review_model_pool.sh` - - skips `nvidia-nim/*` when `NVIDIA_API_KEY` is unset (same pattern as OpenRouter) + - if NIM candidates are configured and `NVIDIA_NIM_API_KEY` is unset, fail + closed instead of falling through to GitHub Models ## Temporary permission bypass (hotfix only) @@ -31,23 +34,46 @@ For this merge-aid hotfix only: CodeQL gates. - **Do not** flip OpenCode agent `permission.edit` / `bash` from `deny` to `allow` permanently; review agents remain read-only. -- Org secret `NVIDIA_API_KEY` must be set on ContextualWisdomLab for NIM pool - entries to execute; without it the pool falls through to prior candidates. +- Org secret `NVIDIA_NIM_API_KEY` must be set on ContextualWisdomLab for NIM + review-pool entries to execute; without it the OpenCode pool fails closed. ## Rollback -Remove the `nvidia-nim/*` prefixes from `OPENCODE_MODEL_CANDIDATES`, drop the -`nvidia-nim` provider block, and delete this note once GitHub Models / OpenCode -catalog reliability is restored. +Remove the `nvidia-nim/*` prefixes from `OPENCODE_MODEL_CANDIDATES` only if a +later policy names a different required review provider. Do not restore GitHub +Models to the OpenCode review catalog. Delete this note once the NIM-only +review catalog is the standing contract. ## Secret name Org secret is **`NVIDIA_NIM_API_KEY`**. Workflows bind it to process env `NVIDIA_API_KEY` -(fallback: `secrets.NVIDIA_API_KEY` if present) so `opencode.jsonc` `{env:NVIDIA_API_KEY}` resolves. +with no `secrets.NVIDIA_API_KEY` fallback so `opencode.jsonc` `{env:NVIDIA_API_KEY}` resolves. -## Large-repo OpenCode timeouts (~1 hour) +## Large-repo OpenCode timeouts (NIM ≥7200s) -Primary/default run timeouts and the dynamic queue timeout cap default to -**3600s** (hour-class) so large repositories are not cut off by the old 600s -default when env is unset. Free-tier failover remains capped at 600s. -Workflow-provided values (e.g. 5400s) still win over defaults. +The 180s NIM per-candidate timeout killed NVIDIA sessions in three minutes +and skipped the review (ContextualWisdomLab/fast-mlsirm#290). Central +dispatch now sets: + +- `OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS` and + `OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS` to **7200** (one two-hour NIM + attempt, then skip remaining NIM so seven 7200s candidates cannot stack) +- generic / cadence / dynamic-cap / central-fallback run timeouts to **7200** +- free-tier at **3600s** (unchanged short cap; no GitHub Models GPT-5 path) + +GitHub Models is removed from the OpenCode review catalog. If +`NVIDIA_NIM_API_KEY` is unset, OpenCode fails closed (skip / +REQUEST_CHANGES / status) instead of falling through to GitHub Models or +Luna. Strix remains a separately governed protected-main contract and keeps +its authenticated multi-provider fail-closed fallback policy. Concurrency +stays PR-number scoped with `cancel-in-progress: true`; pool max cycles and +attempts stay at 1 so the dispatch queue does not multiply unbounded parallel +two-hour jobs. + +## Next provider: contextual-orchestrator + +NIM-direct is the current default. The long-term OpenCode provider is +ContextualWisdomLab/contextual-orchestrator. Dispatch may attach one +optional provider block when `CONTEXTUAL_ORCHESTRATOR_URL` is set; it +does not start the sidecar and never falls back to GitHub Models. See +[`docs/doctoring/opencode-contextual-orchestrator-sidecar.md`](doctoring/opencode-contextual-orchestrator-sidecar.md). diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 3d2e1ac61..3b466581c 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -40,7 +40,7 @@ The central `.github/workflows/opencode-review.yml` is now part of the active or - Trusted source: `ContextualWisdomLab/.github` - PR-head handling: authenticated current-head `repository_dispatch` runs `.github/workflows/opencode-review-dispatch.yml` from the protected default branch; that workflow owns metadata validation, bounded coverage, source-as-data inspection, model review, and publication - Manual target support: the central scheduler sends exact repository, PR, base, and head metadata through `repository_dispatch`; the dispatch workflow rejects an unauthorized actor, an unallowlisted base repository, a malformed head-repository identity, or any live base/head metadata mismatch. A canonical fork head remains reviewable as untrusted source data. -- Model token posture: use the organization `STRIX_GITHUB_MODELS_TOKEN` secret for GitHub Models calls, with `github.token` as the fallback; live workflow evidence showed `github.token` alone can return 403 from `models.github.ai/inference` +- Model token posture: use the organization `NVIDIA_NIM_API_KEY` secret only. Workflows bind it to process env `NVIDIA_API_KEY`. GitHub Models is not used; if the NIM secret is unset, OpenCode and Strix fail closed instead of falling through to another provider. NIM-direct is the current default. An optional `CONTEXTUAL_ORCHESTRATOR_URL` may attach [`ContextualWisdomLab/contextual-orchestrator`](https://github.com/ContextualWisdomLab/contextual-orchestrator) later; this rollout does not start that sidecar. - Write posture: OpenCode may create review/comment side effects through the OpenCode app token when available; the workflow token is limited to the same-repository PR context and publication failures remain visible - Coverage execution posture: PR-controlled package, test, build, R, Rust, and Docker inputs are never executed from `pull_request_target`; the dispatch workflow runs bounded low-privilege coverage only after exact live metadata and scheduler identity validation - Fork posture: PR heads are fetched through `refs/pull//head` when direct head-SHA fetch is not available, so review can inspect fork PR source as data without executing it in the trusted workflow context diff --git a/opencode.jsonc b/opencode.jsonc index 3429b88a3..f5aeabe80 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -5,7 +5,7 @@ // first (see the "contextual-orchestrator" provider block below). "model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", "small_model": "nvidia-nim/meta/llama-3.3-70b-instruct", - "enabled_providers": ["nvidia-nim", "github-models", "contextual-orchestrator"], + "enabled_providers": ["nvidia-nim"], "lsp": false, "mcp": {}, "permission": { @@ -82,209 +82,6 @@ } }, "provider": { - "github-models": { - "npm": "@ai-sdk/openai-compatible", - "name": "GitHub Models", - "options": { - "baseURL": "https://models.github.ai/inference", - "apiKey": "{env:STRIX_GITHUB_MODELS_TOKEN}" - }, - "models": { - "openai/gpt-4.1": { - "name": "OpenAI GPT-4.1", - "tool_call": true, - "limit": { - "context": 1048576, - "output": 32768 - } - }, - "openai/gpt-5": { - "name": "OpenAI GPT-5", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-chat": { - "name": "OpenAI GPT-5 Chat", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-mini": { - "name": "OpenAI GPT-5 Mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/gpt-5-nano": { - "name": "OpenAI GPT-5 Nano", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "deepseek/deepseek-r1": { - "name": "DeepSeek R1", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "deepseek/deepseek-r1-0528": { - "name": "DeepSeek R1 0528", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "deepseek/deepseek-v3-0324": { - "name": "DeepSeek V3 0324", - "tool_call": true, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "openai/o3": { - "name": "OpenAI o3", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/o3-mini": { - "name": "OpenAI o3-mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "openai/o4-mini": { - "name": "OpenAI o4-mini", - "tool_call": true, - "reasoning": true, - "options": { - "reasoningEffort": "high" - }, - "variants": { - "high": { - "reasoningEffort": "high" - } - }, - "limit": { - "context": 200000, - "output": 100000 - } - }, - "mistral-ai/mistral-medium-2505": { - "name": "Mistral Medium 3 25.05", - "tool_call": true, - "limit": { - "context": 128000, - "output": 4096 - } - }, - "meta/llama-4-maverick-17b-128e-instruct-fp8": { - "name": "Llama 4 Maverick 17B 128E Instruct FP8", - "tool_call": true, - "limit": { - "context": 1000000, - "output": 4096 - } - }, - "meta/llama-4-scout-17b-16e-instruct": { - "name": "Llama 4 Scout 17B 16E Instruct", - "tool_call": true, - "limit": { - "context": 1000000, - "output": 4096 - } - } - } - }, "nvidia-nim": { "npm": "@ai-sdk/openai-compatible", "name": "NVIDIA NIM", diff --git a/scripts/ci/assert_opencode_reasoning_effort.py b/scripts/ci/assert_opencode_reasoning_effort.py index 82079d511..b10540ade 100644 --- a/scripts/ci/assert_opencode_reasoning_effort.py +++ b/scripts/ci/assert_opencode_reasoning_effort.py @@ -102,7 +102,7 @@ def validate_candidate(config: dict[str, Any], candidate: str) -> list[str]: return [str(exc)] if not config_for_model: - if provider == "github-models" or is_known_reasoning_capable(model_name): + if provider in {"github-models", "nvidia-nim"} or is_known_reasoning_capable(model_name): return [ f"OpenCode candidate {candidate} is not defined in opencode.jsonc " f"under provider {provider}." diff --git a/scripts/ci/attach_contextual_orchestrator_provider.py b/scripts/ci/attach_contextual_orchestrator_provider.py new file mode 100644 index 000000000..b96eefefa --- /dev/null +++ b/scripts/ci/attach_contextual_orchestrator_provider.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Optionally attach ContextualWisdomLab/contextual-orchestrator to OpenCode config. + +NIM-direct remains the default. This helper is a no-op unless +``CONTEXTUAL_ORCHESTRATOR_URL`` is set. It never adds GitHub Models. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +from typing import Any +from urllib.parse import urlparse + + +PROVIDER_NAME = "contextual-orchestrator" +FORBIDDEN_HOST_MARKERS = ( + "models.github.ai", + "github-models", + "models.github.com", +) +LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"} + + +def load_config(path: Path) -> dict[str, Any]: + """Load one isolated OpenCode JSON config.""" + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit(f"OpenCode config not found: {path}") from None + except json.JSONDecodeError as exc: + raise SystemExit(f"OpenCode config is not valid JSON: {path}: {exc}") from None + if not isinstance(loaded, dict): + raise SystemExit(f"OpenCode config root must be an object: {path}") + return loaded + + +def normalize_orchestrator_url(raw_url: str) -> str | None: + """Return a usable orchestrator base URL, or None when the env is unset.""" + stripped = raw_url.strip() + if not stripped: + return None + parsed = urlparse(stripped) + if parsed.scheme not in {"http", "https"}: + raise SystemExit( + "CONTEXTUAL_ORCHESTRATOR_URL must be an http(s) OpenAI-compatible " + "base URL; refusing to attach the orchestrator provider." + ) + if parsed.username or parsed.password: + raise SystemExit( + "CONTEXTUAL_ORCHESTRATOR_URL must not embed credentials; " + "refusing to attach the orchestrator provider." + ) + host = (parsed.hostname or "").casefold() + if not host: + raise SystemExit( + "CONTEXTUAL_ORCHESTRATOR_URL is missing a host; refusing to attach " + "the orchestrator provider." + ) + if any(marker in stripped.casefold() or marker in host for marker in FORBIDDEN_HOST_MARKERS): + raise SystemExit( + "CONTEXTUAL_ORCHESTRATOR_URL must not point at GitHub Models; " + "refusing to attach the orchestrator provider." + ) + if parsed.scheme == "http" and host not in LOOPBACK_HOSTS: + raise SystemExit( + "CONTEXTUAL_ORCHESTRATOR_URL may use http only for a loopback " + "sidecar; refusing to attach the orchestrator provider." + ) + return stripped.rstrip("/") + + +def attach_orchestrator_provider( + config: dict[str, Any], orchestrator_url: str +) -> dict[str, Any]: + """Attach one OpenAI-compatible orchestrator provider without changing NIM defaults.""" + providers = config.setdefault("provider", {}) + if not isinstance(providers, dict): + raise SystemExit("OpenCode config provider map must be an object.") + if "github-models" in providers: + raise SystemExit( + "OpenCode config still names github-models; refusing to attach " + "the orchestrator provider." + ) + enabled = list(config.get("enabled_providers") or []) + if "nvidia-nim" not in enabled: + raise SystemExit( + "OpenCode config must keep nvidia-nim enabled; refusing to attach " + "the orchestrator provider." + ) + providers[PROVIDER_NAME] = { + "npm": "@ai-sdk/openai-compatible", + "name": "Contextual Orchestrator", + "options": { + "baseURL": orchestrator_url, + }, + } + if PROVIDER_NAME not in enabled: + enabled.append(PROVIDER_NAME) + config["enabled_providers"] = enabled + config["provider"] = providers + return config + + +def main(argv: list[str] | None = None) -> int: + """Attach the orchestrator provider when CONTEXTUAL_ORCHESTRATOR_URL is set.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("config", type=Path) + args = parser.parse_args(argv) + raw_url = os.environ.get("CONTEXTUAL_ORCHESTRATOR_URL", "") + try: + orchestrator_url = normalize_orchestrator_url(raw_url) + except SystemExit as exc: + print(exc, file=sys.stderr) + return 1 + if orchestrator_url is None: + print("Contextual orchestrator URL unset; keeping NIM-direct OpenCode defaults.") + return 0 + try: + config = load_config(args.config) + attach_orchestrator_provider(config, orchestrator_url) + except SystemExit as exc: + print(exc, file=sys.stderr) + return 1 + args.config.write_text( + json.dumps(config, indent=2, ensure_ascii=True) + "\n", + encoding="utf-8", + ) + print( + "Attached contextual-orchestrator provider at " + f"{orchestrator_url}; NIM-direct remains the default model." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh index 5637cb861..ac005ac8f 100755 --- a/scripts/ci/emit_opencode_failed_check_fallback_findings.sh +++ b/scripts/ci/emit_opencode_failed_check_fallback_findings.sh @@ -956,20 +956,20 @@ extract_strix_failed_check_block "$EVIDENCE_FILE" "$strix_evidence_file" emit_known_missing_string_finding \ "$EVIDENCE_FILE" \ - "steps.target_visibility.outputs.is_private == 'false' && 'nvidia_nim/nvidia/nemotron-3-super-120b-a12b' || 'gpt-5.4'" \ - "Strix public scans must default to NVIDIA NIM while private scans retain the contracted provider" \ + "github.event.client_payload.strix_llm || (steps.target_visibility.outputs.is_private == 'false' && 'nvidia_nim/nvidia/nemotron-3-super-120b-a12b' || 'gpt-5.4')" \ + "Strix PR scans must default to NVIDIA NIM Nemotron" \ ".github/workflows/strix.yml" \ "scripts/ci/test_strix_quick_gate.sh" emit_known_missing_string_finding \ "$EVIDENCE_FILE" \ - "STRIX_LLM must select NVIDIA NIM Nemotron, GitHub Models openai/gpt-5 or newer, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model" \ + "STRIX_LLM must select NVIDIA NIM Nemotron, direct OpenAI GPT-5.4 or newer, OpenRouter openrouter/free, or an approved organization Vertex AI model" \ "Strix unsupported-model errors must name the allowed providers" \ ".github/workflows/strix.yml" \ "scripts/ci/test_strix_quick_gate.sh" emit_known_missing_string_finding \ "$EVIDENCE_FILE" \ - "MODEL: github-models/openai/gpt-5" \ - "OpenCode review must try GitHub Models GPT-5 first" \ + "MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" \ + "OpenCode failed-check diagnosis must use NVIDIA NIM" \ ".github/workflows/opencode-review.yml" \ "scripts/ci/test_strix_quick_gate.sh" emit_known_unexpected_string_finding \ diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py old mode 100755 new mode 100644 index a05212354..f13f962b2 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -471,10 +471,13 @@ def _run_uv_export( """Run ``uv export`` for a reconstructed base project and return the result. ``--frozen`` forbids lock mutation and ``--offline`` forbids network access. - A minimal environment and ephemeral cache/config/home directories prevent - runner-level configuration, dotenv files, Python downloads, or persistent - cache state from selecting export behavior. Project metadata discovery stays - enabled so the reconstructed ``pyproject.toml`` remains authoritative. + ``--all-extras`` includes test/runtime dependencies declared as project + extras; otherwise a valid lock can install successfully while pytest cannot + import the governed project. A minimal environment and ephemeral + cache/config/home directories prevent runner-level configuration, dotenv + files, Python downloads, or persistent cache state from selecting export + behavior. Project metadata discovery stays enabled so the reconstructed + ``pyproject.toml`` remains authoritative. """ return subprocess.run( [ @@ -486,6 +489,7 @@ def _run_uv_export( "--no-progress", "--color", "never", + "--all-extras", "--no-emit-project", "--no-editable", "--format", diff --git a/scripts/ci/materialize_base_rust_toolchain.py b/scripts/ci/materialize_base_rust_toolchain.py new file mode 100644 index 000000000..149860368 --- /dev/null +++ b/scripts/ci/materialize_base_rust_toolchain.py @@ -0,0 +1,376 @@ +"""Materialize bounded Rust inputs from a validated pull-request base commit. + +The isolated coverage sandbox is networkless and previously used Debian rustc +1.85 without ``llvm-tools-preview``. OriginWeave-style workspaces declare +``rust-version = "1.97"`` and ``edition = "2024"``, so the image must install +the repository toolchain plus llvm-tools and prefetch ``Cargo.lock`` crates +before the sandbox starts. Only regular blobs from the exact validated base +commit may enter that trusted image build context. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +from pathlib import Path, PurePosixPath +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised by Python 3.10 CI. + import tomli as tomllib + +DEBIAN_RUSTC = (1, 85, 0) +CHANNEL_RE = re.compile(r"^[A-Za-z0-9._+-]+$") +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +VERSION_RE = re.compile(r"^(\d+)\.(\d+)(?:\.(\d+))?$") +RUST_INPUT_NAMES = ("rust-toolchain.toml", "rust-toolchain", "Cargo.toml", "Cargo.lock") +REGULAR_BLOB_MODES = frozenset({"100644", "100755"}) +GIT_BINARY = "/usr/bin/git" +GIT_TIMEOUT_SECONDS = 30 + + +def _resolve_git_dir(repo_root: Path) -> Path: + """Return the git directory for a regular checkout or gitdir pointer file.""" + git_path = repo_root / ".git" + if git_path.is_symlink(): + raise RuntimeError("git object read failed: .git is a symbolic link") + if git_path.is_file(): + match = re.search( + r"(?m)^gitdir:\s*(.+?)\s*$", + git_path.read_text(encoding="utf-8"), + ) + if match is None: + raise RuntimeError("git object read failed: invalid gitdir pointer") + raw = match.group(1) + candidate = Path(raw) if Path(raw).is_absolute() else git_path.parent / raw + if candidate.is_symlink() or not candidate.is_dir(): + raise RuntimeError("git object read failed: gitdir is not a regular directory") + return candidate + if git_path.is_dir(): + return git_path + raise RuntimeError("git object read failed: not a git repository") + + +def _bounded_repo_path(path: str) -> PurePosixPath: + """Return one normalized repository path or fail closed.""" + candidate = PurePosixPath(path) + if ( + not path + or candidate.is_absolute() + or "." in candidate.parts + or ".." in candidate.parts + or "\\" in path + or "\0" in path + or candidate.as_posix() != path + ): + raise ValueError(f"Rust input is not a bounded repository path: {path!r}") + return candidate + + +def _git_environment() -> dict[str, str]: + """Return a deterministic environment for read-only Git object access.""" + return { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_NO_REPLACE_OBJECTS": "1", + "GIT_OPTIONAL_LOCKS": "0", + "HOME": os.devnull, + "LC_ALL": "C", + "PATH": os.defpath, + } + + +def _git(repo_root: Path, *args: str) -> bytes: + """Run one allowlisted, read-only Git object query.""" + if args[:3] == ("ls-tree", "-rz", "--full-tree") and len(args) == 4: + if SHA_RE.fullmatch(args[3]) is None: + raise ValueError("base SHA must be exactly 40 hexadecimal characters") + elif args[:1] == ("show",) and len(args) == 2: + revision, separator, path = args[1].partition(":") + if separator != ":" or SHA_RE.fullmatch(revision) is None or ":" in path: + raise ValueError("Git blob selector must bind one exact SHA and bounded path") + _bounded_repo_path(path) + else: + raise RuntimeError( + f"git {args[0] if args else 'command'} failed: unsupported invocation" + ) + + _resolve_git_dir(repo_root) + completed = subprocess.run( + [ + GIT_BINARY, + "-c", + "core.fsmonitor=false", + "-c", + "core.hooksPath=/dev/null", + "-C", + str(repo_root), + *args, + ], + check=False, + capture_output=True, + env=_git_environment(), + timeout=GIT_TIMEOUT_SECONDS, + ) + if completed.returncode != 0: + stderr = completed.stderr.decode("utf-8", errors="replace").strip() + raise RuntimeError(f"git {args[0]} failed: {stderr}") + return completed.stdout + + +def parse_rust_version(value: str) -> tuple[int, int, int] | None: + """Parse a rust-version or toolchain channel into a comparable triple.""" + match = VERSION_RE.fullmatch(value.strip()) + if match is None: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3) or 0)) + + +def _nested(document: dict[str, Any], path: str) -> Any: + """Return a dotted TOML value, or None when any segment is absent.""" + value: Any = document + for segment in path.split("."): + if not isinstance(value, dict) or segment not in value: + return None + value = value[segment] + return value + + +def read_toml(content: bytes, source: str) -> dict[str, Any]: + """Load one exact-revision TOML blob as a mapping.""" + document = tomllib.loads(content.decode("utf-8")) + if not isinstance(document, dict): + raise TypeError(f"{source} must contain a TOML table") + return document + + +def tracked_paths(repo_root: Path, revision_sha: str) -> set[str]: + """Return regular blob paths from one exact commit tree.""" + if SHA_RE.fullmatch(revision_sha) is None: + raise ValueError("base SHA must be exactly 40 hexadecimal characters") + listed = _git(repo_root, "ls-tree", "-rz", "--full-tree", revision_sha) + paths: set[str] = set() + for entry in listed.split(b"\0"): + if not entry: + continue + header, separator, raw_path = entry.partition(b"\t") + fields = header.split() + if separator != b"\t" or len(fields) != 3: + raise RuntimeError("git ls-tree failed: malformed tree entry") + mode, object_type, object_sha = fields + if len(object_sha) != 40 or re.fullmatch(rb"[0-9a-f]{40}", object_sha) is None: + raise RuntimeError("git ls-tree failed: invalid object identity") + if object_type != b"blob" or mode.decode("ascii") not in REGULAR_BLOB_MODES: + continue + try: + path = raw_path.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise ValueError("Rust input path is not valid UTF-8") from exc + _bounded_repo_path(path) + paths.add(path) + return paths + + +def _read_blob( + repo_root: Path, + revision_sha: str, + relative: str, + regular_paths: set[str], +) -> bytes: + """Read one proven regular blob from an exact commit tree.""" + _bounded_repo_path(relative) + if relative not in regular_paths: + raise ValueError(f"refusing to materialize non-regular Rust input: {relative}") + return _git(repo_root, "show", f"{revision_sha}:{relative}") + + +def toolchain_channel( + repo_root: Path, + revision_sha: str, + regular_paths: set[str] | None = None, +) -> str | None: + """Return the rustup channel declared by exact-revision toolchain files.""" + paths = regular_paths if regular_paths is not None else tracked_paths(repo_root, revision_sha) + if "rust-toolchain.toml" in paths: + content = _read_blob(repo_root, revision_sha, "rust-toolchain.toml", paths) + channel = _nested(read_toml(content, "rust-toolchain.toml"), "toolchain.channel") + if isinstance(channel, str) and CHANNEL_RE.fullmatch(channel): + return channel + if "rust-toolchain" in paths: + content = _read_blob(repo_root, revision_sha, "rust-toolchain", paths) + lines = content.decode("utf-8").strip().splitlines() + if lines: + channel = lines[0].strip() + if CHANNEL_RE.fullmatch(channel): + return channel + return None + + +def declared_rust_version( + repo_root: Path, + revision_sha: str, + regular_paths: set[str] | None = None, +) -> str | None: + """Return package or workspace rust-version from the exact root manifest.""" + paths = regular_paths if regular_paths is not None else tracked_paths(repo_root, revision_sha) + if "Cargo.toml" not in paths: + return None + content = _read_blob(repo_root, revision_sha, "Cargo.toml", paths) + document = read_toml(content, "Cargo.toml") + for path in ("package.rust-version", "workspace.package.rust-version"): + value = _nested(document, path) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def rustup_channel( + repo_root: Path, + revision_sha: str, + regular_paths: set[str] | None = None, +) -> str | None: + """Choose the rustup toolchain the coverage image must install.""" + paths = regular_paths if regular_paths is not None else tracked_paths(repo_root, revision_sha) + channel = toolchain_channel(repo_root, revision_sha, paths) + if channel is not None: + return channel + rust_version = declared_rust_version(repo_root, revision_sha, paths) + if rust_version is None: + return None + parsed = parse_rust_version(rust_version) + if parsed is None or parsed <= DEBIAN_RUSTC: + return None + return rust_version + + +def _bounded_member_path(member: str) -> PurePosixPath: + """Reject absolute or parent-directory workspace member paths.""" + return _bounded_repo_path(member) + + +def expand_workspace_member(member: str, regular_paths: set[str]) -> list[str]: + """Expand one workspace member against exact-tree regular blob paths.""" + if any(marker in member for marker in ("?", "[", "**")): + raise ValueError(f"unsupported workspace member glob: {member}") + if member.endswith("/*"): + parent = _bounded_member_path(member[:-2]) + prefix = f"{parent.as_posix()}/" + paths: list[str] = [] + for path in sorted(regular_paths): + if not path.startswith(prefix) or not path.endswith("/Cargo.toml"): + continue + remainder = path[len(prefix) :] + if remainder.count("/") == 1: + paths.append(path) + return paths + if "*" in member: + raise ValueError(f"unsupported workspace member glob: {member}") + relative = _bounded_member_path(member) + member_manifest = f"{relative.as_posix()}/Cargo.toml" + return [member_manifest] if member_manifest in regular_paths else [] + + +def workspace_member_manifests( + repo_root: Path, + revision_sha: str, + regular_paths: set[str] | None = None, +) -> list[str]: + """Return bounded workspace member manifests from one exact root manifest.""" + paths = regular_paths if regular_paths is not None else tracked_paths(repo_root, revision_sha) + if "Cargo.toml" not in paths: + return [] + content = _read_blob(repo_root, revision_sha, "Cargo.toml", paths) + members = _nested(read_toml(content, "Cargo.toml"), "workspace.members") + if not isinstance(members, list): + return [] + manifests: list[str] = [] + for member in members: + if isinstance(member, str): + manifests.extend(expand_workspace_member(member, paths)) + return list(dict.fromkeys(manifests)) + + +def tracked_rust_inputs( + repo_root: Path, + revision_sha: str, + regular_paths: set[str] | None = None, +) -> list[str]: + """List exact-revision Rust inputs that may enter the image context.""" + paths = regular_paths if regular_paths is not None else tracked_paths(repo_root, revision_sha) + if "Cargo.toml" not in paths: + return [] + inputs = [name for name in RUST_INPUT_NAMES if name in paths] + inputs.extend(workspace_member_manifests(repo_root, revision_sha, paths)) + return list(dict.fromkeys(inputs)) + + +def write_bounded_blob( + repo_root: Path, + revision_sha: str, + relative: str, + regular_paths: set[str], + output_dir: Path, +) -> None: + """Write one exact-revision regular blob into the build context.""" + content = _read_blob(repo_root, revision_sha, relative, regular_paths) + destination = output_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.is_symlink(): + raise ValueError(f"refusing to replace symlinked Rust output: {relative}") + destination.write_bytes(content) + destination.chmod(0o444) + + +def materialize(repo_root: Path, base_sha: str, output_dir: Path) -> dict[str, Any]: + """Write bounded Rust inputs and a revision-bound machine-readable manifest.""" + if SHA_RE.fullmatch(base_sha) is None: + raise ValueError("base SHA must be exactly 40 hexadecimal characters") + repo_root = repo_root.resolve() + if output_dir.is_symlink(): + raise ValueError("output directory must not be a symlink") + output_dir.mkdir(parents=True, exist_ok=True) + paths = tracked_paths(repo_root, base_sha) + inputs = tracked_rust_inputs(repo_root, base_sha, paths) + for relative in inputs: + write_bounded_blob(repo_root, base_sha, relative, paths, output_dir) + payload = { + "revision_sha": base_sha.lower(), + "rustup_channel": rustup_channel(repo_root, base_sha, paths) if inputs else None, + "has_lock": "Cargo.lock" in inputs, + "has_manifest": "Cargo.toml" in inputs, + "inputs": inputs, + } + manifest = output_dir / "manifest.json" + manifest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + manifest.chmod(0o444) + return payload + + +def main(argv: list[str] | None = None) -> int: + """Copy Rust coverage inputs from an exact base commit into the image context.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, required=True) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args(argv) + try: + payload = materialize(args.repo_root, args.base_sha, args.output_dir) + except ( + OSError, + RuntimeError, + subprocess.SubprocessError, + UnicodeError, + ValueError, + tomllib.TOMLDecodeError, + ) as exc: + parser.error(str(exc)) + print(json.dumps(payload, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 9317860e4..cff01846f 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -12,6 +12,7 @@ import socket import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -41,6 +42,15 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 +NIM_CHAT_HOST = "integrate.api.nvidia.com" +FORBIDDEN_NOEMA_MODEL_MARKERS = ("gpt-5.6", "github-models", "github_models", "copilot") +TRANSIENT_GH_ERROR_RE = re.compile( + r"HTTP 429|HTTP 502|HTTP 503|No server is currently available to service your request", + re.IGNORECASE, +) +TRANSIENT_GITHUB_STATUS_RE = TRANSIENT_GH_ERROR_RE +GH_TRANSIENT_RETRY_ATTEMPTS = 6 +GH_TRANSIENT_RETRY_SLEEP_SECONDS = 5 # ⚡ Bolt: Pre-compiled regex patterns to avoid recompilation on every scrub_sensitive_data call. # Impact: Improves string processing performance in error reporting. @@ -64,25 +74,136 @@ def scrub_sensitive_data(text: str | None) -> str | None: return text -def run(args: Sequence[str], *, stdin: str | None = None) -> str: +def run(args: Sequence[str], *, stdin: str | None = None, retry: bool = True) -> str: """Run a command without invoking a shell and return stdout.""" if isinstance(args, str): raise TypeError("run() requires argv, not a shell command string") - completed = subprocess.run( - list(args), - input=stdin, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - shell=False, + argv = list(args) + last_stderr = "" + last_returncode = 1 + attempts = 1 + if retry and argv and argv[0] == "gh": + attempts = max(1, GH_TRANSIENT_RETRY_ATTEMPTS) + attempt = 1 + while True: + completed = subprocess.run( + argv, + input=stdin, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + ) + if completed.returncode == 0: + return completed.stdout + last_returncode = completed.returncode + last_stderr = completed.stderr.strip() + if ( + attempt < attempts + and TRANSIENT_GH_ERROR_RE.search(last_stderr) + ): + sleep_s = float( + os.environ.get( + "NOEMA_GH_RETRY_SLEEP", str(GH_TRANSIENT_RETRY_SLEEP_SECONDS) + ) + ) + if sleep_s > 0: + time.sleep(sleep_s) + attempt += 1 + continue + break + scrubbed_stderr = scrub_sensitive_data(last_stderr) + raise RuntimeError( + f"Command failed ({last_returncode}): {argv[0]}\n{scrubbed_stderr}" ) - if completed.returncode != 0: - scrubbed_stderr = scrub_sensitive_data(completed.stderr.strip()) + + +def is_transient_github_error(message: str) -> bool: + """Return whether a gh failure looks like a retryable GitHub outage.""" + return bool(TRANSIENT_GH_ERROR_RE.search(str(message or ""))) + + +def run_github(args: Sequence[str], *, stdin: str | None = None, attempts: int = 3) -> str: + """Run gh and retry transient 429/502/503 failures a bounded number of times.""" + if isinstance(args, str): + raise TypeError("run_github() requires argv, not a shell command string") + sleep_s = float(os.environ.get("NOEMA_GH_RETRY_SLEEP", "1")) + last_error: RuntimeError | None = None + for attempt in range(attempts): + try: + return run(args, stdin=stdin) + except RuntimeError as exc: + last_error = exc + if attempt + 1 >= attempts or not is_transient_github_error(str(exc)): + raise + if sleep_s > 0: + time.sleep(sleep_s) + raise last_error or RuntimeError("GitHub request failed") + + +def emit_noema_failure(exc: BaseException) -> None: + """Publish a scrubbed Noema exception to the job log and step summary.""" + detail = scrub_sensitive_data(str(exc)) or "Noema review failed" + print(f"::error::{detail}", file=sys.stderr) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write("## Noema review failure\n\n") + handle.write(f"{detail}\n") + + +def allowed_noema_llm_hosts() -> set[str]: + """Return NIM plus an optional contextual-orchestrator hostname.""" + hosts = {NIM_CHAT_HOST} + orchestrator = os.environ.get("CONTEXTUAL_ORCHESTRATOR_URL", "").strip() + if orchestrator: + parsed = urllib.parse.urlparse(orchestrator) + hostname = (parsed.hostname or "").lower() + if hostname: + hosts.add(hostname) + return hosts + + +def require_nim_runtime() -> None: + """Fail closed unless Noema uses the visibility-governed review provider.""" + api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() + api_key = os.environ.get("NOEMA_LLM_API_KEY", "").strip() + model = os.environ.get("NOEMA_LLM_MODEL", "").strip() + if not api_url or not api_key or not model: + raise RuntimeError( + "Noema NIM runtime is unconfigured: NOEMA_LLM_API_URL, " + "NOEMA_LLM_MODEL, and NOEMA_LLM_API_KEY are required." + ) + lowered_model = model.casefold() + if any(marker in lowered_model for marker in FORBIDDEN_NOEMA_MODEL_MARKERS): raise RuntimeError( - f"Command failed ({completed.returncode}): {args[0]}\n{scrubbed_stderr}" + f"Noema must not use GitHub Models, Copilot, or gpt-5.6; observed model {model!r}." + ) + visibility = os.environ.get("TARGET_REPOSITORY_PRIVATE", "").strip().casefold() + if visibility not in {"true", "false"}: + raise RuntimeError( + "Noema target repository visibility is missing or invalid; failing closed." + ) + parsed = urllib.parse.urlparse(api_url) + hostname = (parsed.hostname or "").lower() + if not hostname: + raise RuntimeError("Noema LLM URL must include a hostname.") + if visibility == "true": + if hostname == NIM_CHAT_HOST: + raise RuntimeError( + "A private repository must not send review evidence to hosted NVIDIA NIM." + ) + if parsed.scheme.casefold() != "https": + raise RuntimeError( + "A private repository requires an explicitly configured HTTPS Noema LLM endpoint." + ) + return + if hostname not in allowed_noema_llm_hosts(): + raise RuntimeError( + "Noema LLM URL must target integrate.api.nvidia.com or the optional " + f"contextual-orchestrator host; observed {hostname or ''}." ) - return completed.stdout def split_repo(repo: str) -> tuple[str, str]: @@ -549,6 +670,8 @@ def submit_review(repo: str, number: int, pr: dict[str, Any], actor: str, verdic head_sha = str(pr.get("headRefOid") or "") decision = str(verdict.get("decision") or "comment").lower() event = "APPROVE" if decision == "approve" else "REQUEST_CHANGES" if decision == "request_changes" else "COMMENT" + if event == "APPROVE" and pr.get("isDraft"): + raise RuntimeError("draft must never receive bot APPROVE") source = os.environ.get("NOEMA_REVIEW_TOKEN_SOURCE") or "NOEMA_REVIEW_TOKEN" summary = str(verdict.get("summary") or "Noema completed an independent LLM review.").strip() findings = format_findings(verdict.get("findings")) @@ -577,46 +700,52 @@ def submit_review(repo: str, number: int, pr: dict[str, Any], actor: str, verdic run( ["gh", "api", "-X", "POST", f"repos/{repo}/pulls/{number}/reviews", "--input", "-"], stdin=json.dumps(payload), + retry=False, ) print(f"Noema {event} review submitted for {repo}#{number} at {head_sha}.") def inspect_and_review(repo: str, number: int) -> int: """Inspect PR state and submit Noema's LLM review when gates are clean.""" - pr = fetch_pr(repo, number) - actor = current_actor() - if actor in PRIMARY_REVIEW_AUTHORS: - print( - f"Current token actor {actor!r} is already a primary review actor; " - "Noema review skipped so GitHub receives an independent reviewer." - ) - return 0 - if pr.get("isDraft"): - print("PR is draft; Noema review skipped.") - return 0 - if existing_noema_review(pr, actor): - print("Current head already has a Noema review; nothing to do.") - return 0 - if not current_primary_approval(pr): - print("Current head does not have a primary OpenCode approval; Noema review skipped.") - return 0 - if has_current_changes_requested(pr): - print("Current head has requested changes; Noema review skipped.") - return 0 - if has_unresolved_threads(pr): - print("PR has unresolved review threads; Noema review skipped.") - return 0 - blockers = blocking_checks(pr) - if blockers: - print("Blocking checks remain; Noema review skipped:") - for blocker in blockers: - print(f"- {blocker}") + try: + pr = fetch_pr(repo, number) + actor = current_actor() + if actor in PRIMARY_REVIEW_AUTHORS: + print( + f"Current token actor {actor!r} is already a primary review actor; " + "Noema review skipped so GitHub receives an independent reviewer." + ) + return 1 + if pr.get("isDraft"): + print("PR is draft; Noema review skipped.") + return 1 + if existing_noema_review(pr, actor): + print("Current head already has a Noema review; nothing to do.") + return 0 + if not current_primary_approval(pr): + print("Current head does not have a primary OpenCode approval; Noema review skipped.") + return 1 + if has_current_changes_requested(pr): + print("Current head has requested changes; Noema review skipped.") + return 1 + if has_unresolved_threads(pr): + print("PR has unresolved review threads; Noema review skipped.") + return 1 + blockers = blocking_checks(pr) + if blockers: + print("Blocking checks remain; Noema review skipped:") + for blocker in blockers: + print(f"- {blocker}") + return 1 + require_nim_runtime() + diff, truncated = fetch_diff(repo, number) + review_context = build_review_context(repo, number, pr) + verdict = call_llm(repo, number, pr, diff, truncated, review_context) + submit_review(repo, number, pr, actor, verdict) return 0 - diff, truncated = fetch_diff(repo, number) - review_context = build_review_context(repo, number, pr) - verdict = call_llm(repo, number, pr, diff, truncated, review_context) - submit_review(repo, number, pr, actor, verdict) - return 0 + except (RuntimeError, ValueError, OSError, json.JSONDecodeError) as exc: + emit_noema_failure(exc) + return 1 def parse_args(argv: list[str]) -> argparse.Namespace: diff --git a/scripts/ci/opencode_coverage_identity.py b/scripts/ci/opencode_coverage_identity.py new file mode 100644 index 000000000..b0d9e12fe --- /dev/null +++ b/scripts/ci/opencode_coverage_identity.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Verify a quoted coverage conclusion against the canonical exact-head check.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + + +CANONICAL_CHECK_NAME = "coverage-evidence" +CANONICAL_WORKFLOW_NAMES = frozenset({"Required OpenCode Review"}) +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +REPO_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*$") +TERMINAL_RESULTS = frozenset( + {"success", "failure", "cancelled", "skipped", "neutral", "timed_out", "action_required"} +) + +KAEFA_78_HEAD = "5092a70c9737221d6367e74643d06980609fe0b1" +KAEFA_75_HEAD = "4c8ad480a0f104601ca668cee5f0cf9372e819c3" +KAEFA_79_HEAD = "1c5d9f0491fc178be3f7f307dac521fbcbba6978" + + +class CoverageQuoteError(ValueError): + """Raised when a review would quote a coverage result that is not canonical.""" + + +def normalize_result(value: str) -> str: + """Return a lowercase GitHub check conclusion or ``unknown``.""" + normalized = str(value or "").strip().casefold() + if normalized in TERMINAL_RESULTS: + return normalized + return "unknown" + + +def check_head_sha(check: Mapping[str, Any]) -> str: + """Return the commit SHA recorded on a check-run object.""" + head = check.get("head_sha") or check.get("headSha") or "" + return str(head).strip() + + +def check_workflow_name(check: Mapping[str, Any]) -> str: + """Return the workflow name that produced a check-run, if present. + + The REST list-check-runs response's ``check_suite`` object does not carry a + ``workflow_run`` field, so this almost always returns "". ``app.name`` is + always "GitHub Actions" for Actions-produced checks, not the workflow name, + so it is not an acceptable fallback: a canonical exact-head check would be + rejected by a workflow-name mismatch it can never satisfy. An empty result + defers to ``is_canonical_coverage_check``'s ``not workflow`` acceptance. + """ + suite = check.get("check_suite") or check.get("checkSuite") or {} + if isinstance(suite, Mapping): + run = suite.get("workflow_run") or suite.get("workflowRun") or {} + if isinstance(run, Mapping): + workflow = run.get("workflow") or {} + if isinstance(workflow, Mapping): + name = str(workflow.get("name") or "").strip() + if name: + return name + return "" + + +def is_canonical_coverage_check(check: Mapping[str, Any], head_sha: str) -> bool: + """Return whether a check-run is the exact-head canonical coverage-evidence check.""" + if str(check.get("name") or "").strip() != CANONICAL_CHECK_NAME: + return False + if check_head_sha(check).lower() != head_sha.lower(): + return False + status = str(check.get("status") or "").strip().casefold() + if status and status != "completed": + return False + workflow = check_workflow_name(check) + return not workflow or workflow in CANONICAL_WORKFLOW_NAMES + + +def terminal_coverage_result( + check_runs: Sequence[Mapping[str, Any]], head_sha: str +) -> str: + """Return the terminal canonical coverage-evidence conclusion for ``head_sha``.""" + if not SHA_RE.fullmatch(head_sha): + raise CoverageQuoteError("coverage identity requires a 40-character head SHA") + matches = [ + check + for check in check_runs + if isinstance(check, Mapping) and is_canonical_coverage_check(check, head_sha) + ] + if not matches: + raise CoverageQuoteError( + f"no completed canonical {CANONICAL_CHECK_NAME} check for head {head_sha}" + ) + preferred = [ + check + for check in matches + if check_workflow_name(check) in CANONICAL_WORKFLOW_NAMES + ] + chosen = preferred[-1] if preferred else matches[-1] + result = normalize_result(str(chosen.get("conclusion") or "")) + if result == "unknown": + raise CoverageQuoteError( + f"canonical {CANONICAL_CHECK_NAME} conclusion is missing or non-terminal" + ) + return result + + +def assert_quoted_matches( + quoted_result: str, check_runs: Sequence[Mapping[str, Any]], head_sha: str +) -> str: + """Return the canonical result or raise when the quoted conclusion differs.""" + canonical = terminal_coverage_result(check_runs, head_sha) + quoted = normalize_result(quoted_result) + if quoted != canonical: + raise CoverageQuoteError( + f"quoted coverage-evidence result {quoted!r} does not match " + f"canonical exact-head result {canonical!r} for {head_sha}" + ) + return canonical + + +def load_check_runs(path: str | None) -> list[Mapping[str, Any]]: + """Load check-run objects from a JSON file or stdin.""" + raw = sys.stdin.read() if not path or path == "-" else Path(path).read_text(encoding="utf-8") + loaded = json.loads(raw) + if isinstance(loaded, Mapping) and isinstance(loaded.get("check_runs"), list): + loaded = loaded["check_runs"] + if not isinstance(loaded, list): + raise CoverageQuoteError("coverage identity payload must be a check-run array") + return [item for item in loaded if isinstance(item, Mapping)] + + +def fetch_check_runs(repo: str, head_sha: str) -> list[Mapping[str, Any]]: + """Read exact-head check-runs through gh without invoking a shell.""" + if not REPO_RE.fullmatch(repo): + raise CoverageQuoteError(f"coverage identity requires an owner/repo value, got {repo!r}") + if not SHA_RE.fullmatch(head_sha): + raise CoverageQuoteError("coverage identity requires a 40-character head SHA") + completed = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/commits/{head_sha}/check-runs?per_page=100", + "--paginate", + "--slurp", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "gh check-runs lookup failed").strip() + raise CoverageQuoteError(f"canonical coverage check lookup failed: {detail}") + loaded = json.loads(completed.stdout or "{}") + if isinstance(loaded, list): + runs: list[Mapping[str, Any]] = [] + for page in loaded: + if isinstance(page, Mapping) and isinstance(page.get("check_runs"), list): + runs.extend( + item for item in page["check_runs"] if isinstance(item, Mapping) + ) + elif isinstance(page, Mapping): + runs.append(page) + return runs + if isinstance(loaded, Mapping) and isinstance(loaded.get("check_runs"), list): + return [item for item in loaded["check_runs"] if isinstance(item, Mapping)] + raise CoverageQuoteError("canonical coverage check lookup returned malformed JSON") + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse coverage-identity CLI arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="") + parser.add_argument("--head-sha", required=True) + parser.add_argument("--quoted-result", required=True) + parser.add_argument("--check-runs-file") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Verify a quoted coverage conclusion and print the canonical result.""" + args = parse_args(argv) + try: + if args.check_runs_file: + checks = load_check_runs(args.check_runs_file) + elif args.repo: + checks = fetch_check_runs(args.repo, args.head_sha) + else: + raise CoverageQuoteError("coverage identity needs --repo or --check-runs-file") + canonical = assert_quoted_matches(args.quoted_result, checks, args.head_sha) + except (CoverageQuoteError, json.JSONDecodeError, OSError) as exc: + print(f"::error::{exc}", file=sys.stderr) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write("## Coverage identity failure\n\n") + handle.write(f"{exc}\n") + return 1 + sys.stdout.write(f"{canonical}\n") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/opencode_review_comment_helpers.sh b/scripts/ci/opencode_review_comment_helpers.sh index 52bf189c6..87a8a6da6 100644 --- a/scripts/ci/opencode_review_comment_helpers.sh +++ b/scripts/ci/opencode_review_comment_helpers.sh @@ -4,101 +4,35 @@ # This file is sourced by workflow run blocks after the trusted .github # repository has been checked out. +opencode_review_surfaces_py() { + local helper_dir + helper_dir="$(CDPATH='' cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" + printf '%s' "${helper_dir}/opencode_review_surfaces.py" +} + emit_change_flow_mermaid_graph() { local merge_state="${1:-UNKNOWN}" - local changed_files_file surfaces_file idx next_node + local changed_files_file changed_files_file="$(mktemp)" - surfaces_file="$(mktemp)" if ! timeout "${REVIEW_PUBLISH_GH_API_TIMEOUT_SECONDS:-120}s" \ gh pr diff "$PR_NUMBER" --repo "$GH_REPOSITORY" --name-only >"$changed_files_file" 2>/dev/null || [ ! -s "$changed_files_file" ]; then - printf '```mermaid\n' - printf 'flowchart LR\n' - printf ' Evidence["OpenCode evidence"] --> Review["Current PR review path"]\n' - printf ' Review --> Verify["Required checks"]\n' - printf '```\n' - rm -f "$changed_files_file" "$surfaces_file" - return 0 - fi - - awk ' - function basename(path) { - sub(/^.*\//, "", path) - return path - } - function clean(value) { - gsub(/"/, "", value) - gsub(/[\r\n\t]/, " ", value) - return value - } - function add(key, surface, impact, verify, path) { - if (!(key in count)) { - keys[++n] = key - label[key] = surface ": " basename(path) - impacts[key] = impact - verifies[key] = verify - } - count[key]++ - } - /^\.github\/workflows\// { - add("workflow", "Workflow", "GitHub Actions review job", "actionlint plus required checks", $0) - next - } - /^scripts\/ci\// { - add("ci", "CI script", "review and security gate shell path", "bash -n plus Strix self-test", $0) - next - } - /^backend\// { - add("backend", "Backend", "API and service runtime", "backend tests", $0) - next - } - /^frontend\// { - add("frontend", "Frontend", "browser runtime and bundle", "frontend tests", $0) - next - } - /^tests?\// || /(^|\/)test_/ { - add("tests", "Test", "regression suite", "targeted test run", $0) - next - } - /^docs\// { - add("docs", "Docs", "operator or user guidance", "docs review", $0) - next - } - { - add("other", "Changed file", "repository behavior", "required checks", $0) - } - END { - for (i = 1; i <= n; i++) { - key = keys[i] - if (count[key] > 1) { - sub(/: .*/, " (" count[key] " files)", label[key]) - } - print clean(label[key]) "\t" clean(impacts[key]) "\t" clean(verifies[key]) - } - } - ' "$changed_files_file" >"$surfaces_file" - - printf '```mermaid\n' - printf 'flowchart LR\n' - printf ' PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]\n' - idx=1 - while IFS="$(printf '\t')" read -r surface impact verify; do - [ -n "$surface" ] || continue - printf ' Evidence --> S%s["%s"]\n' "$idx" "$surface" - printf ' S%s --> I%s["%s"]\n' "$idx" "$idx" "$impact" - if [ "$merge_state" = "DIRTY" ] || [ "$merge_state" = "CONFLICTING" ]; then - printf ' I%s --> Conflict["Merge conflict blocks this path"]\n' "$idx" - next_node="Conflict" - else - printf ' I%s --> R%s["Review risk: %s"]\n' "$idx" "$idx" "$surface" - next_node="R${idx}" + if [ -n "${OPENCODE_CHANGED_FILES_FILE:-}" ] && [ -s "${OPENCODE_CHANGED_FILES_FILE}" ]; then + cp "${OPENCODE_CHANGED_FILES_FILE}" "$changed_files_file" fi - printf ' %s --> V%s["%s"]\n' "$next_node" "$idx" "$verify" - idx=$((idx + 1)) - done <"$surfaces_file" - printf '```\n' - rm -f "$changed_files_file" "$surfaces_file" + fi + if [ -n "${OPENCODE_SOURCE_WORKDIR:-}" ]; then + python3 "$(opencode_review_surfaces_py)" emit-mermaid \ + --changed-files-file "$changed_files_file" \ + --source-root "$OPENCODE_SOURCE_WORKDIR" \ + --merge-state "$merge_state" + else + python3 "$(opencode_review_surfaces_py)" emit-mermaid \ + --changed-files-file "$changed_files_file" \ + --merge-state "$merge_state" + fi + rm -f "$changed_files_file" } append_mermaid_review_graph() { diff --git a/scripts/ci/opencode_review_prompt_template.md b/scripts/ci/opencode_review_prompt_template.md index 32614dcfc..29189d1a0 100644 --- a/scripts/ci/opencode_review_prompt_template.md +++ b/scripts/ci/opencode_review_prompt_template.md @@ -30,7 +30,9 @@ For changed scrolling, animation, transition, or motion behavior, verify that us When a claim can be tested, use python3 scripts/ci/sandboxed_verify.py --repo-root "$OPENCODE_SOURCE_WORKDIR" -- or the web E2E wrapper above. If local tooling is missing or language versions differ, create an isolated Docker, Docker Compose, devcontainer, Nix, or temporary package-install sandbox and execute the verification there. If verification legitimately needs network or GitHub Secrets, pass only required names with --allow-env, declare --network required, add --evidence-note, and never print secret values; prefer synthetic/local substitutes over production services. Temporary proof or repro code must live only under the runner temporary directory or another ignored scratch path; do not commit or request committing scratch files. When proposing a fix for a blocker, prefer proving it in an isolated scratch copy or temporary worktree: apply the minimal patch there, run the relevant tests/linters/PoC, and cite the result. The review agent must not commit or push that proof patch; it should report the tested direction and, when concise enough, include a GitHub suggestion-ready diff. -Draw the right diagram. The required DAG evidence is not a file inventory. Use CodeGraph and focused source reads to identify the PR's relevant functions, classes, routes, components, database objects, workflows, or domain transitions, then compare base branch behavior with PR head behavior when that affects review. Include the most useful compact Mermaid diagram: sequenceDiagram for runtime message flow, classDiagram for class/API shape, erDiagram for schema/data relationship changes, stateDiagram for state transitions, or flowchart/DAG for function/control flow. Node labels must be quoted, for example A["parse_request"], so spaces, punctuation, parentheses, and file counts render safely. If CodeGraph cannot represent the changed surface, say why and draw a source-backed focused flow instead. +Draw the right diagram. The required DAG evidence is not a file inventory. Use CodeGraph and focused source reads to identify the PR's relevant functions, classes, routes, components, database objects, workflows, or domain transitions, then compare base branch behavior with PR head behavior when that affects review. Include the most useful compact Mermaid diagram: sequenceDiagram for runtime message flow, classDiagram for class/API shape, erDiagram for schema/data relationship changes, stateDiagram for state transitions, or flowchart/DAG for function/control flow. Node labels must be quoted, for example A["parse_request"], so spaces, punctuation, parentheses, and file counts render safely. Never label a crate, package, or language surface as `Changed file (N files)`. If CodeGraph cannot represent the changed surface, say why and draw a source-backed focused flow instead. + +The formal pull-request review is the code review of the actual diff. Name the changed product files and what they do. Publish file/line findings on the current-head diff, or an explicit APPROVE with a real walkthrough of those files. Coverage execution evidence is a separate gate: a coverage miss, skip, or unsupported-tooling result blocks approval in the status comment and must not replace the diff review. Never cite `.github/workflows/opencode-review.yml` or line 1 of that file as a finding unless that exact path is in the current-head changed-file list. Lead with severity-ordered findings. REQUEST_CHANGES findings must be actionable, source-backed, and line-specific: path, positive line, severity, title, problem, root_cause, fix_direction, regression_test_direction, and suggested_diff. The line value must be a positive integer from a current-head source, test, workflow, config, or evidence line; never use line 0. Include observable impact, trigger condition, exact failed log/check phrase when relevant, and a concrete verification command when the repository provides one. Do not request changes with only a check URL, workflow name, generic failure summary, raw tool-access failure, or missing-string marker. Suggested diffs must be GitHub suggestion-ready when possible, and every removed line must exist in the cited current local file. @@ -41,7 +43,7 @@ Never approve material workflow, script, source, config, package, or test change Coverage and Docstring coverage must cite Coverage execution evidence showing supported repository test suites passed and configured repository docstring gates passed or were advisory, or explicitly cite Coverage execution evidence as not applicable because no supported source files or package manifests were found. Missing, failed, skipped, unavailable, unsupported-tooling, partial, or below-threshold evidence is a blocker, not an approval condition. DAG: must name the CodeGraph/source-backed behavioral diagram and say whether it reflects base, head, or base-to-head changed flow. Compatibility/convention: must include naming and reserved-word review for changed schema/API/config/code objects, or explain which changed surfaces had no externally meaningful names. Developer experience: must name the DX surface classified for this PR and the evidence used to judge it. User experience: must name the UX surface classified for this PR and the evidence used to judge it. Visual/DOM: must cite Playwright visual/DOM/ARIA/console evidence for web UI changes; for non-web changes, state the non-web interaction surface reviewed instead, such as CLI/API/logs/docs/workflow/review-comment output. UX and DX must never be dismissed as not applicable merely because the repository is not a web app. -First line exactly: +Then, after the review body, one line exactly: Then exactly one control block. The object below is a non-current schema illustration: replace every `COPY_*` identity with the exact values from the sentinel above, choose one enum value rather than copying `CHOOSE_*`, and do not quote or repeat this illustration before the sentinel. @@ -50,4 +52,4 @@ Replace the example probe's `path`, numeric positive `line`, and `source-line-sh {"head_sha":"COPY_SENTINEL_HEAD_SHA","run_id":"COPY_SENTINEL_RUN_ID","run_attempt":"COPY_SENTINEL_RUN_ATTEMPT","result":"CHOOSE_APPROVE_OR_REQUEST_CHANGES","reason":"short reason","summary":"short review summary with concrete evidence and all required labels","adversarial_validation":{"status":"CHOOSE_PASSED_OR_FAILED","probes":[{"path":"COPY_EXACT_PATH_FROM_TRUSTED_RECEIPT_SECTION","line":1,"hypothesis":"concrete failure hypothesis","attack_or_counterexample":"input, state, race, threat, or boundary used to challenge it","evidence":"trusted test/check/log/diff/source-trace outcome at matching path:line and exactly one copied source-line-sha256 receipt","outcome":"CHOOSE_FALSIFIED_OR_CONFIRMED"}],"residual_risk":"bounded residual risk after the probes"},"findings":[]} --> -Do not include analysis, planning, tool-call narration, placeholders, raw tool-call markup, MCP call syntax, function-call JSON, or prose before the sentinel. Replace APPROVE or REQUEST_CHANGES with exactly one valid result. Put all required labels inside the JSON summary string itself. When result is APPROVE, `adversarial_validation.status` must be `passed`, every probe outcome must be `falsified`, and findings must be exactly [] with no advisory, informational, already-fixed, or positive findings. When result is REQUEST_CHANGES, `adversarial_validation.status` must be `failed`, at least one probe outcome must be `confirmed` at the same path and line as a source-backed finding, and findings must include source-backed line-specific blockers. Return only the review body. +Write the human-readable review first using the Verdict / Findings / Test Gaps structure. Then append the sentinel and exactly one control block. Do not include analysis, planning, tool-call narration, placeholders, raw tool-call markup, MCP call syntax, or function-call JSON. Replace APPROVE or REQUEST_CHANGES with exactly one valid result. Put all required labels inside the JSON summary string itself. When result is APPROVE, `adversarial_validation.status` must be `passed`, every probe outcome must be `falsified`, and findings must be exactly [] with no advisory, informational, already-fixed, or positive findings. When result is REQUEST_CHANGES, `adversarial_validation.status` must be `failed`, at least one probe outcome must be `confirmed` at the same path and line as a source-backed finding, and findings must include source-backed line-specific blockers. Return the review body, then the control JSON. diff --git a/scripts/ci/opencode_review_receipt_gate.py b/scripts/ci/opencode_review_receipt_gate.py new file mode 100644 index 000000000..939cf90a8 --- /dev/null +++ b/scripts/ci/opencode_review_receipt_gate.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Require a current-head formal OpenCode review receipt before a required check is green.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + + +SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +REPO_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*$") +HEAD_SHA_IN_BODY_RE = re.compile(r"Head SHA:\s*`([0-9a-fA-F]{40})`") +FORMAL_AUTHORS = frozenset( + {"opencode-agent", "opencode-agent[bot]", "github-actions[bot]"} +) +FORMAL_STATES = frozenset({"APPROVED", "CHANGES_REQUESTED", "COMMENTED"}) +STATUS_HEADINGS = ("## OpenCode Review Status", "## OpenCode 게이트 상태") +PRODUCT_MARKERS = ( + "## Pull request overview", + "## Pull request 개요", + "## Changed files", + "## Changed API", + "## Verdict", + "opencode-review-control-v1", + "OpenCode reviewed the current-head product diff", + "OpenCode reviewed the current-head bounded evidence", +) +MENTION_RE = re.compile(r"^@opencode-agent\b", re.IGNORECASE) + +AFIPC_230_HEAD = "5eda857066c9207786d3bdde49826f8f94b98c12" +AFIPC_230_STALE_HEADS = frozenset( + { + "8a1133d406d0d15b425644e0dc3910f112ccbb36", + "8757e7b022cb66f21886d4c241857a9986ef7a6c", + } +) +KAEFA_79_HEAD = "1c5d9f0491fc178be3f7f307dac521fbcbba6978" + + +class ReceiptGateError(ValueError): + """Raised when the required OpenCode check lacks a current-head formal receipt.""" + + +def review_author(review: Mapping[str, Any]) -> str: + """Return the login for a REST or GraphQL review object.""" + user = review.get("user") or review.get("author") or {} + if isinstance(user, Mapping): + return str(user.get("login") or "").strip() + return "" + + +def review_commit(review: Mapping[str, Any]) -> str: + """Return the commit SHA the review was submitted against.""" + commit_id = str(review.get("commit_id") or "").strip() + if commit_id: + return commit_id + commit = review.get("commit") or {} + if isinstance(commit, Mapping): + return str(commit.get("oid") or commit.get("sha") or "").strip() + return "" + + +def review_body_head_sha(review: Mapping[str, Any]) -> str | None: + """Return the last explicit Head SHA recorded in a review body.""" + matches = HEAD_SHA_IN_BODY_RE.findall(str(review.get("body") or "")) + return matches[-1] if matches else None + + +def review_matches_head(review: Mapping[str, Any], head_sha: str) -> bool: + """Return whether commit and optional body SHA both match the live head.""" + if not head_sha or review_commit(review).lower() != head_sha.lower(): + return False + body_head = review_body_head_sha(review) + return body_head is None or body_head.lower() == head_sha.lower() + + +def is_mention_or_malformed(body: str) -> bool: + """Return whether a body is a mention payload or not a product-file review.""" + stripped = body.strip() + if not stripped: + return True + first_line = stripped.splitlines()[0].strip() + if MENTION_RE.match(first_line) and "Head SHA:" not in stripped: + return True + if any(heading in stripped for heading in STATUS_HEADINGS) and not any( + marker in stripped for marker in PRODUCT_MARKERS + ): + return True + return not any(marker in stripped for marker in PRODUCT_MARKERS) + + +def is_formal_receipt( + review: Mapping[str, Any], + head_sha: str, + *, + is_draft: bool, +) -> tuple[bool, str]: + """Return whether a review is a usable current-head formal product-file receipt.""" + if not review_matches_head(review, head_sha): + return False, "stale or mismatched head" + author = review_author(review) + if author not in FORMAL_AUTHORS: + return False, f"author {author or ''} is not an OpenCode publisher" + state = str(review.get("state") or "").upper() + if state not in FORMAL_STATES: + return False, f"state {state or ''} is not a formal review verdict" + if not review.get("id"): + return False, "missing pullrequestreview id" + body = str(review.get("body") or "") + if is_mention_or_malformed(body): + return False, "mention, status-only, or malformed payload is not a formal review" + if is_draft and state == "APPROVED": + return False, "draft must never receive bot APPROVE" + return True, "current-head formal review" + + +def evaluate_receipts( + reviews: Sequence[Mapping[str, Any]], + head_sha: str, + *, + is_draft: bool = False, +) -> tuple[Mapping[str, Any] | None, str]: + """Return the current-head formal receipt or explain why the gate fails.""" + if not SHA_RE.fullmatch(head_sha): + return None, "receipt gate requires a 40-character head SHA" + stale_hits = 0 + for review in reversed(list(reviews)): + if not isinstance(review, Mapping): + continue + commit = review_commit(review) + if commit and commit.lower() != head_sha.lower(): + stale_hits += 1 + continue + ok, reason = is_formal_receipt(review, head_sha, is_draft=is_draft) + if ok: + return review, reason + if "never receive bot APPROVE" in reason: + return None, reason + if reason.startswith("stale"): + stale_hits += 1 + continue + if stale_hits: + return ( + None, + "stale CHANGES_REQUESTED or prior-head reviews are not current-head receipts", + ) + return None, "no current-head formal OpenCode review receipt" + + +def load_reviews(path: str | None) -> list[Mapping[str, Any]]: + """Load review objects from a JSON file or stdin.""" + raw = sys.stdin.read() if not path or path == "-" else Path(path).read_text(encoding="utf-8") + loaded = json.loads(raw) + if not isinstance(loaded, list): + raise ReceiptGateError("review payload must be a JSON array") + return [item for item in loaded if isinstance(item, Mapping)] + + +def fetch_reviews(repo: str, number: int) -> list[Mapping[str, Any]]: + """Read pull-request reviews through gh without invoking a shell.""" + if not REPO_RE.fullmatch(repo): + raise ReceiptGateError(f"receipt gate requires an owner/repo value, got {repo!r}") + completed = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/pulls/{number}/reviews", + "--paginate", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout or "gh reviews lookup failed").strip() + raise ReceiptGateError(f"formal review receipt lookup failed: {detail}") + loaded = json.loads(completed.stdout or "[]") + if isinstance(loaded, list): + return [item for item in loaded if isinstance(item, Mapping)] + raise ReceiptGateError("formal review receipt lookup returned malformed JSON") + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse receipt-gate CLI arguments.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", default="") + parser.add_argument("--pr-number", type=int, default=0) + parser.add_argument("--head-sha", required=True) + parser.add_argument("--draft", action="store_true") + parser.add_argument("--reviews-file") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Fail closed unless a verifiable current-head formal review receipt exists.""" + args = parse_args(argv) + try: + if args.reviews_file: + reviews = load_reviews(args.reviews_file) + elif args.repo and args.pr_number > 0: + reviews = fetch_reviews(args.repo, args.pr_number) + else: + raise ReceiptGateError("receipt gate needs --reviews-file or --repo/--pr-number") + receipt, reason = evaluate_receipts( + reviews, args.head_sha, is_draft=args.draft + ) + if receipt is None: + raise ReceiptGateError(reason) + except (ReceiptGateError, json.JSONDecodeError, OSError) as exc: + print(f"::error::{exc}", file=sys.stderr) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write("## OpenCode formal review receipt missing\n\n") + handle.write(f"{exc}\n") + return 1 + review_id = receipt.get("id") + print( + f"Current-head formal OpenCode receipt id={review_id} " + f"state={receipt.get('state')} head={args.head_sha}" + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/opencode_review_surfaces.py b/scripts/ci/opencode_review_surfaces.py new file mode 100644 index 000000000..2c9ac3840 --- /dev/null +++ b/scripts/ci/opencode_review_surfaces.py @@ -0,0 +1,720 @@ +#!/usr/bin/env python3 +"""Split OpenCode review publication into a diff review and a gate-status comment. + +The OriginWeave #47 failure posted the same coverage-gate body as both the +formal pull-request review and the issue comment, and it anchored that body to +``.github/workflows/opencode-review.yml:1`` even though the product diff was a +Rust crate. This module is the trusted publisher contract for those surfaces. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections import OrderedDict +from collections.abc import Mapping, Sequence +from pathlib import Path, PurePosixPath + +CENTRAL_WORKFLOW_ANCHOR = ".github/workflows/opencode-review.yml" +PUB_ITEM_RE = re.compile( + r"^\s*pub(?:\s*\([^)]*\))?\s+" + r"(?:async\s+)?(?:unsafe\s+)?" + r"(?Pstruct|enum|fn|trait|type|mod)\s+" + r"(?P[A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +RUST_SUFFIXES = {".rs"} +PYTHON_SUFFIXES = {".py"} +TYPESCRIPT_SUFFIXES = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} +GO_SUFFIXES = {".go"} +WORKFLOW_PREFIXES = (".github/workflows/",) +CI_PREFIXES = ("scripts/ci/",) +DOC_PREFIXES = ("docs/",) +TEST_NAME_RE = re.compile(r"(^|/)tests?(/|$)|(^|/)test_[^/]+") + + +def posix_path(raw_path: str) -> str: + """Normalize a repository-relative path to POSIX form without traversal.""" + normalized = raw_path.replace("\\", "/").strip() + while normalized.startswith("./"): + normalized = normalized[2:] + candidate = PurePosixPath(normalized) + if candidate.is_absolute() or ".." in candidate.parts: + raise ValueError(f"changed path is not a bounded repository path: {raw_path}") + return str(candidate) + + +def classify_changed_path(raw_path: str) -> dict[str, str]: + """Return the review surface, impact, and verification label for one path.""" + path = posix_path(raw_path) + suffix = Path(path).suffix.lower() + parts = PurePosixPath(path).parts + name = Path(path).name + + if path.startswith(WORKFLOW_PREFIXES): + return { + "key": f"workflow:{path}", + "surface": f"Workflow: {name}", + "impact": "GitHub Actions review job", + "verify": "actionlint plus required checks", + "kind": "workflow", + } + if path.startswith(CI_PREFIXES): + return { + "key": f"ci:{path}", + "surface": f"CI script: {name}", + "impact": "review and security gate shell path", + "verify": "bash -n plus Strix self-test", + "kind": "ci", + } + if parts and parts[0] == "crates": + crate = parts[1] if len(parts) > 1 else name + return { + "key": f"rust-crate:{crate}", + "surface": f"Rust crate: {crate}", + "impact": "Rust workspace crate API and tests", + "verify": "cargo test plus llvm-cov", + "kind": "rust-crate", + } + if name in {"Cargo.toml", "Cargo.lock"}: + return { + "key": "rust-manifest", + "surface": f"Rust manifest: {name}", + "impact": "Rust workspace or package manifest", + "verify": "cargo test plus llvm-cov", + "kind": "rust", + } + if TEST_NAME_RE.search(path): + return { + "key": f"tests:{Path(path).parent.as_posix()}", + "surface": f"Test: {name}", + "impact": "regression suite", + "verify": "targeted test run", + "kind": "tests", + } + if suffix in RUST_SUFFIXES: + return { + "key": "rust-source", + "surface": f"Rust source: {name}", + "impact": "Rust package behavior", + "verify": "cargo test plus llvm-cov", + "kind": "rust", + } + if path.startswith(DOC_PREFIXES): + return { + "key": "docs", + "surface": f"Docs: {name}", + "impact": "operator or user guidance", + "verify": "docs review", + "kind": "docs", + } + if parts and parts[0] == "backend": + return { + "key": "backend", + "surface": f"Backend: {name}", + "impact": "API and service runtime", + "verify": "backend tests", + "kind": "backend", + } + if parts and parts[0] == "frontend": + return { + "key": "frontend", + "surface": f"Frontend: {name}", + "impact": "browser runtime and bundle", + "verify": "frontend tests", + "kind": "frontend", + } + if parts and parts[0] == "src" and suffix in PYTHON_SUFFIXES: + return { + "key": "python-src", + "surface": f"Python package: {name}", + "impact": "Python runtime API", + "verify": "pytest plus coverage", + "kind": "python", + } + if parts and parts[0] == "src" and suffix in TYPESCRIPT_SUFFIXES: + return { + "key": "typescript-src", + "surface": f"TypeScript/JavaScript: {name}", + "impact": "TypeScript or JavaScript runtime", + "verify": "package test plus coverage", + "kind": "typescript", + } + if suffix in PYTHON_SUFFIXES: + return { + "key": "python", + "surface": f"Python: {name}", + "impact": "Python module behavior", + "verify": "pytest plus coverage", + "kind": "python", + } + if suffix in TYPESCRIPT_SUFFIXES: + return { + "key": "typescript", + "surface": f"TypeScript/JavaScript: {name}", + "impact": "TypeScript or JavaScript runtime", + "verify": "package test plus coverage", + "kind": "typescript", + } + if suffix in GO_SUFFIXES: + return { + "key": "go", + "surface": f"Go package: {name}", + "impact": "Go runtime API", + "verify": "go test", + "kind": "go", + } + return { + "key": f"other:{path}", + "surface": f"Repository file: {name}", + "impact": "repository behavior", + "verify": "required checks", + "kind": "other", + } + + +def classify_surfaces(raw_paths: Sequence[str]) -> list[dict[str, str]]: + """Group changed paths into labeled review surfaces.""" + grouped: "OrderedDict[str, dict[str, str]]" = OrderedDict() + for raw_path in raw_paths: + if not str(raw_path).strip(): + continue + classified = classify_changed_path(raw_path) + key = classified["key"] + if key not in grouped: + grouped[key] = { + "surface": classified["surface"], + "impact": classified["impact"], + "verify": classified["verify"], + "kind": classified["kind"], + "count": "1", + } + else: + count = int(grouped[key]["count"]) + 1 + grouped[key]["count"] = str(count) + label = grouped[key]["surface"].split(" (", 1)[0] + grouped[key]["surface"] = f"{label} ({count} files)" + return list(grouped.values()) + + +def rust_api_symbols(source_root: Path | None, raw_paths: Sequence[str]) -> list[str]: + """Extract public Rust API names from changed crate sources when present.""" + if source_root is None: + return [] + names: list[str] = [] + seen: set[str] = set() + for raw_path in raw_paths: + path = posix_path(raw_path) + if Path(path).suffix != ".rs": + continue + candidate = source_root / path + if not candidate.is_file() or candidate.is_symlink(): + continue + text = candidate.read_text(encoding="utf-8", errors="replace") + for match in PUB_ITEM_RE.finditer(text): + name = match.group("name") + if name not in seen: + seen.add(name) + names.append(name) + return names + + +def _quote_label(value: str) -> str: + """Make a Mermaid node label safe for quoted rendering.""" + return value.replace('"', "").replace("\n", " ").replace("\r", " ").strip() + + +def emit_mermaid( + raw_paths: Sequence[str], + merge_state: str = "UNKNOWN", + source_root: Path | None = None, +) -> str: + """Render a source-backed diagram of the changed API, not a file inventory.""" + paths = [posix_path(path) for path in raw_paths if str(path).strip()] + if not paths: + return ( + "```mermaid\n" + "flowchart LR\n" + ' Evidence["OpenCode evidence"] --> Review["Current PR review path"]\n' + ' Review --> Verify["Required checks"]\n' + "```\n" + ) + + symbols = rust_api_symbols(source_root, paths) + rust_paths = [ + path + for path in paths + if path.startswith("crates/") + or path.endswith(".rs") + or path.endswith("Cargo.toml") + or path.endswith("Cargo.lock") + ] + if symbols: + lines = ["```mermaid", "classDiagram"] + for symbol in symbols[:8]: + lines.append(f" class {_quote_label(symbol)}") + lines.append("```") + return "\n".join(lines) + "\n" + if rust_paths: + crate = "Rust crate" + for path in rust_paths: + parts = PurePosixPath(path).parts + if len(parts) > 1 and parts[0] == "crates": + crate = parts[1] + break + return ( + "```mermaid\n" + "sequenceDiagram\n" + f" participant Caller as Caller\n" + f" participant Crate as {_quote_label(crate)}\n" + " participant Tests as Crate tests\n" + " Caller->>Crate: changed public API\n" + " Tests->>Crate: regression coverage\n" + "```\n" + ) + + surfaces = classify_surfaces(paths) + lines = [ + "```mermaid", + "flowchart LR", + ' PR["PR changed files"] --> Evidence["OpenCode bounded evidence"]', + ] + for index, surface in enumerate(surfaces, start=1): + label = _quote_label(surface["surface"]) + impact = _quote_label(surface["impact"]) + verify = _quote_label(surface["verify"]) + lines.append(f' Evidence --> S{index}["{label}"]') + lines.append(f' S{index} --> I{index}["{impact}"]') + if merge_state in {"DIRTY", "CONFLICTING"}: + lines.append(f' I{index} --> Conflict["Merge conflict blocks this path"]') + next_node = "Conflict" + else: + lines.append(f' I{index} --> R{index}["Review risk: {label}"]') + next_node = f"R{index}" + lines.append(f' {next_node} --> V{index}["{verify}"]') + lines.append("```") + return "\n".join(lines) + "\n" + + +def coverage_anchor_allowed(path: str, changed_files: Sequence[str]) -> bool: + """Allow a workflow-file finding only when that file is in the current diff.""" + normalized = posix_path(path) + changed = {posix_path(item) for item in changed_files if str(item).strip()} + return normalized in changed + + +def _language(value: str) -> str: + """Normalize the review-language contract to korean or english.""" + return "korean" if value.strip().casefold() == "korean" else "english" + + +CONTROL_START = ""): + skipping_control = False + continue + lines.append(line) + return "\n".join(lines).strip() + + +def format_structured_findings( + findings: Sequence[object], + changed_files: Sequence[str] | None = None, +) -> str: + """Render control-plane findings as markdown without a fake workflow:1 anchor.""" + allowed = list(changed_files or []) + blocks: list[str] = [] + for index, raw in enumerate(findings, start=1): + if not isinstance(raw, Mapping): + continue + path = str(raw.get("path") or "unknown") + line = raw.get("line") or 0 + location = f"{path}:{line}" + if ( + path == CENTRAL_WORKFLOW_ANCHOR + and str(line) == "1" + and not coverage_anchor_allowed(CENTRAL_WORKFLOW_ANCHOR, allowed) + ): + location = "Review process" + title = str(raw.get("title") or "Finding") + severity = str(raw.get("severity") or "severity").upper() + blocks.append( + "\n".join( + [ + f"### {index}. {severity} {location} - {title}", + f"- Problem: {raw.get('problem') or ''}", + f"- Root cause: {raw.get('root_cause') or ''}", + f"- Fix: {raw.get('fix_direction') or ''}", + f"- Regression test: {raw.get('regression_test_direction') or ''}", + "- Suggested diff: posted in this finding's inline review thread.", + ] + ) + ) + return "\n\n".join(blocks) + + +def _strip_forbidden_workflow_anchor(body: str, changed_files: Sequence[str]) -> str: + """Remove a synthesized central-workflow:1 citation unless that file changed.""" + if coverage_anchor_allowed(CENTRAL_WORKFLOW_ANCHOR, changed_files): + return body + return body.replace(f"{CENTRAL_WORKFLOW_ANCHOR}:1", "Review process") + + +def format_request_changes_review( + *, + model_prose: str, + structured_findings: str = "", + findings: Sequence[object] | None = None, + head_sha: str, + run_id: str, + run_attempt: str, + reason: str = "", + changed_files: Sequence[str] | None = None, +) -> str: + """Keep model walkthrough/diagrams and append structured findings.""" + allowed = list(changed_files or []) + prose = extract_model_prose(model_prose) + rendered = structured_findings.strip() + if not rendered and findings: + rendered = format_structured_findings(findings, allowed) + lines: list[str] = [] + if prose: + lines.extend([prose, ""]) + else: + lines.extend( + [ + "## Verdict", + "", + "REQUEST_CHANGES", + "", + ] + ) + joined = "\n".join(lines) + if rendered and rendered not in joined: + if "## Findings" not in joined: + lines.extend(["## Findings", ""]) + lines.extend([rendered, ""]) + if reason and f"- Reason: {reason}" not in "\n".join(lines): + lines.extend([f"- Reason: {reason}", ""]) + identity = ( + f"- Head SHA: `{head_sha}`", + f"- Workflow run: {run_id}", + f"- Workflow attempt: {run_attempt}", + ) + existing = "\n".join(lines) + if identity[0] not in existing: + lines.extend([*identity, ""]) + body = _strip_forbidden_workflow_anchor("\n".join(lines), allowed) + return body if body.endswith("\n") else body + "\n" + + +def build_status_comment( + *, + result: str, + head_sha: str, + run_id: str, + run_attempt: str, + coverage_result: str, + coverage_summary: str = "", + language: str = "english", + control_block: str = "", + model_pool_outcome: str = "", + verdict: str = "", + formal_review_url: str = "", +) -> str: + """Build the issue-comment gate/status surface without review findings.""" + korean = _language(language) == "korean" + heading = "OpenCode 게이트 상태" if korean else "OpenCode Review Status" + coverage_label = "커버리지 게이트" if korean else "Coverage gate" + lines = [ + "", + f"## {heading}", + "", + f"- Head SHA: `{head_sha}`", + f"- Workflow run: {run_id}", + f"- Workflow attempt: {run_attempt}", + f"- Gate result: `{result}`", + f"- {coverage_label}: `{coverage_result}`", + ] + if model_pool_outcome: + label = "모델 풀" if korean else "Model pool" + lines.append(f"- {label}: `{model_pool_outcome}`") + if verdict: + label = "판정" if korean else "Verdict" + lines.append(f"- {label}: `{verdict}`") + if formal_review_url: + label = "정식 리뷰" if korean else "Formal review" + lines.append(f"- {label}: {formal_review_url}") + lines.append("") + if coverage_result != "success": + blocker = ( + "커버리지 증거 작업이 통과하지 않아 승인은 차단됩니다. 코드 리뷰는 별도 정식 리뷰 본문에 있습니다." + if korean + else ( + "Coverage evidence did not pass, so approval is blocked. " + "The formal pull-request review is the source-backed diff review, " + "not this status comment." + ) + ) + lines.extend([blocker, ""]) + if control_block.strip(): + lines.extend([control_block.strip(), ""]) + _ = coverage_summary + return "\n".join(lines).rstrip() + "\n" + + +def _file_role(path: str) -> str: + """Describe what a changed path is in the review walkthrough.""" + classified = classify_changed_path(path) + return f"`{posix_path(path)}` — {classified['impact']}" + + +def build_fallback_review( + *, + changed_files: Sequence[str], + head_sha: str, + run_id: str, + run_attempt: str, + source_root: Path | None = None, + language: str = "english", + coverage_result: str = "success", +) -> str: + """Build a source-backed formal review of the actual changed product files.""" + paths = [posix_path(path) for path in changed_files if str(path).strip()] + korean = _language(language) == "korean" + overview = "Pull request overview" if not korean else "Pull request 개요" + walkthrough = "Changed files" if not korean else "변경 파일" + diagram = "Changed behavior" if not korean else "변경 동작" + findings = "Findings" if not korean else "발견 사항" + intro = ( + "OpenCode reviewed the current-head product diff. Coverage is a separate gate." + if not korean + else "OpenCode가 현재 head의 제품 diff를 리뷰했습니다. 커버리지는 별도 게이트입니다." + ) + if not paths: + intro = ( + "OpenCode could not list changed product files for this head." + if not korean + else "OpenCode가 이 head의 변경 제품 파일을 나열하지 못했습니다." + ) + lines = [ + f"## {overview}", + "", + intro, + "", + f"## {walkthrough}", + "", + ] + if paths: + lines.extend(f"- {_file_role(path)}" for path in paths) + else: + lines.append("- No changed product files were supplied to the fallback review.") + lines.extend(["", f"## {diagram}", "", emit_mermaid(paths, source_root=source_root).rstrip(), ""]) + symbols = rust_api_symbols(source_root, paths) + if symbols: + api_heading = "Changed API" if not korean else "변경 API" + lines.extend([f"## {api_heading}", ""]) + lines.extend(f"- `{symbol}`" for symbol in symbols) + lines.append("") + lines.extend( + [ + f"## {findings}", + "", + ( + "No source-backed product finding is synthesized from the coverage gate. " + "A coverage miss belongs in the status comment." + if not korean + else "커버리지 게이트만으로 제품 소스 발견 사항을 합성하지 않습니다. 커버리지 결과는 상태 댓글에 둡니다." + ), + "", + f"- Head SHA: `{head_sha}`", + f"- Workflow run: {run_id}", + f"- Workflow attempt: {run_attempt}", + f"- Coverage gate: `{coverage_result}`", + "", + ] + ) + body = "\n".join(lines) + if CENTRAL_WORKFLOW_ANCHOR in body and not coverage_anchor_allowed( + CENTRAL_WORKFLOW_ANCHOR, paths + ): + raise ValueError( + "fallback review must not cite " + f"{CENTRAL_WORKFLOW_ANCHOR} unless that file is in the PR diff" + ) + return body + + +def distinct_surfaces(review_body: str, comment_body: str) -> None: + """Reject publication that pastes the same overview/findings onto both surfaces.""" + if review_body.strip() == comment_body.strip(): + raise ValueError("formal review body must not equal the status comment body") + if "## Pull request overview" in comment_body or "## Pull request 개요" in comment_body: + raise ValueError("status comment must not contain the formal review overview") + if "## Findings" in comment_body or "## 발견 사항" in comment_body: + raise ValueError("status comment must not contain the formal review findings") + if ( + "## OpenCode Review Status" in review_body + or "## OpenCode Review Overview" in review_body + or "## OpenCode 게이트 상태" in review_body + ): + raise ValueError("formal review must not reuse the status-comment heading") + + +def review_event_when_coverage_blocks(model_result: str) -> str: + """Return the GitHub review event when coverage failed but a diff review exists.""" + if model_result == "REQUEST_CHANGES": + return "REQUEST_CHANGES" + return "COMMENT" + + +def read_changed_files(path: Path) -> list[str]: + """Load a newline-delimited changed-file list.""" + return [ + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def _add_common_identity_args(parser: argparse.ArgumentParser) -> None: + """Add the head/run identity flags shared by publisher subcommands.""" + parser.add_argument("--head-sha", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--run-attempt", required=True) + parser.add_argument("--coverage-result", default="unknown") + parser.add_argument("--language", default="english") + + +def main(argv: Sequence[str] | None = None) -> int: + """CLI for trusted review/status rendering from the publisher workflow.""" + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + mermaid = subparsers.add_parser("emit-mermaid", help="Render the changed-API diagram") + mermaid.add_argument("--changed-files-file", type=Path, required=True) + mermaid.add_argument("--source-root", type=Path) + mermaid.add_argument("--merge-state", default="UNKNOWN") + + status = subparsers.add_parser("build-status", help="Render the gate/status comment") + _add_common_identity_args(status) + status.add_argument("--result", required=True) + status.add_argument("--coverage-summary", default="") + status.add_argument("--control-block", default="") + status.add_argument("--model-pool-outcome", default="") + status.add_argument("--verdict", default="") + status.add_argument("--formal-review-url", default="") + + fallback = subparsers.add_parser( + "build-fallback-review", help="Render a source-backed diff review" + ) + _add_common_identity_args(fallback) + fallback.add_argument("--changed-files-file", type=Path, required=True) + fallback.add_argument("--source-root", type=Path) + + extract = subparsers.add_parser( + "extract-prose", help="Strip sentinel and control JSON from model output" + ) + extract.add_argument("--model-body-file", type=Path, required=True) + + request_changes = subparsers.add_parser( + "format-request-changes", + help="Keep model prose and append structured findings", + ) + _add_common_identity_args(request_changes) + request_changes.add_argument("--model-body-file", type=Path) + request_changes.add_argument("--findings-json-file", type=Path) + request_changes.add_argument("--reason", default="") + request_changes.add_argument("--changed-files-file", type=Path) + + args = parser.parse_args(argv) + if args.command == "emit-mermaid": + sys.stdout.write( + emit_mermaid( + read_changed_files(args.changed_files_file), + merge_state=args.merge_state, + source_root=args.source_root, + ) + ) + return 0 + if args.command == "build-status": + sys.stdout.write( + build_status_comment( + result=args.result, + head_sha=args.head_sha, + run_id=args.run_id, + run_attempt=args.run_attempt, + coverage_result=args.coverage_result, + coverage_summary=args.coverage_summary, + language=args.language, + control_block=args.control_block, + model_pool_outcome=args.model_pool_outcome, + verdict=args.verdict, + formal_review_url=args.formal_review_url, + ) + ) + return 0 + if args.command == "extract-prose": + prose = extract_model_prose(args.model_body_file.read_text(encoding="utf-8")) + sys.stdout.write(prose if prose.endswith("\n") else prose + "\n") + return 0 + if args.command == "format-request-changes": + model_body = ( + args.model_body_file.read_text(encoding="utf-8") + if args.model_body_file is not None + else "" + ) + findings: list[object] = [] + if args.findings_json_file is not None: + loaded = json.loads(args.findings_json_file.read_text(encoding="utf-8")) + if isinstance(loaded, list): + findings = loaded + changed = ( + read_changed_files(args.changed_files_file) + if args.changed_files_file is not None + else [] + ) + sys.stdout.write( + format_request_changes_review( + model_prose=model_body, + findings=findings, + head_sha=args.head_sha, + run_id=args.run_id, + run_attempt=args.run_attempt, + reason=args.reason, + changed_files=changed, + ) + ) + return 0 + sys.stdout.write( + build_fallback_review( + changed_files=read_changed_files(args.changed_files_file), + head_sha=args.head_sha, + run_id=args.run_id, + run_attempt=args.run_attempt, + source_root=args.source_root, + language=args.language, + coverage_result=args.coverage_result, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 986982e9a..6a68f66a4 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -92,7 +92,7 @@ env_integer_or_default() { cap_dynamic_cadence_for_queue() { local timeout_cap budget_cap cycle_cap previous_run_timeout previous_budget_seconds previous_max_cycles - timeout_cap="$(env_integer_or_default OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600)" + timeout_cap="$(env_integer_or_default OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 7200)" budget_cap="$(env_integer_or_default OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS 7200)" cycle_cap="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES_CAP 0)" previous_run_timeout="$original_run_timeout" @@ -130,29 +130,12 @@ count_changed_files_for_cadence() { awk 'NF { count += 1 } END { printf "%d\n", count + 0 }' "$changed_files_file" } -should_inline_prompt_evidence_excerpt() { - local model_candidate="$1" - - # GitHub Models OpenAI review endpoints currently reject request bodies - # above roughly 4000 tokens. Keep full evidence available as workspace - # files, but do not inline the excerpt for those candidates. - case "$model_candidate" in - github-models/openai/gpt-5 | github-models/openai/gpt-5-chat | github-models/openai/o3) - return 1 - ;; - *) - return 0 - ;; - esac -} - write_prompt() { local model_candidate="$1" local prompt_file="$2" local intro local contract_file local evidence_excerpt_file - local evidence_file_in_workdir if [ -n "${OPENCODE_REVIEW_INTRO:-}" ]; then intro="$OPENCODE_REVIEW_INTRO" @@ -163,7 +146,6 @@ write_prompt() { # names that Windows and actions/upload-artifact reject. contract_file="$OPENCODE_REVIEW_WORKDIR/opencode-review-contract-${model_candidate//[\/:]/-}.md" evidence_excerpt_file="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence-excerpt.md" - evidence_file_in_workdir="$OPENCODE_REVIEW_WORKDIR/bounded-review-evidence.md" cp "$GITHUB_WORKSPACE/scripts/ci/opencode_review_prompt_template.md" "$contract_file" OPENCODE_REVIEW_INTRO="$intro" \ PROMPT_MODEL_CANDIDATE="$model_candidate" \ @@ -174,17 +156,9 @@ write_prompt() { printf 'Follow the complete review contract in `%s`; use this launcher as a packet-first entry point, not as a reduced policy.\n' "$contract_file" printf 'Read bounded review evidence from `%s` and source files from `%s` when tool access works.\n' "$OPENCODE_EVIDENCE_FILE" "$OPENCODE_SOURCE_WORKDIR" printf 'Use the trusted review workspace `%s` for scripts, prompts, policy files, CodeGraph config, and validation helpers.\n\n' "$OPENCODE_REVIEW_WORKDIR" - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - printf 'First review the current-head evidence excerpt in this prompt. Then inspect full evidence, changed files, focused related code, and configured structural/search tools when available.\n' - else - printf 'The current-head evidence excerpt is not inlined for this GitHub Models OpenAI candidate because that provider rejects large request bodies. First read `%s`, `%s`, changed files, focused related code, and configured structural/search tools before any conclusion.\n' "$evidence_file_in_workdir" "$evidence_excerpt_file" - fi + printf 'First review the current-head evidence excerpt in this prompt. Then inspect full evidence, changed files, focused related code, and configured structural/search tools when available.\n' printf 'Never emit raw tool-call markup, MCP call syntax, function-call JSON, tool_call text, or a JSON array of tool calls. If tool calls or file reads are unavailable, do not emit progress notes or raw tool-call text.\n' - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - printf 'If full-file reads do not execute, use the inlined evidence packet and its repeated current-head sections for Changed files, Focused changed hunks, Coverage execution evidence, Failed GitHub Check evidence, and unresolved thread evidence.\n' - else - printf 'If file reads do not execute for this non-inlined prompt, do not approve from memory or generic confidence. REQUEST_CHANGES only when the visible launcher text or executed file reads provide current-head evidence tied to a positive source/evidence line.\n' - fi + printf 'If full-file reads do not execute, use the inlined evidence packet and its repeated current-head sections for Changed files, Focused changed hunks, Coverage execution evidence, Failed GitHub Check evidence, and unresolved thread evidence.\n' printf 'Do not request changes solely because your tool call, MCP call, or full-file read was not executed. Treat that as a review source limitation unless current-head evidence explicitly reports a materialization failure; any such finding must be tied to that evidence, not a generic model-exhaustion message. REQUEST_CHANGES findings must cite a positive source/evidence line; never use line 0.\n' printf 'Always return a final control block instead of a progress summary. Return only the final review body.\n\n' printf 'Adversarial evidence must state a concrete observed pass, failure, rejection, return value, exit code, or trace outcome and copy exactly one source-line-sha256=<64 lowercase hex> receipt with its matching path and line from the trusted receipt section; generic source-inspection or coverage-verification claims are invalid.\n' @@ -193,8 +167,7 @@ write_prompt() { printf 'Before returning, verify: exactly one top-level current-run control object; non-empty reason, summary, and residual_risk; the required number of complete probes; APPROVE has status=passed, only falsified probes, and findings=[]; REQUEST_CHANGES has status=failed, a confirmed probe, and a same-location source-backed finding.\n' if [ -s "$evidence_excerpt_file" ]; then printf '\nCurrent-head evidence packet:\n\n' - if should_inline_prompt_evidence_excerpt "$model_candidate"; then - python3 - "$evidence_excerpt_file" "${OPENCODE_PROMPT_EVIDENCE_MAX_BYTES:-120000}" <<'PY' + python3 - "$evidence_excerpt_file" "${OPENCODE_PROMPT_EVIDENCE_MAX_BYTES:-120000}" <<'PY' import pathlib import sys @@ -214,9 +187,6 @@ else: ) sys.stdout.buffer.write(tail) PY - else - printf '[Evidence excerpt omitted for `%s` to stay under the GitHub Models OpenAI request-body limit. Read `%s` and `%s` from the review workspace before returning a control block.]\n' "$model_candidate" "$evidence_file_in_workdir" "$evidence_excerpt_file" - fi printf '\n' fi } >"$prompt_file" @@ -387,8 +357,7 @@ fi is_low_sensitivity_candidate() { case "$1" in - openai/*-mini | openai/*-nano | \ - github-models/openai/*-mini | github-models/openai/*-nano) + openai/*-mini | openai/*-nano) return 0 ;; *) @@ -426,14 +395,11 @@ cap_model_run_timeout() { case "$model_candidate" in nvidia-nim/*) - cap_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180)" + cap_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 7200)" ;; opencode-free/*) cap_seconds="$(env_integer_or_default OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600)" ;; - github-models/openai/gpt-5 | github-models/openai/gpt-5-chat) - cap_seconds="$(env_integer_or_default OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS 45)" - ;; *) printf '%s\n' "$run_timeout_seconds" return 0 @@ -476,8 +442,8 @@ run_one_model_attempt() { --title "PR #${PR_NUMBER} OpenCode bounded review ${model_candidate} attempt ${attempt}/${attempts}" \ >"$opencode_json_file" 2>"$opencode_stderr_file" & opencode_pid=$! - # Some providers (github-models ContextOverflowError) log a fatal error and - # then hang instead of exiting, burning the whole run timeout. Watch the JSON + # Some providers log a fatal error and then hang instead of exiting, + # burning the whole run timeout. Watch the JSON # log while opencode runs and kill the process early so the pool falls # through to the next candidate within seconds instead of minutes. while kill -0 "$opencode_pid" 2>/dev/null; do @@ -613,7 +579,21 @@ main() { fi exit 1 fi - nim_budget_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 900)" + has_nim_candidate=0 + for model_candidate in "${model_candidates[@]}"; do + if is_nvidia_nim_candidate "$model_candidate"; then + has_nim_candidate=1 + break + fi + done + if [ "$has_nim_candidate" -eq 1 ] && [ -z "${NVIDIA_NIM_API_KEY:-}" ]; then + printf 'OpenCode model pool requires NVIDIA_NIM_API_KEY; failing closed without GitHub Models fallback.\n' + if finish_pool_without_model; then + exit 0 + fi + exit 1 + fi + nim_budget_seconds="$(env_integer_or_default OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 7200)" nim_elapsed_seconds=0 non_nim_candidate_count=0 for model_candidate in "${model_candidates[@]}"; do diff --git a/scripts/ci/rust_coverage_policy.py b/scripts/ci/rust_coverage_policy.py new file mode 100644 index 000000000..362827849 --- /dev/null +++ b/scripts/ci/rust_coverage_policy.py @@ -0,0 +1,137 @@ +"""Decide how central review should measure a Rust workspace. + +OriginWeave-class repos declare ``rust-version = "1.97"`` and ship +``scripts/ci/verify_coverage.py`` instead of +``workspace.metadata.opencode.coverage.minimum_lines``. Applying the +central default ``--fail-under-lines 100`` on Debian ``rustc`` is a +false blocker; this module prefers the repo verifier when that file +exists. +""" + +from __future__ import annotations + +import argparse +import sys +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + from rust_coverage_threshold import read_minimum_lines +except ImportError: # pragma: no cover - package-style import in CI + from scripts.ci.rust_coverage_threshold import read_minimum_lines + + +@dataclass(frozen=True) +class CoveragePlan: + """How the coverage-evidence job should score a Rust workspace.""" + + mode: str + fail_under: int | None + verifier: Path | None + + +def _parse_manifest(manifest: Path) -> dict[str, Any]: + """Return the Cargo.toml mapping or raise ``ValueError``.""" + try: + parsed = tomllib.loads(manifest.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise ValueError(f"invalid Cargo.toml: {exc}") from exc + if not isinstance(parsed, dict): + raise ValueError("Cargo.toml root must be a table") + return parsed + + +def _opencode_coverage_metadata(parsed: Mapping[str, Any]) -> Mapping[str, Any] | None: + """Return package or workspace ``metadata.opencode.coverage`` when present.""" + for root_key in ("package", "workspace"): + root = parsed.get(root_key) + if not isinstance(root, dict): + continue + metadata = root.get("metadata") + if not isinstance(metadata, dict): + continue + opencode = metadata.get("opencode") + if not isinstance(opencode, dict): + continue + coverage = opencode.get("coverage") + if isinstance(coverage, dict): + return coverage + return None + + +def repo_coverage_verifier(repo_root: Path) -> Path | None: + """Return the repo's coverage verifier script when it exists as a file.""" + for relative in ( + Path("scripts") / "ci" / "verify_coverage.py", + Path("scripts") / "ci" / "verify_coverage.sh", + ): + candidate = repo_root / relative + if candidate.is_file() and not candidate.is_symlink(): + return candidate + return None + + +def coverage_plan(*, repo_root: Path, manifest: Path) -> CoveragePlan: + """Choose llvm-cov threshold vs the repo's own coverage verifier. + + Repos that publish ``workspace.metadata.opencode.coverage`` keep the + central ``cargo llvm-cov --fail-under-lines`` path. Repos that ship + ``scripts/ci/verify_coverage.py`` (or ``.sh``) without that metadata + must not inherit the canned 100% default. Only a workspace with + neither metadata nor a verifier still defaults to 100. + """ + parsed = _parse_manifest(manifest) + metadata = _opencode_coverage_metadata(parsed) + if metadata is not None: + threshold = read_minimum_lines(manifest) + fail_under = 100 if threshold is None else int(threshold) + return CoveragePlan( + mode="llvm-cov-threshold", + fail_under=fail_under, + verifier=None, + ) + verifier = repo_coverage_verifier(repo_root) + if verifier is not None: + return CoveragePlan(mode="repo-verifier", fail_under=None, verifier=verifier) + return CoveragePlan(mode="llvm-cov-threshold", fail_under=100, verifier=None) + + +def rustc_cargo_version_log(*, rustc: str, cargo: str, rustup_show: str = "") -> str: + """Format rustc/cargo identity for the coverage_summary artifact.""" + lines = [ + f"rustc: {rustc.strip() or 'unavailable'}", + f"cargo: {cargo.strip() or 'unavailable'}", + ] + show = rustup_show.strip() + if show: + lines.append(f"rustup show: {show}") + return "\n".join(lines) + "\n" + + +def plan_fields(plan: CoveragePlan) -> str: + """Serialize one coverage plan as tab-separated mode, threshold, verifier.""" + fail_under = "" if plan.fail_under is None else str(plan.fail_under) + verifier = "" if plan.verifier is None else plan.verifier.as_posix() + return f"{plan.mode}\t{fail_under}\t{verifier}\n" + + +def main(argv: list[str] | None = None) -> int: + """Print the coverage plan for one Cargo manifest.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + args = parser.parse_args(argv) + try: + plan = coverage_plan(repo_root=args.repo_root, manifest=args.manifest) + except (OSError, ValueError) as exc: + print(f"invalid Rust coverage policy: {exc}", file=sys.stderr) + return 2 + sys.stdout.write(plan_fields(plan)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index cfc97a63c..3aa81f0e3 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -2499,15 +2499,10 @@ child_model_for_api_base() { fi case "$model" in - openai_direct/*) - printf 'openai/%s\n' "${model#openai_direct/}" - return 0 - ;; - # The workflow contract spells the direct-OpenAI fallback with a hyphen - # (openai-direct/...). litellm cannot infer a provider from that prefix, - # so both spellings must resolve to the litellm openai/ form. - openai-direct/*) - printf 'openai/%s\n' "${model#openai-direct/}" + # The workflow accepts both direct-OpenAI spellings. LiteLLM cannot infer a + # provider from either prefix, so normalize both in this single case arm. + openai_direct/* | openai-direct/*) + printf 'openai/%s\n' "${model#*/}" return 0 ;; esac diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index abcb5ed07..93b497a86 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -514,6 +514,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { local bootstrap_file="$REPO_ROOT/.github/workflows/opencode-review.yml" local workflow_file="$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" local comment_helpers_file="$REPO_ROOT/scripts/ci/opencode_review_comment_helpers.sh" + local surfaces_py="$REPO_ROOT/scripts/ci/opencode_review_surfaces.py" local opencode_config="$REPO_ROOT/opencode.jsonc" assert_file_contains "$bootstrap_file" "pull_request_target:" "opencode required workflow loads its metadata-only bootstrap from the protected base ref" @@ -554,6 +555,11 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "DISPATCH_SENDER: \${{ github.event.sender.login || '' }}" "opencode repository dispatch independently binds the sender identity" assert_file_contains "$workflow_file" 'ALLOWED_DISPATCH_ACTOR: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_ACTOR }}' "opencode repository dispatch uses the protected scheduler identity" assert_file_contains "$workflow_file" 'ALLOWED_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }}' "opencode repository dispatch uses an exact target repository allowlist" + assert_file_not_contains "$workflow_file" "ContextualWisdomLab/Orgmetra" "opencode dispatch must not embed Orgmetra as a workflow fallback literal" + assert_file_not_contains "$bootstrap_file" "ContextualWisdomLab/Orgmetra" "opencode required workflow must not embed Orgmetra as a fallback literal" + assert_file_contains "$bootstrap_file" "opencode_review_receipt_gate.py" "opencode required check verifies a current-head formal review receipt" + assert_file_contains "$workflow_file" "opencode_coverage_identity.py" "opencode dispatch verifies quoted coverage against the canonical exact-head check" + assert_file_contains "$workflow_file" "draft must never receive bot APPROVE" "opencode dispatch refuses draft APPROVE publication" assert_file_contains "$workflow_file" "repository_dispatch authorization rejected actor=" "opencode repository dispatch fails visibly for an unauthorized actor" assert_file_contains "$workflow_file" "repository_dispatch authorization rejected target=" "opencode repository dispatch fails visibly for a disallowed target" assert_file_contains "$workflow_file" '&& github.event_name == '\''repository_dispatch'\''' "opencode coverage and review execution require an authorized default-branch dispatch" @@ -642,8 +648,11 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_not_contains "$workflow_file" 'ref: ${{ github.event.pull_request.base.sha' "opencode trusted checkout avoids dynamic pull_request refs that Scorecard flags" assert_file_not_contains "$workflow_file" 'ref: ${{ github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha || github.sha }}' "opencode review must not checkout PR head into the trusted workflow workspace" assert_file_not_contains "$workflow_file" 'secrets.GITHUB_TOKEN' "opencode review uses github.token instead of a nonexistent GITHUB_TOKEN secret" - assert_file_contains "$workflow_file" 'STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }}' "opencode review uses the organization GitHub Models token secret with GITHUB_TOKEN fallback" - assert_file_not_contains "$workflow_file" 'GITHUB_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }}' "opencode review does not expose GitHub credentials through the generic model environment" + assert_file_not_contains "$workflow_file" "STRIX_GITHUB_MODELS_TOKEN:" "opencode review does not bind a GitHub Models token" + assert_file_not_contains "$workflow_file" "secrets.STRIX_GITHUB_MODELS_TOKEN" "opencode review does not use a GitHub Models secret" + assert_file_contains "$workflow_file" "attach_contextual_orchestrator_provider.py" "opencode review may attach contextual-orchestrator when CONTEXTUAL_ORCHESTRATOR_URL is set" + assert_file_contains "$workflow_file" "vars.CONTEXTUAL_ORCHESTRATOR_URL" "opencode review treats the orchestrator URL as optional" + assert_file_not_contains "$workflow_file" "COPILOT_GITHUB_TOKEN" "opencode review does not introduce COPILOT_GITHUB_TOKEN" assert_file_contains "$workflow_file" 'is_private: ${{ steps.validate.outputs.is_private }}' "opencode review carries validated repository privacy into model routing" assert_file_contains "$workflow_file" '"opencode-free"' "opencode review enables its anonymous Zen free provider" assert_file_contains "$workflow_file" '"baseURL": "https://opencode.ai/zen/v1"' "opencode review routes the free provider through the official Zen endpoint" @@ -652,7 +661,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" '"apiKey": "{env:NVIDIA_API_KEY}"' "opencode review resolves normalized NVIDIA NIM credentials at runtime" assert_file_contains "$workflow_file" 'NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}' "opencode review exposes NVIDIA NIM credentials only to the model runtime" assert_file_contains "$workflow_file" '"north-mini-code-free"' "opencode review declares the current Zen coding model" - assert_file_contains "$workflow_file" "needs.validate-pr-metadata.outputs.is_private == 'false'" "opencode review limits data-retaining free models to public repositories" + assert_file_not_contains "$workflow_file" "needs.validate-pr-metadata.outputs.is_private == 'false'" "opencode review no longer skips NIM or free-tier candidates by repository privacy" assert_file_matches "$workflow_file" 'uses:[[:space:]]+actions/checkout@[0-9a-fA-F]{40}([[:space:]]|$)' "opencode review workflow pins checkout to a full commit SHA" assert_workflow_uses_are_sha_pinned "$workflow_file" "opencode review workflow" assert_file_contains "$workflow_file" "scripts/ci/codegraph-package/package-lock.json" "opencode review workflow installs CodeGraph from the committed lockfile" @@ -759,31 +768,33 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" 'timeout-minutes: 205' "opencode model pool preserves full-hour candidates within a bounded provider-pool window" assert_file_contains "$workflow_file" 'timeout-minutes: 34' "opencode fast approval publication is bounded around the dynamic image and package/GPU check wait" assert_file_contains "$workflow_file" 'continue-on-error: true' "opencode approval gate still runs after model-pool failure to publish a reason" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' "opencode primary review preserves legitimate full-hour provider sessions" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "7200"' "opencode primary review preserves legitimate two-hour provider sessions" assert_file_contains "$workflow_file" 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS: "3600"' "opencode free-tier failover timeout is hour-class (~3600s)" -assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "180"' "opencode NVIDIA NIM candidates have a short per-candidate failover timeout" -assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "900"' "opencode NVIDIA NIM candidates share a bounded combined runtime budget" +assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "7200"' "opencode NVIDIA NIM candidates have a two-hour per-candidate timeout" +assert_file_contains "$workflow_file" 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "7200"' "opencode NVIDIA NIM candidates share a two-hour combined runtime budget" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_RUN_TIMEOUT_SECONDS:-3600' "opencode pool defaults primary run timeout to hour-class (~3600s) for large repos" -assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 3600' "opencode pool dynamic timeout cap defaults to hour-class (~3600s)" +assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS 7200' "opencode pool dynamic timeout cap defaults to two-hour class (~7200s)" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS 3600' "opencode free-tier failover timeout is hour-class (~3600s)" -assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 180' "opencode NVIDIA NIM candidate runtime cap defaults to three minutes" -assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 900' "opencode NVIDIA NIM combined runtime cap defaults to fifteen minutes" +assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS 7200' "opencode NVIDIA NIM candidate runtime cap defaults to two hours" +assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS 7200' "opencode NVIDIA NIM combined runtime cap defaults to two hours" assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" assert_file_contains "$workflow_file" 'OPENCODE_POOL_MAX_CYCLES: "1"' "opencode model pool exhausts each candidate only once before bounded fallback" assert_file_not_contains "$workflow_file" 'opencode-exhausted-retry:' "opencode model exhaustion retries stay owned by the least-privilege central scheduler" assert_file_not_contains "$workflow_file" 'RETRY_DISPATCH_TOKEN' "opencode does not retain a recursive write-token dispatch path" - assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode model pool only runs after coverage evidence passed" + assert_file_contains "$workflow_file" "needs.coverage-evidence.result != 'cancelled'" "opencode model pool still runs when coverage evidence failed so the diff can be reviewed" assert_file_contains "$workflow_file" "id: opencode_review_model_pool" "opencode DeepSeek V3 fallback still runs after a primary model timeout or step failure when coverage evidence passed" assert_file_contains "$workflow_file" "always()" "opencode fallback chain uses always() so failed model steps cannot skip every fallback" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode fallback tries the catalog promptly instead of spending the entire review on one model" assert_file_contains "$workflow_file" "Run OpenCode PR Review model pool" "opencode review includes a broad catalog fallback pool" assert_file_not_contains "$workflow_file" "steps.opencode_review_model_pool.outcome == 'success'" "opencode approval gate still runs after model pool failure to publish a reason" assert_file_contains "$workflow_file" "opencode-free/north-mini-code-free" "opencode review starts public repository reviews with a free coding model" - assert_file_contains "$workflow_file" "opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5" "opencode review retains paid Zen and DeepSeek V3 before full-size GPT fallbacks" + assert_file_contains "$workflow_file" "openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder" "opencode review keeps keyed GPT-5.4 and OpenRouter after NIM and free-tier" + assert_file_not_contains "$workflow_file" "opencode/gpt-5.6-terra" "opencode review omits Copilot-class Zen Terra from the model pool" + assert_file_not_contains "$workflow_file" "github-models/openai/gpt-4.1 github-models/openai/gpt-5" "opencode review omits github-models GPT fallbacks from the model pool" assert_file_contains "$workflow_file" "The publish gate re-runs source-backed validation against PR-head data" "opencode review publish gate validates model output against the PR-head worktree" - assert_file_contains "$workflow_file" '"openai/o3"' "opencode config declares OpenAI o3 fallback" - assert_file_contains "$workflow_file" '"openai/o4-mini"' "opencode config declares OpenAI o4-mini fallback" + assert_file_not_contains "$workflow_file" '"openai/o3"' "opencode isolated catalog no longer declares GitHub Models OpenAI o3" + assert_file_not_contains "$workflow_file" '"openai/o4-mini"' "opencode isolated catalog no longer declares GitHub Models OpenAI o4-mini" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OpenCode %s attempt %s/%s failed with exit %s.' "opencode review logs per-model retry attempts" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "emit_sanitized_opencode_failure_detail" "opencode review logs a bounded provider reason after each failed attempt" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OpenCode provider failure metadata" "opencode review labels provider failure classes in the check log" @@ -822,7 +833,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" "inspect the PR head and available changed-file evidence directly" "opencode focused hunk fallback does not depend on changed-files.txt existing" assert_file_contains "$workflow_file" '-- "${focused_hunk_paths[@]}"' "opencode review evidence passes dynamic changed paths to git diff" assert_file_contains "$workflow_file" "do not return file-inaccessible findings" "opencode review prompt forbids placeholder inaccessible-file findings when hunks are present" - assert_file_contains "$workflow_file" "Do not include analysis, planning, tool-call narration, placeholders, or prose before the sentinel." "opencode review prompt forbids reasoning text before the control sentinel" + assert_file_contains "$workflow_file" "Write the Verdict / Findings / Test Gaps review first, then append the sentinel and control JSON. Do not include analysis, planning, tool-call narration, placeholders, or prose that is not part of that review structure." "opencode review prompt writes Verdict/Findings first, then control JSON, without tool-call narration" assert_file_contains "$workflow_file" "OpenCode output did not include a valid control conclusion." "opencode review model steps fail when output lacks a parseable control conclusion" assert_file_contains "$workflow_file" 'bash "$GITHUB_WORKSPACE/scripts/ci/opencode_review_approve_gate.sh" "$HEAD_SHA" "$RUN_ID" "$RUN_ATTEMPT" "$output_file"' "opencode review model steps validate the control block before publishing" assert_file_contains "$workflow_file" 'if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_review_normalize_output.py" \' "opencode review model steps normalize before approval gate validation" @@ -928,11 +939,13 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_not_contains "$workflow_file" "no model produced a valid review control block" "opencode model-failure path no longer documents a final exhausted state" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode primary and fallback paths avoid multi-attempt stalls on one model" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode catalog fallback tries each model once before moving on" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' "opencode catalog fallback preserves legitimate full-hour provider sessions" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "7200"' "opencode catalog fallback preserves legitimate two-hour provider sessions" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OpenCode %s attempt %s/%s failed" "opencode catalog fallback records per-model retry failures" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "exponential backoff" "opencode model retry paths use exponential backoff instead of fixed sleeps" - assert_file_contains "$workflow_file" "opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5" "opencode review tries paid Zen and DeepSeek V3 before OpenAI fallbacks" - assert_file_contains "$workflow_file" "github-models/deepseek/deepseek-r1-0528 github-models/deepseek/deepseek-r1" "opencode review keeps DeepSeek reasoning fallback coverage after OpenAI candidates" + assert_file_contains "$workflow_file" "openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder" "opencode review tries keyed GPT-5.4 and OpenRouter after NIM and free-tier" + assert_file_not_contains "$workflow_file" "opencode/gpt-5.6-terra" "opencode catalog fallback omits Copilot-class Zen Terra from the model pool" + assert_file_not_contains "$workflow_file" '"deepseek/deepseek-r1-0528"' "opencode isolated catalog no longer defines GitHub Models DeepSeek R1 0528" + assert_file_not_contains "$workflow_file" '"deepseek/deepseek-r1"' "opencode isolated catalog no longer defines GitHub Models DeepSeek R1" assert_file_contains "$workflow_file" "coverage-source-tree:" "opencode workflow materializes coverage source before running PR-head tests" assert_file_contains "$workflow_file" "coverage-evidence:" "opencode workflow measures coverage before review" assert_file_contains "$workflow_file" "Materialize pull request merge tree for coverage measurement" "required OpenCode reviews measure coverage instead of approving skipped coverage evidence" @@ -1053,12 +1066,13 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$merge_scheduler_workflow" 'repos/${GITHUB_REPOSITORY}/commits/${REVIEW_HEAD_SHA}/check-runs?per_page=100' "review-event scheduler reads exact-head OpenCode completion evidence" assert_file_contains "$merge_scheduler_workflow" "The scheduled organization sweep remains authoritative." "review-event scheduler logs its fallback when direct follow-up cannot proceed" assert_file_contains "$workflow_file" 'build_coverage_evidence_check_failure_body()' "opencode approval can describe a coverage-evidence blocker" - assert_file_contains "$workflow_file" 'request_changes_for_coverage_evidence_failure' "opencode approval publishes REQUEST_CHANGES when coverage-evidence did not pass" - assert_file_contains "$workflow_file" "publish REQUEST_CHANGES when coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence are present" "opencode approval turns coverage-evidence blocker states into actionable review state" - assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode model steps skip when coverage-evidence already failed" + assert_file_contains "$workflow_file" 'request_changes_for_coverage_evidence_failure' "opencode approval records coverage-evidence failure on the status comment without replacing the diff review" + assert_file_contains "$workflow_file" "record coverage-evidence blocker states such as cancelled, skipped, failed, unsupported-tooling, or below-100 evidence in the status comment and refuse APPROVE while still publishing a source-backed review of changed product files" "opencode approval turns coverage-evidence blocker states into a status-comment gate" + assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode fast approval still requires coverage evidence success" + assert_file_contains "$workflow_file" "publish_fallback_diff_review" "opencode still publishes a source-backed product-file review when coverage-evidence failed" assert_file_contains "$workflow_file" "supported repository test suites passed" "opencode coverage evidence requires supported repository test suites to pass" assert_file_contains "$workflow_file" "rust_coverage_manifests()" "opencode coverage evidence discovers nested Cargo manifests for changed Rust files" - assert_file_contains "$workflow_file" 'cargo llvm-cov --manifest-path "$manifest"' "opencode coverage evidence runs Rust coverage against nested Cargo packages" + assert_file_contains "$workflow_file" 'cargo llvm-cov --offline --locked --manifest-path "$manifest"' "opencode coverage evidence runs offline locked Rust coverage against nested Cargo packages" assert_file_contains "$workflow_file" "ensure_tauri_frontend_dist()" "opencode coverage evidence prepares local Tauri frontendDist assets before Rust coverage" assert_file_contains "$workflow_file" "Tauri frontendDist build" "opencode coverage evidence labels Tauri frontend build logs before cargo coverage" assert_file_contains "$workflow_file" 'npm run build --workspace "$package_name"' "opencode coverage evidence builds npm workspace Tauri frontends before cargo coverage" @@ -1067,6 +1081,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" "package.metadata.opencode.coverage.minimum_lines" "opencode coverage evidence documents the Rust coverage baseline metadata key" assert_file_contains "$workflow_file" "workspace.metadata.opencode.coverage.minimum_lines" "opencode coverage evidence supports virtual-workspace Rust coverage baselines" assert_file_contains "$workflow_file" "scripts/ci/rust_coverage_threshold.py" "opencode coverage evidence uses the tested trusted Rust threshold parser" + assert_file_contains "$workflow_file" "scripts/ci/rust_coverage_policy.py" "opencode coverage evidence prefers a repo verifier over a canned 100 percent default" assert_file_contains "$workflow_file" '--fail-under-lines "$threshold"' "opencode coverage evidence enforces the resolved Rust line coverage threshold" assert_file_contains "$workflow_file" "'requirements.txt' '*/requirements.txt'" "opencode coverage evidence discovers nested requirements-only Python test projects" assert_file_contains "$workflow_file" "configured_python_ci_test_commands()" "opencode coverage evidence prefers repository-configured CI pytest commands before falling back to the full tests tree" @@ -1214,7 +1229,8 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" 'select((.status // "") != "COMPLETED")' "opencode approval treats incomplete check runs as approval blockers" assert_file_contains "$workflow_file" '["PENDING","EXPECTED"]' "opencode approval treats pending status contexts as approval blockers" assert_file_contains "$workflow_file" "" "opencode review publishes a durable Review Overview marker" - assert_file_contains "$workflow_file" "## OpenCode Review Overview" "opencode review publishes a visible Review Overview heading" + assert_file_contains "$workflow_file" "opencode_review_surfaces.py build-status" "opencode review publishes a gate-status comment instead of pasting the formal review body" + assert_file_contains "$surfaces_py" "OpenCode Review Status" "opencode status comment uses a distinct heading from the formal review" assert_file_contains "$workflow_file" 'gh api -X PATCH "repos/${GH_REPOSITORY}/issues/comments/${overview_comment_id}"' "opencode review updates an existing Review Overview comment instead of duplicating it" assert_file_contains "$workflow_file" "Exchange OpenCode app token for review writes" "opencode review obtains an app token before publishing review writes" assert_file_contains "$workflow_file" 'OPENCODE_APP_TOKEN_EXCHANGE_TIMEOUT_SECONDS: "20"' "opencode app-token exchange has a bounded network timeout" @@ -1241,7 +1257,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" 'approving based on source-backed OpenCode result and successful coverage evidence while branch protection remains authoritative' "opencode source-backed approval tolerates app-token-limited failed-check lookup" assert_file_contains "$workflow_file" 'opencode-agent[bot]' "opencode review can find overview comments written by the OpenCode app token" assert_file_contains "$workflow_file" 'update_review_overview()' "opencode approval step can rewrite the durable Review Overview after final gate decisions" - assert_file_contains "$workflow_file" 'update_review_overview "$event" "$body"' "opencode approval reviews refresh the durable overview with the actual approval-step event" + assert_file_contains "$workflow_file" 'update_review_overview "$event"' "opencode approval reviews refresh the durable status comment without copying the review body" assert_file_contains "$workflow_file" 'env GH_TOKEN="$overview_comment_token"' "opencode approval overview updates use the workflow comment token" assert_file_contains "$workflow_file" 'warn_gh_publication_failure()' "opencode approval reports PR review/comment publication errors" assert_file_contains "$workflow_file" 'OpenCode could not publish %s; the requested GitHub side effect is unavailable.' "opencode approval explains permission-denied publication failures" @@ -1282,14 +1298,16 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$workflow_file" 'repos/${GH_REPOSITORY}' "opencode review workflow uses env-backed repository context in shell commands" assert_file_contains "$workflow_file" "Run OpenCode PR Review model pool" "opencode review starts the central model pool" assert_file_contains "$workflow_file" "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 nvidia-nim/nvidia/nemotron-3-super-120b-a12b nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b nvidia-nim/meta/llama-3.3-70b-instruct nvidia-nim/deepseek-ai/deepseek-v4-pro nvidia-nim/mistralai/codestral-22b-instruct-v0.1 opencode-free/nemotron-3-ultra-free" "opencode review keeps all NVIDIA NIM candidates inside the public-repository pool" - assert_file_contains "$workflow_file" "opencode/gpt-5.6-terra github-models/deepseek/deepseek-v3-0324 openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder github-models/openai/gpt-4.1 github-models/openai/gpt-5" "opencode review keeps paid Zen, DeepSeek V3, and full-size GPT fallbacks" - assert_file_contains "$workflow_file" "github-models/deepseek/deepseek-r1-0528" "opencode review keeps a reachable DeepSeek R1 reasoning fallback model" - assert_file_contains "$workflow_file" "github-models/deepseek/deepseek-v3-0324" "opencode review has a reachable DeepSeek V3 fallback model" + assert_file_contains "$workflow_file" "openai/gpt-5.4 openrouter/deepseek/deepseek-v3.2 openrouter/qwen/qwen3-coder" "opencode review keeps keyed GPT-5.4 and OpenRouter after NIM and free-tier" + assert_file_not_contains "$workflow_file" "opencode/gpt-5.6-terra" "opencode review omits Copilot-class Zen Terra from the model pool" + assert_file_not_contains "$workflow_file" '"deepseek/deepseek-r1-0528"' "opencode isolated catalog no longer defines GitHub Models DeepSeek R1" + assert_file_contains "$workflow_file" "MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" "opencode publish-stage diagnosis uses NVIDIA NIM" + assert_file_not_contains "$workflow_file" "MODEL: github-models/" "opencode publish-stage diagnosis does not use GitHub Models" assert_file_not_contains "$workflow_file" "secrets.NVIDIA_NIM_API_KEY || secrets.NVIDIA_API_KEY" "opencode review never falls back from the scoped NVIDIA NIM secret to the legacy provider secret" assert_file_contains "$workflow_file" 'NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}' "opencode review binds only the scoped NVIDIA NIM secret into the provider environment" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "NVIDIA_NIM_API_KEY" "model pool normalizes NVIDIA_NIM_API_KEY to NVIDIA_API_KEY" - assert_file_contains "$workflow_file" "github-models/openai/gpt-5" "opencode review still has a bounded GPT-5 fallback model" + assert_file_not_contains "$workflow_file" '"openai/gpt-5"' "opencode isolated catalog no longer defines GitHub Models GPT-5" assert_file_contains "$workflow_file" "Publish bounded OpenCode review comment" "opencode review workflow publishes the agent control comment for the approval gate" assert_file_contains "$workflow_file" "statusCheckRollup" "opencode review workflow reads current-head GitHub Checks before approval" assert_file_contains "$workflow_file" "OPENCODE_FAILED_CHECK_EVIDENCE_FILE" "opencode review workflow persists failed-check evidence across review and approval steps" @@ -1369,6 +1387,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_normalize_output.py" "NON_ACTIONABLE_FAILED_CHECK_REVIEW_PHRASES" "opencode normalizer rejects generic failed-check deflections before publishing" assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "extract_strix_report_model_markers" "failed-check review validator extracts model markers from Strix vulnerability report windows" assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "(?:model|for model)[[:space:]]+" "failed-check review validator reads both Model and for model lines inside Strix reports" + assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "nvidia[-_]nim" "failed-check review validator model patterns accept the nvidia-nim provider" assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "Self-test Strix gate script" "failed-check review validator requires Strix failed step evidence" assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "github.event.client_payload.strix_llm" "failed-check review validator requires exact Strix missing assertion evidence" assert_file_contains "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" "extract_strix_required_markers" "failed-check review validator extracts Strix report titles and locations" @@ -1400,9 +1419,9 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_equals "2" "$graph_helper_sources" "opencode sources the trusted graph helper library in both review publication scopes" assert_file_contains "$workflow_file" "rewritten_payload_file" "opencode inline review payload is rewritten after graph insertion" assert_file_contains "$workflow_file" '.body = $body' "opencode inline review payload JSON receives the same logged review body" - assert_file_contains "$comment_helpers_file" "OpenCode bounded evidence" "opencode Mermaid graph ties changed files to bounded review evidence" - assert_file_contains "$comment_helpers_file" "GitHub Actions review job" "opencode Mermaid graph maps workflow files to the affected execution path" - assert_file_contains "$comment_helpers_file" "Merge conflict blocks this path" "opencode merge-conflict guidance shows which changed-file flow is blocked" + assert_file_contains "$surfaces_py" "OpenCode bounded evidence" "opencode Mermaid graph ties changed files to bounded review evidence" + assert_file_contains "$surfaces_py" "GitHub Actions review job" "opencode Mermaid graph maps workflow files to the affected execution path" + assert_file_contains "$surfaces_py" "Merge conflict blocks this path" "opencode merge-conflict guidance shows which changed-file flow is blocked" assert_file_contains "$workflow_file" "Mermaid DAG" "opencode prompt asks for a Mermaid DAG instead of a generic risk sketch" assert_file_contains "$workflow_file" 'quoted label, for example A["text"]' "opencode prompt avoids shell-executed backtick examples for Mermaid labels" assert_file_not_contains "$workflow_file" '`A["text"]`' "opencode prompt must not put Mermaid label examples in shell-substituted backticks" @@ -1432,7 +1451,7 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$REPO_ROOT/ci-review-prompt.md" "The model is intentionally isolated from execution and the network." "opencode checked-in prompt documents the isolated model boundary" assert_file_contains "$REPO_ROOT/ci-review-prompt.md" "Execution provenance is mandatory" "opencode prompt prohibits unsupported browser execution claims" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_normalize_output.py" "OPENCODE_EXECUTION_RECEIPTS_FILE" "opencode normalizer requires trusted runtime execution receipts" - assert_file_contains "$workflow_file" "Published compact coverage decision output" "opencode coverage output excludes full logs that GitHub may suppress as secret-bearing" + assert_file_contains "$workflow_file" "Published full rust/python/js coverage measurement log" "opencode coverage_summary includes the full rust/python/js measurement log" assert_file_not_contains "$workflow_file" '"bash": "allow"' "opencode generated config denies bash" assert_file_not_contains "$workflow_file" '"task": "allow"' "opencode generated config denies task delegation" assert_file_not_contains "$workflow_file" '"webfetch": "allow"' "opencode generated config denies webfetch" @@ -1470,15 +1489,15 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_not_contains "$REPO_ROOT/scripts/ci/emit_opencode_failed_check_fallback_findings.sh" "Strix provider quota blocked current-head security evidence" "failed-check fallback avoids misleading quota-only provider blocker title" assert_file_contains "$workflow_file" "- Root cause:" "opencode review request-changes body includes root cause per finding" assert_file_contains "$workflow_file" "- Regression test:" "opencode review request-changes body includes regression test direction per finding" - assert_file_contains "$workflow_file" "- Suggested diff:" "opencode review request-changes body includes suggested diff per finding" + assert_file_contains "$surfaces_py" "- Suggested diff:" "opencode review request-changes body includes suggested diff per finding" assert_file_contains "$workflow_file" "OpenCode reviewed the current-head bounded evidence and found source-backed failed-check findings that must be addressed before merge." "opencode review workflow requests changes only when current-head failed checks are mapped to source-backed findings" assert_file_contains "$workflow_file" "OpenCode reviewed the current-head evidence but could not verify peer GitHub Checks before approval." "opencode review workflow explains check lookup failures instead of approving" assert_file_contains "$workflow_file" '["FAILURE","TIMED_OUT","ACTION_REQUIRED","CANCELLED","STARTUP_FAILURE"]' "opencode review workflow treats failed check-run conclusions as request-changes blockers" assert_file_contains "$workflow_file" '["FAILURE","ERROR"]' "opencode review workflow treats failed status contexts as request-changes blockers" assert_file_not_contains "$workflow_file" "MODEL: github-models/gpt-4.1" "opencode review must not fall back to GPT-4.1" - assert_file_contains "$workflow_file" "github-models/openai/gpt-5-chat" "opencode review includes GitHub Models GPT-5 chat as a catalog fallback" + assert_file_not_contains "$workflow_file" '"openai/gpt-5-chat"' "opencode isolated catalog no longer defines GitHub Models GPT-5 chat" assert_file_not_contains "$workflow_file" "github-models/openai/gpt-4.1-mini" "opencode review does not fall back to GPT-4.1 mini review evidence" - assert_file_contains "$workflow_file" "github-models/openai/gpt-5" "opencode review includes GitHub Models GPT-5 as a catalog fallback" + assert_file_not_contains "$workflow_file" '"openai/gpt-5"' "opencode isolated catalog no longer defines GitHub Models GPT-5" assert_file_not_contains "$workflow_file" "github-models/openai/gpt-5-mini" "opencode review excludes GitHub Models GPT-5 mini from the high-sensitivity review pool" assert_file_contains "$opencode_config" '"mcp": {}' "opencode config disables all model-runtime MCP servers" @@ -1489,15 +1508,10 @@ assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" ' assert_file_contains "$opencode_config" '"model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5"' "opencode config defaults review sessions to NVIDIA NIM Nemotron Super" assert_file_contains "$opencode_config" '"nvidia-nim"' "opencode config enables nvidia-nim provider" assert_file_contains "$opencode_config" 'integrate.api.nvidia.com' "opencode config points nvidia-nim at NIM API" - assert_file_contains "$opencode_config" '"openai/gpt-5"' "opencode config defines GitHub Models GPT-5 with full model id" - assert_file_contains "$opencode_config" '"openai/gpt-5-chat"' "opencode config defines GPT-5 Chat catalog fallback" - assert_file_contains "$opencode_config" '"openai/gpt-5-mini"' "opencode config defines GPT-5 Mini catalog fallback" - assert_file_contains "$opencode_config" '"deepseek/deepseek-r1-0528"' "opencode config defines DeepSeek R1 fallback" - assert_file_contains "$opencode_config" '"deepseek/deepseek-v3-0324"' "opencode config defines DeepSeek V3 fallback" - assert_file_contains "$opencode_config" '"context": 200000' "opencode config uses the GitHub Models GPT-5 200k context window" - assert_file_contains "$opencode_config" '"output": 100000' "opencode config uses the GitHub Models GPT-5 100k output window" - assert_file_contains "$opencode_config" '"openai/gpt-4.1"' "opencode config defines the GitHub Models GPT-4.1 fallback" - assert_file_contains "$opencode_config" '"reasoningEffort": "high"' "opencode config keeps high reasoning effort for capable review models" + assert_file_not_contains "$opencode_config" "github-models" "opencode config no longer enables GitHub Models" + assert_file_not_contains "$opencode_config" "STRIX_GITHUB_MODELS_TOKEN" "opencode config does not bind a GitHub Models token" + assert_file_not_contains "$opencode_config" '"openai/gpt-5"' "opencode config no longer defines GitHub Models GPT-5" + assert_file_contains "$opencode_config" '"enabled_providers": ["nvidia-nim"]' "opencode config enables only NVIDIA NIM" } assert_opencode_review_posts_suggested_diffs_inline() { @@ -1538,6 +1552,7 @@ assert_pr_review_merge_scheduler_uses_github_actions_bot_token() { assert_file_contains "$workflow_file" "github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number)" "scheduler scopes pull_request_target concurrency to the active PR" assert_file_contains "$workflow_file" "github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number)" "scheduler scopes workflow_run concurrency to the completed review PR" assert_file_contains "$workflow_file" "github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule)" "scheduler isolates the 15-minute organization sweep from the separate 30-minute scheduled scan" + assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository)" "scheduler keeps manual queue scans isolated per repository dispatch target" assert_file_contains "$workflow_file" "github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != ''" "scheduler scopes targeted manual queue scans to the requested PR" assert_file_contains "$workflow_file" "cancel-in-progress: \${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }}" "scheduler cancels stale PR/review/manual queue scans instead of accumulating merge/update attempts" assert_file_contains "$workflow_file" "timeout-minutes: 60" "organization sweep has enough headroom to finish the complete repository walk" @@ -1998,8 +2013,8 @@ EOF assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" 'awk '\''NF > 0 && $0 !~ /^\// && $0 !~ /(^|\/)\.\.($|\/)/ { print }'\'' >"$OPENCODE_CHANGED_FILES_FILE"' "opencode workflow writes path-safe exact changed files for the normalizer" assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" "changed-files.txt" "opencode workflow copies exact changed-file evidence into the isolated review workspace" assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" 'A["text"]' "opencode prompt requires quoted Mermaid labels" - assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_comment_helpers.sh" 'S%s["%s"]' "opencode generated Mermaid surface labels are quoted" - assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_comment_helpers.sh" 'R%s["Review risk: %s"]' "opencode generated Mermaid risk labels are quoted" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_surfaces.py" 'S{index}["{label}"]' "opencode generated Mermaid surface labels are quoted" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_review_surfaces.py" 'R{index}["Review risk: {label}"]' "opencode generated Mermaid risk labels are quoted" assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" 'emit_review_body_to_action_log "$event" "$body"' "opencode PR-level review bodies are mirrored to the Actions log" assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" 'emit_review_body_to_action_log "$event" "$body" "$review_payload_file"' "opencode inline review bodies are mirrored to the Actions log" assert_file_contains "$REPO_ROOT/.github/workflows/opencode-review-dispatch.yml" 'OpenCode is publishing this review content to PR #%s.' "opencode Actions log includes the review body that is being posted" @@ -2395,6 +2410,44 @@ EOF set -e assert_equals "0" "$rc" "failed-check review validator accepts Strix log-backed findings" + cat >"$evidence_file" <<'EOF' +## Failed check: Strix Security Scan/strix + +### Failed job steps + +- step 6: Self-test Strix gate script (failure) + +### Strix vulnerability report window 1 + +Model nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 Vulnerabilities 1 +│ Vulnerability Report │ +│ Title: Authentication Bypass via X-Dev-User Header │ +│ Severity: CRITICAL │ +│ Endpoint: /api/me │ +│ Method: GET │ +│ Location 1: backend/app/auth.py:132-135 │ +EOF + cat >"$control_json" <<'EOF' +{"head_sha":"abc123","run_id":"42","run_attempt":"1","result":"REQUEST_CHANGES","reason":"Strix Security Scan/strix failed","summary":"Strix Security Scan/strix failed in Self-test Strix gate script and reported Authentication Bypass via X-Dev-User Header with Severity: CRITICAL at backend/app/auth.py:132-135.","findings":[{"path":"backend/app/auth.py","line":132,"severity":"CRITICAL","title":"Authentication Bypass via X-Dev-User Header","problem":"Strix Security Scan/strix failed with a Vulnerability Report for Authentication Bypass via X-Dev-User Header, Severity: CRITICAL, /api/me, Method: GET, backend/app/auth.py:132-135.","root_cause":"The review mapped the Strix title and location but omitted the NIM model id from the report window.","fix_direction":"Remove the unauthenticated fallback at backend/app/auth.py:132-135.","regression_test_direction":"Add auth tests for the request path.","suggested_diff":"diff --git a/backend/app/auth.py b/backend/app/auth.py\n--- a/backend/app/auth.py\n+++ b/backend/app/auth.py\n@@ -132 +132 @@\n-old\n+new"}]} +EOF + set +e + bash "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" \ + "$control_json" "$failed_checks_file" "$evidence_file" >"$tmp_dir/nim-omit.out" 2>"$tmp_dir/nim-omit.err" + rc=$? + set -e + assert_equals "4" "$rc" "failed-check review validator requires mapping nvidia-nim report models" + assert_file_contains "$tmp_dir/nim-omit.out" "Strix vulnerability reports were not mapped to distinct source-backed findings" "failed-check validator treats nvidia-nim report windows as known models, not unknown-model" + + cat >"$control_json" <<'EOF' +{"head_sha":"abc123","run_id":"42","run_attempt":"1","result":"REQUEST_CHANGES","reason":"Strix Security Scan/strix failed","summary":"Strix Security Scan/strix failed in Self-test Strix gate script and reported nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 Authentication Bypass via X-Dev-User Header with Severity: CRITICAL at backend/app/auth.py:132-135.","findings":[{"path":"backend/app/auth.py","line":132,"severity":"CRITICAL","title":"Authentication Bypass via X-Dev-User Header","problem":"Strix Security Scan/strix failed with nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 Vulnerability Report Authentication Bypass via X-Dev-User Header, Severity: CRITICAL, /api/me, Method: GET, backend/app/auth.py:132-135.","root_cause":"The failed Strix NIM report identifies the backend auth fallback line.","fix_direction":"Remove the unauthenticated fallback at backend/app/auth.py:132-135.","regression_test_direction":"Add auth tests for the request path.","suggested_diff":"diff --git a/backend/app/auth.py b/backend/app/auth.py\n--- a/backend/app/auth.py\n+++ b/backend/app/auth.py\n@@ -132 +132 @@\n-old\n+new"}]} +EOF + set +e + bash "$REPO_ROOT/scripts/ci/validate_opencode_failed_check_review.sh" \ + "$control_json" "$failed_checks_file" "$evidence_file" >"$tmp_dir/nim-ok.out" 2>"$tmp_dir/nim-ok.err" + rc=$? + set -e + assert_equals "0" "$rc" "failed-check review validator accepts a source-backed nvidia-nim report mapping" + rm -rf "$tmp_dir" } diff --git a/scripts/ci/validate_opencode_failed_check_review.sh b/scripts/ci/validate_opencode_failed_check_review.sh index e710cd9ff..bb157ac85 100755 --- a/scripts/ci/validate_opencode_failed_check_review.sh +++ b/scripts/ci/validate_opencode_failed_check_review.sh @@ -161,13 +161,13 @@ extract_strix_report_model_markers() { if (/^### Strix vulnerability report window/i) { $in_window = 1; - while (m{(?:model|for model)[[:space:]]+((?:github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)}gi) { + while (m{(?:model|for model)[[:space:]]+((?:nvidia[-_]nim|github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)}gi) { print "$1\n"; } next; } next unless $in_window; - if (m{(?:^|[[:space:]])Model[[:space:]]+((?:github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)}i) { + if (m{(?:^|[[:space:]])Model[[:space:]]+((?:nvidia[-_]nim|github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)}i) { print "$1\n"; } ' "$FAILED_CHECK_EVIDENCE_FILE" | sort -u @@ -184,7 +184,7 @@ from pathlib import Path control = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) pattern = re.compile( - r"strix|github[-_]models/|deepseek/|openai/gpt-|vertex_ai/|Vulnerability Report", + r"strix|nvidia[-_]nim/|github[-_]models/|deepseek/|openai/gpt-|vertex_ai/|Vulnerability Report", re.IGNORECASE, ) count = 0 @@ -223,7 +223,7 @@ evidence_text = evidence_file.read_text(encoding="utf-8", errors="replace") ansi_re = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") model_re = re.compile( - r"(?:^|[\s])Model\s+((?:github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)", + r"(?:^|[\s])Model\s+((?:nvidia[-_]nim|github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)", re.IGNORECASE, ) failed_model_re = re.compile(r"Strix run failed for model '([^']+)'") @@ -236,7 +236,7 @@ clean_suffix_pipe_re = re.compile(r"\s*│.*$") clean_prefix_z_re = re.compile(r"^.*?[0-9]Z\s+") clean_whitespace_re = re.compile(r"\s+") new_field_re = re.compile(r"^(Title|Severity|CVSS Score|CVSS Vector|Target|Endpoint|Method|Description|Impact|Technical Analysis|PoC Description|PoC Code|Code Locations|Remediation)\b", re.IGNORECASE) -window_model_re = re.compile(r"(?:model|for model)\s+((?:github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)", re.IGNORECASE) +window_model_re = re.compile(r"(?:model|for model)\s+((?:nvidia[-_]nim|github[-_]models|openai|deepseek|vertex_ai)/[A-Za-z0-9._/-]+)", re.IGNORECASE) continuation_border_re = re.compile(r"^[╭╰─]+$") field_title_re = re.compile(r"^Title:\s+(.+)", re.IGNORECASE) field_severity_re = re.compile(r"^Severity:\s+(CRITICAL|HIGH|MEDIUM|LOW|NONE)\b", re.IGNORECASE) @@ -456,7 +456,7 @@ for evidence_marker in \ "Self-test Strix gate script" \ "github.event.client_payload.strix_llm" \ "STRIX_LLM must select" \ - "MODEL: github-models/openai/gpt-5" + "MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" do if grep -Fq -- "$evidence_marker" "$FAILED_CHECK_EVIDENCE_FILE" && ! contains_review_text "$evidence_marker"; then diff --git a/tests/test_agent_mention_sweep.py b/tests/test_agent_mention_sweep.py index 1489873b7..96b409659 100644 --- a/tests/test_agent_mention_sweep.py +++ b/tests/test_agent_mention_sweep.py @@ -654,7 +654,8 @@ def test_main_constructs_clients_and_forwards_options(monkeypatch) -> None: monkeypatch.setenv("TARGET_REPOSITORY_TOKEN", "target") monkeypatch.setenv("AGENT_DISPATCH_TOKEN", "dispatch") monkeypatch.setenv( - "OPENCODE_REPOSITORY_DISPATCH_TARGETS", "ContextualWisdomLab/example" + "OPENCODE_REPOSITORY_DISPATCH_TARGETS", + "ContextualWisdomLab/example,ContextualWisdomLab/Orgmetra", ) monkeypatch.setattr(sweep, "sweep", lambda **kwargs: captured.append(kwargs) or 0) assert ( @@ -677,3 +678,4 @@ def test_main_constructs_clients_and_forwards_options(monkeypatch) -> None: assert captured[0]["lookback_hours"] == 48 assert captured[0]["max_dispatches"] == 3 assert captured[0]["dry_run"] is True + assert "ContextualWisdomLab/Orgmetra" in captured[0]["opencode_allowlist"] diff --git a/tests/test_assert_opencode_reasoning_effort.py b/tests/test_assert_opencode_reasoning_effort.py index 73bd8c781..607cf517a 100644 --- a/tests/test_assert_opencode_reasoning_effort.py +++ b/tests/test_assert_opencode_reasoning_effort.py @@ -11,7 +11,7 @@ def write_config(tmp_path, models): """Write a minimal OpenCode config and return its path.""" path = tmp_path / "opencode.jsonc" path.write_text( - json.dumps({"provider": {"github-models": {"models": models}}}), + json.dumps({"provider": {"nvidia-nim": {"models": models}}}), encoding="utf-8", ) return path @@ -46,29 +46,29 @@ def test_validate_candidate_accepts_high_effort_and_non_reasoning_models(tmp_pat ) config = guard.load_config(config_path) - assert guard.validate_candidate(config, "github-models/openai/o3") == [] + assert guard.validate_candidate(config, "nvidia-nim/openai/o3") == [] assert ( - guard.validate_candidate(config, "github-models/deepseek/deepseek-v3-0324") + guard.validate_candidate(config, "nvidia-nim/deepseek/deepseek-v3-0324") == [] ) def test_validate_candidate_reports_missing_and_unqualified_models(): """Unknown and unqualified candidates fail with actionable messages.""" - config = {"provider": {"github-models": {"models": {}}}} + config = {"provider": {"nvidia-nim": {"models": {}}}} assert guard.validate_candidate(config, "openai-o3") == [ "OpenCode candidate openai-o3 is not provider-qualified." ] - assert guard.validate_candidate(config, "github-models/openai/o3") == [ - "OpenCode candidate github-models/openai/o3 is not defined in opencode.jsonc " - "under provider github-models." + assert guard.validate_candidate(config, "nvidia-nim/openai/o3") == [ + "OpenCode candidate nvidia-nim/openai/o3 is not defined in opencode.jsonc " + "under provider nvidia-nim." ] def test_validate_candidate_skips_unknown_non_reasoning_provider_fallbacks(): """Unknown provider fallbacks pass when no reasoning-effort support is known.""" - config = {"provider": {"github-models": {"models": {}}}} + config = {"provider": {"nvidia-nim": {"models": {}}}} assert guard.validate_candidate(config, "vertex_ai/fallback-one") == [] @@ -77,7 +77,7 @@ def test_validate_candidate_reports_each_missing_high_effort_field(): """Reasoning-capable models must opt into high effort in every required field.""" config = { "provider": { - "github-models": { + "nvidia-nim": { "models": { "openai/o3": { "reasoning": True, @@ -90,18 +90,18 @@ def test_validate_candidate_reports_each_missing_high_effort_field(): } } - assert guard.validate_candidate(config, "github-models/openai/o3") == [ - "OpenCode reasoning-capable candidate github-models/openai/o3 must set " + assert guard.validate_candidate(config, "nvidia-nim/openai/o3") == [ + "OpenCode reasoning-capable candidate nvidia-nim/openai/o3 must set " "options.reasoningEffort=high in opencode.jsonc.", - "OpenCode reasoning-capable candidate github-models/openai/o3 must set " + "OpenCode reasoning-capable candidate nvidia-nim/openai/o3 must set " "variants.high.reasoningEffort=high in opencode.jsonc.", ] - assert guard.validate_candidate(config, "github-models/deepseek/deepseek-r1-0528") == [ - "OpenCode reasoning-capable candidate github-models/deepseek/deepseek-r1-0528 " + assert guard.validate_candidate(config, "nvidia-nim/deepseek/deepseek-r1-0528") == [ + "OpenCode reasoning-capable candidate nvidia-nim/deepseek/deepseek-r1-0528 " "must set reasoning=true in opencode.jsonc.", - "OpenCode reasoning-capable candidate github-models/deepseek/deepseek-r1-0528 " + "OpenCode reasoning-capable candidate nvidia-nim/deepseek/deepseek-r1-0528 " "must set options.reasoningEffort=high in opencode.jsonc.", - "OpenCode reasoning-capable candidate github-models/deepseek/deepseek-r1-0528 " + "OpenCode reasoning-capable candidate nvidia-nim/deepseek/deepseek-r1-0528 " "must set variants.high.reasoningEffort=high in opencode.jsonc.", ] @@ -188,8 +188,8 @@ def test_main_reports_all_candidate_errors(tmp_path, capsys): [ "--config", str(config_path), - "github-models/openai/o3", - "github-models/mistral-ai/mistral-medium-2505", + "nvidia-nim/openai/o3", + "nvidia-nim/mistral-ai/mistral-medium-2505", ] ) == 1 @@ -207,7 +207,7 @@ def test_module_entrypoint_success(monkeypatch, tmp_path): "assert_opencode_reasoning_effort.py", "--config", str(config_path), - "github-models/openai/gpt-5", + "nvidia-nim/openai/gpt-5", ], ) diff --git a/tests/test_attach_contextual_orchestrator_provider.py b/tests/test_attach_contextual_orchestrator_provider.py new file mode 100644 index 000000000..19cf3106c --- /dev/null +++ b/tests/test_attach_contextual_orchestrator_provider.py @@ -0,0 +1,262 @@ +"""Fail-closed optional Contextual Orchestrator provider attachment.""" + +from __future__ import annotations + +import json +from pathlib import Path +import runpy +import sys + +import pytest + +from scripts.ci import attach_contextual_orchestrator_provider as attach + + +def nim_only_config() -> dict[str, object]: + """Return the current NIM-direct isolated catalog shape.""" + return { + "model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", + "small_model": "nvidia-nim/meta/llama-3.3-70b-instruct", + "enabled_providers": ["nvidia-nim"], + "provider": { + "nvidia-nim": { + "options": { + "baseURL": "https://integrate.api.nvidia.com/v1", + "apiKey": "{env:NVIDIA_API_KEY}", + } + } + }, + } + + +def write_config(tmp_path: Path, payload: dict[str, object]) -> Path: + """Write one isolated OpenCode config for helper tests.""" + path = tmp_path / "opencode.jsonc" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_unset_url_is_a_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Missing orchestrator URL leaves the NIM-direct catalog unchanged.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.delenv("CONTEXTUAL_ORCHESTRATOR_URL", raising=False) + + assert attach.main([str(path)]) == 0 + assert json.loads(path.read_text(encoding="utf-8")) == nim_only_config() + + +def test_blank_url_is_a_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Whitespace-only orchestrator URL does not attach a provider.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", " \n") + + assert attach.main([str(path)]) == 0 + assert json.loads(path.read_text(encoding="utf-8")) == nim_only_config() + + +def test_https_url_attaches_provider_without_changing_nim_defaults( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A valid https URL adds one provider and keeps NIM as the default model.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://orchestrator.example/v1/") + + assert attach.main([str(path)]) == 0 + config = json.loads(path.read_text(encoding="utf-8")) + assert config["model"] == "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" + assert config["small_model"] == "nvidia-nim/meta/llama-3.3-70b-instruct" + assert config["enabled_providers"] == ["nvidia-nim", "contextual-orchestrator"] + assert config["provider"]["contextual-orchestrator"] == { + "npm": "@ai-sdk/openai-compatible", + "name": "Contextual Orchestrator", + "options": {"baseURL": "https://orchestrator.example/v1"}, + } + assert "github-models" not in config["provider"] + assert "Attached contextual-orchestrator provider" in capsys.readouterr().out + + +def test_loopback_http_sidecar_is_allowed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The future review-job sidecar may listen on loopback http.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "http://127.0.0.1:4000/v1") + + assert attach.main([str(path)]) == 0 + config = json.loads(path.read_text(encoding="utf-8")) + assert config["provider"]["contextual-orchestrator"]["options"]["baseURL"] == ( + "http://127.0.0.1:4000/v1" + ) + + +def test_localhost_http_sidecar_is_allowed() -> None: + """localhost is treated as the same loopback sidecar class as 127.0.0.1.""" + assert ( + attach.normalize_orchestrator_url("http://localhost:4000/v1") + == "http://localhost:4000/v1" + ) + assert ( + attach.normalize_orchestrator_url("http://[::1]:4000/v1") + == "http://[::1]:4000/v1" + ) + + +def test_existing_orchestrator_enabled_entry_is_not_duplicated() -> None: + """Re-attaching does not append a second enabled_providers entry.""" + config = nim_only_config() + config["enabled_providers"] = ["nvidia-nim", "contextual-orchestrator"] + + updated = attach.attach_orchestrator_provider( + config, "https://orchestrator.example/v1" + ) + + assert updated["enabled_providers"] == ["nvidia-nim", "contextual-orchestrator"] + + +def test_github_models_url_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """GitHub Models endpoints are never a valid orchestrator URL.""" + original = nim_only_config() + path = write_config(tmp_path, original) + monkeypatch.setenv( + "CONTEXTUAL_ORCHESTRATOR_URL", + "https://models.github.ai/inference", + ) + + assert attach.main([str(path)]) == 1 + assert "must not point at GitHub Models" in capsys.readouterr().err + assert json.loads(path.read_text(encoding="utf-8")) == original + + +def test_non_loopback_http_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Plain http is only for a local sidecar, not a remote fallback.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "http://orchestrator.example/v1") + + assert attach.main([str(path)]) == 1 + + +def test_embedded_credentials_fail_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Userinfo in the orchestrator URL is not accepted.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv( + "CONTEXTUAL_ORCHESTRATOR_URL", + "https://user:token@orchestrator.example/v1", + ) + + assert attach.main([str(path)]) == 1 + + +def test_missing_scheme_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A host without an http(s) scheme is not a usable OpenAI-compatible base.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "orchestrator.example/v1") + + assert attach.main([str(path)]) == 1 + + +def test_missing_host_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """https without a host is not a usable sidecar URL.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://") + + assert attach.main([str(path)]) == 1 + + +def test_missing_config_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A set URL cannot attach into a missing isolated catalog.""" + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://orchestrator.example/v1") + + assert attach.main([str(tmp_path / "missing.jsonc")]) == 1 + + +def test_invalid_json_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Corrupt isolated catalogs are not rewritten.""" + path = tmp_path / "opencode.jsonc" + path.write_text("{", encoding="utf-8") + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://orchestrator.example/v1") + + assert attach.main([str(path)]) == 1 + + +def test_non_object_root_fails_closed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A JSON array is not an OpenCode config.""" + path = tmp_path / "opencode.jsonc" + path.write_text("[]", encoding="utf-8") + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://orchestrator.example/v1") + + assert attach.main([str(path)]) == 1 + + +def test_github_models_provider_map_fails_closed() -> None: + """Do not attach beside a leftover GitHub Models provider.""" + config = nim_only_config() + providers = config["provider"] + assert isinstance(providers, dict) + providers["github-models"] = {} + + with pytest.raises(SystemExit, match="github-models"): + attach.attach_orchestrator_provider(config, "https://orchestrator.example/v1") + + +def test_missing_nvidia_nim_enabled_provider_fails_closed() -> None: + """The optional path cannot replace NIM-direct as the enabled default.""" + config = nim_only_config() + config["enabled_providers"] = ["openai"] + + with pytest.raises(SystemExit, match="nvidia-nim"): + attach.attach_orchestrator_provider(config, "https://orchestrator.example/v1") + + +def test_non_object_provider_map_fails_closed() -> None: + """A broken provider map is not rewritten.""" + config = nim_only_config() + config["provider"] = [] + + with pytest.raises(SystemExit, match="provider map"): + attach.attach_orchestrator_provider(config, "https://orchestrator.example/v1") + + +def test_module_entrypoint_skips_when_unset( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The script entrypoint exits successfully when the sidecar URL is absent.""" + path = write_config(tmp_path, nim_only_config()) + monkeypatch.delenv("CONTEXTUAL_ORCHESTRATOR_URL", raising=False) + monkeypatch.setattr( + sys, + "argv", + ["attach_contextual_orchestrator_provider.py", str(path)], + ) + + module = sys.modules.pop( + "scripts.ci.attach_contextual_orchestrator_provider", None + ) + with pytest.raises(SystemExit) as exc_info: + try: + runpy.run_module( + "scripts.ci.attach_contextual_orchestrator_provider", + run_name="__main__", + ) + finally: + if module is not None: + sys.modules["scripts.ci.attach_contextual_orchestrator_provider"] = ( + module + ) + + assert exc_info.value.code == 0 diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 813385b23..62fed2b01 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -33,6 +33,14 @@ def test_codeql_pr_workflow_gates_head_and_merge_sarif_locally() -> None: assert "analyze-merge:" in workflow assert "merge_commit_sha != ''" in workflow assert "CodeQL merge preview" in workflow + assert workflow.count("Wait for GitHub API before CodeQL init") == 2 + assert "GitHub API stayed unavailable; CodeQL init cannot determine feature enablement." in workflow + assert workflow.count("Initialize CodeQL") == 2 + assert "continue-on-error: true" not in workflow + assert "Wait after CodeQL feature-enablement outage" not in workflow + assert "Retry Initialize CodeQL" not in workflow + assert "steps.codeql_init.outcome == 'failure'" not in workflow + assert 'rm -rf "$RUNNER_TEMP/codeql_databases" "$GITHUB_WORKSPACE/.codeql"' not in workflow assert "github.event.pull_request.head.sha" in workflow assert "github.event.pull_request.merge_commit_sha" in workflow assert "refs/pull/{0}/head" in workflow diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 58ded3740..cfd763bf4 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -1152,6 +1152,7 @@ def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[b assert result.stdout == b"out" assert captured["argv"][:3] == ["/usr/bin/uv", "export", "--frozen"] assert "--offline" in captured["argv"] + assert "--all-extras" in captured["argv"] assert "--no-emit-project" in captured["argv"] assert "--no-editable" in captured["argv"] assert captured["cwd"] == str(tmp_path) diff --git a/tests/test_materialize_base_rust_toolchain.py b/tests/test_materialize_base_rust_toolchain.py new file mode 100644 index 000000000..3825c4345 --- /dev/null +++ b/tests/test_materialize_base_rust_toolchain.py @@ -0,0 +1,397 @@ +"""Tests for exact-base Rust toolchain materialization.""" + +from __future__ import annotations + +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_rust_toolchain as materializer + + +def git(repo: Path, *args: str) -> str: + """Run Git in one temporary fixture repository.""" + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def init_repo(tmp_path: Path, name: str = "repo") -> Path: + """Create an empty Git repository with a deterministic test identity.""" + repo = tmp_path / name + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + return repo + + +def commit(repo: Path, message: str = "fixture") -> str: + """Commit the fixture tree and return its exact revision.""" + git(repo, "add", "-A") + git(repo, "commit", "--allow-empty", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def rust_workspace(tmp_path: Path) -> tuple[Path, str]: + """Create an OriginWeave-style workspace and return its base revision.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text( + "[workspace]\n" + 'members = ["crates/originweave-destination", "crates/originweave-core"]\n' + 'resolver = "3"\n\n' + "[workspace.package]\n" + 'edition = "2024"\n' + 'rust-version = "1.97"\n', + encoding="utf-8", + ) + (repo / "Cargo.lock").write_text("# lock\n", encoding="utf-8") + (repo / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "1.97.1"\n', + encoding="utf-8", + ) + destination = repo / "crates/originweave-destination" + destination.mkdir(parents=True) + (destination / "Cargo.toml").write_text( + '[package]\nname = "originweave-destination"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + (destination / "src").mkdir() + (destination / "src/lib.rs").write_text("pub fn ok() {}\n", encoding="utf-8") + core = repo / "crates/originweave-core" + core.mkdir(parents=True) + (core / "Cargo.toml").write_text( + '[package]\nname = "originweave-core"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + return repo, commit(repo, "workspace") + + +def test_materialize_reads_rust_inputs_from_exact_base_commit(tmp_path: Path) -> None: + """A pull request cannot select the trusted base Rust toolchain or lock.""" + repo, base_sha = rust_workspace(tmp_path) + (repo / "rust-toolchain.toml").write_text( + '[toolchain]\nchannel = "1.99.0"\n', + encoding="utf-8", + ) + (repo / "Cargo.lock").write_text("# pull-request lock\n", encoding="utf-8") + commit(repo, "untrusted pull-request inputs") + + output = tmp_path / "base-rust" + assert materializer.main( + [ + "--repo-root", + str(repo), + "--base-sha", + base_sha, + "--output-dir", + str(output), + ] + ) == 0 + + payload = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + assert payload["revision_sha"] == base_sha + assert payload["rustup_channel"] == "1.97.1" + assert (output / "Cargo.lock").read_text(encoding="utf-8") == "# lock\n" + + +def test_originweave_workspace_copies_only_base_rust_metadata(tmp_path: Path) -> None: + """Only tracked base manifests, lock, and toolchain metadata enter the image.""" + repo, base_sha = rust_workspace(tmp_path) + output = tmp_path / "base-rust" + payload = materializer.materialize(repo, base_sha, output) + assert payload == { + "revision_sha": base_sha, + "rustup_channel": "1.97.1", + "has_lock": True, + "has_manifest": True, + "inputs": [ + "rust-toolchain.toml", + "Cargo.toml", + "Cargo.lock", + "crates/originweave-destination/Cargo.toml", + "crates/originweave-core/Cargo.toml", + ], + } + assert (output / "crates/originweave-destination/Cargo.toml").is_file() + assert not (output / "crates/originweave-destination/src/lib.rs").exists() + + +@pytest.mark.parametrize( + ("rust_version", "expected"), + [("1.97", "1.97"), ("1.85.0", None), ("1.80", None), ("stable", None)], +) +def test_rust_version_selects_only_newer_numeric_toolchains( + tmp_path: Path, rust_version: str, expected: str | None +) -> None: + """Only a numeric rust-version newer than Debian rustc selects rustup.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text( + f'[package]\nname = "fixture"\nversion = "0.1.0"\nrust-version = "{rust_version}"\n', + encoding="utf-8", + ) + revision = commit(repo) + assert materializer.declared_rust_version(repo, revision) == rust_version + assert materializer.rustup_channel(repo, revision) == expected + + +def test_legacy_toolchain_file_selects_a_safe_channel(tmp_path: Path) -> None: + """A base-owned legacy rust-toolchain file can select a bounded channel.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n') + (repo / "rust-toolchain").write_text("nightly-2026-08-01\n") + revision = commit(repo) + assert materializer.toolchain_channel(repo, revision) == "nightly-2026-08-01" + assert materializer.rustup_channel(repo, revision) == "nightly-2026-08-01" + + +def test_tree_without_cargo_manifest_writes_empty_revision_manifest(tmp_path: Path) -> None: + """A non-Rust base commit records its revision without installing Rust.""" + repo = init_repo(tmp_path) + revision = commit(repo) + payload = materializer.materialize(repo, revision, tmp_path / "out") + assert payload["revision_sha"] == revision + assert payload["rustup_channel"] is None + assert payload["has_manifest"] is False + assert payload["inputs"] == [] + + +def test_workspace_member_path_and_glob_validation_fail_closed(tmp_path: Path) -> None: + """Traversal, recursive globs, and in-segment globs cannot select blobs.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text('[workspace]\nmembers = ["../escape"]\n') + revision = commit(repo, "traversal") + with pytest.raises(ValueError, match="bounded repository path"): + materializer.workspace_member_manifests(repo, revision) + for member in ("crates/**", "cr*tes/foo", "crates/?"): + with pytest.raises(ValueError, match="unsupported workspace member glob"): + materializer.expand_workspace_member(member, {"Cargo.toml"}) + + +def test_symlink_inputs_do_not_cross_the_regular_blob_boundary(tmp_path: Path) -> None: + """Git symlink entries are excluded instead of following worktree targets.""" + repo, _ = rust_workspace(tmp_path) + outside = tmp_path / "outside.lock" + outside.write_text("outside\n") + (repo / "Cargo.lock").unlink() + (repo / "Cargo.lock").symlink_to(outside) + revision = commit(repo, "symlink lock") + output = tmp_path / "out" + payload = materializer.materialize(repo, revision, output) + assert payload["has_lock"] is False + assert not (output / "Cargo.lock").exists() + + +def test_cli_and_script_entrypoint_require_exact_base_sha( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """The workflow CLI and script entrypoint bind output to the given revision.""" + repo, revision = rust_workspace(tmp_path) + output = tmp_path / "cli-out" + argv = [ + "--repo-root", + str(repo), + "--base-sha", + revision, + "--output-dir", + str(output), + ] + assert materializer.main(argv) == 0 + assert json.loads(capsys.readouterr().out)["revision_sha"] == revision + monkeypatch.setattr(sys, "argv", [materializer.__file__, *argv[:-1], str(tmp_path / "entry")]) + with pytest.raises(SystemExit, match="0"): + runpy.run_path(materializer.__file__, run_name="__main__") + + +def test_workspace_glob_expands_only_immediate_base_crates(tmp_path: Path) -> None: + """A trailing glob includes immediate crate manifests but not deeper paths.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text('[workspace]\nmembers = ["crates/*"]\n') + direct = repo / "crates/direct" + direct.mkdir(parents=True) + (direct / "Cargo.toml").write_text('[package]\nname = "direct"\nversion = "0.1.0"\n') + deep = repo / "crates/group/deep" + deep.mkdir(parents=True) + (deep / "Cargo.toml").write_text('[package]\nname = "deep"\nversion = "0.1.0"\n') + revision = commit(repo) + assert materializer.workspace_member_manifests(repo, revision) == [ + "crates/direct/Cargo.toml" + ] + + +def test_non_list_and_missing_workspace_members_yield_no_manifests(tmp_path: Path) -> None: + """Non-list metadata and absent member blobs cannot become Rust inputs.""" + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text('[workspace]\nmembers = "crates/*"\n') + non_list = commit(repo, "non-list") + assert materializer.workspace_member_manifests(repo, non_list) == [] + (repo / "Cargo.toml").write_text('[workspace]\nmembers = [1, "missing"]\n') + missing = commit(repo, "missing") + assert materializer.workspace_member_manifests(repo, missing) == [] + + +def test_invalid_toml_and_unsafe_channels_fail_closed(tmp_path: Path) -> None: + """Malformed metadata and unsafe toolchain channels never select rustup.""" + with pytest.raises(materializer.tomllib.TOMLDecodeError): + materializer.read_toml(b"this is not toml [[[", "Cargo.toml") + with ( + pytest.raises(TypeError, match="TOML table"), + pytest.MonkeyPatch.context() as monkeypatch, + ): + monkeypatch.setattr(materializer.tomllib, "loads", lambda _text: ["not-table"]) + materializer.read_toml(b"ignored", "Cargo.toml") + + repo = init_repo(tmp_path) + (repo / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n') + (repo / "rust-toolchain.toml").write_text('[toolchain]\nchannel = "../evil"\n') + (repo / "rust-toolchain").write_text("not a channel!\n") + revision = commit(repo) + assert materializer.toolchain_channel(repo, revision) is None + + +def test_tracked_paths_accept_only_well_formed_regular_blobs( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Tree parsing excludes symlinks and directories and validates every entry.""" + repo = tmp_path + revision = "a" * 40 + blob = "b" * 40 + monkeypatch.setattr( + materializer, + "_git", + lambda *_args: ( + f"100644 blob {blob}\tCargo.toml\0" + f"100755 blob {blob}\tscripts/tool\0" + f"120000 blob {blob}\tCargo.lock\0" + f"040000 tree {blob}\tcrates\0" + ).encode(), + ) + assert materializer.tracked_paths(repo, revision) == {"Cargo.toml", "scripts/tool"} + + for malformed, match in ( + (b"broken\0", "malformed tree entry"), + (b"100644 blob bad\tCargo.toml\0", "invalid object identity"), + (f"100644 blob {blob}\t../escape\0".encode(), "bounded repository path"), + (f"100644 blob {blob}\tbad-".encode() + b"\xff\0", "valid UTF-8"), + ): + monkeypatch.setattr(materializer, "_git", lambda *_args, value=malformed: value) + with pytest.raises((RuntimeError, ValueError), match=match): + materializer.tracked_paths(repo, revision) + + +def test_git_object_reader_rejects_untrusted_invocations_and_repositories(tmp_path: Path) -> None: + """Only exact tree/blob reads execute, and invalid repository metadata fails closed.""" + repo = tmp_path / "repo" + repo.mkdir() + with pytest.raises(RuntimeError, match="unsupported invocation"): + materializer._git(repo, "status") + with pytest.raises(RuntimeError, match="unsupported invocation"): + materializer._git(repo) + with pytest.raises(ValueError, match="exactly 40"): + materializer._git(repo, "ls-tree", "-rz", "--full-tree", "main") + with pytest.raises(ValueError, match="blob selector"): + materializer._git(repo, "show", "main:Cargo.toml") + with pytest.raises(RuntimeError, match="not a git repository"): + materializer.tracked_paths(repo, "a" * 40) + + git_path = repo / ".git" + git_path.symlink_to(tmp_path) + with pytest.raises(RuntimeError, match="symbolic link"): + materializer.tracked_paths(repo, "a" * 40) + git_path.unlink() + git_path.write_text("not a pointer\n") + with pytest.raises(RuntimeError, match="invalid gitdir pointer"): + materializer.tracked_paths(repo, "a" * 40) + git_path.write_text("gitdir: missing\n") + with pytest.raises(RuntimeError, match="not a regular directory"): + materializer.tracked_paths(repo, "a" * 40) + + +def test_real_git_failures_and_gitdir_pointers_are_handled(tmp_path: Path) -> None: + """Missing objects fail closed while a regular worktree pointer remains readable.""" + repo, revision = rust_workspace(tmp_path) + with pytest.raises(RuntimeError, match="git ls-tree failed"): + materializer.tracked_paths(repo, "f" * 40) + moved = tmp_path / "real-git" + (repo / ".git").rename(moved) + (repo / ".git").write_text(f"gitdir: {moved}\n") + assert "Cargo.toml" in materializer.tracked_paths(repo, revision) + + +def test_invalid_sha_output_symlink_and_nonregular_blob_fail_closed(tmp_path: Path) -> None: + """Revision, output, and regular-blob boundaries reject ambiguous inputs.""" + repo, revision = rust_workspace(tmp_path) + with pytest.raises(ValueError, match="base SHA"): + materializer.materialize(repo, "main", tmp_path / "out") + linked_output = tmp_path / "linked-output" + linked_output.symlink_to(tmp_path / "elsewhere") + with pytest.raises(ValueError, match="output directory"): + materializer.materialize(repo, revision, linked_output) + with pytest.raises(ValueError, match="non-regular"): + materializer._read_blob(repo, revision, "Cargo.lock", {"Cargo.toml"}) + + +def test_existing_destination_symlink_and_invalid_base_toml_fail_cli(tmp_path: Path) -> None: + """The materializer neither replaces output symlinks nor accepts malformed base TOML.""" + repo, revision = rust_workspace(tmp_path) + output = tmp_path / "out" + output.mkdir() + (output / "Cargo.toml").symlink_to(tmp_path / "outside") + with pytest.raises(ValueError, match="symlinked Rust output"): + materializer.materialize(repo, revision, output) + + (repo / "Cargo.toml").write_text("this is not toml [[[\n") + corrupt = commit(repo, "corrupt") + with pytest.raises(SystemExit): + materializer.main( + [ + "--repo-root", + str(repo), + "--base-sha", + corrupt, + "--output-dir", + str(tmp_path / "corrupt-out"), + ] + ) + + +def test_parse_helpers_and_bounded_paths_cover_edge_cases() -> None: + """Version and path helpers accept normalized values and reject ambiguity.""" + assert materializer.parse_rust_version("1.97") == (1, 97, 0) + assert materializer.parse_rust_version("1.85.0") == (1, 85, 0) + assert materializer.parse_rust_version("nightly") is None + assert materializer._nested({}, "missing.value") is None + assert materializer._nested({"value": "not-a-table"}, "value.child") is None + assert materializer._bounded_member_path("crates/core").as_posix() == "crates/core" + for path in ("", "/absolute", "./dot", "a/../b", "a\\b", "a//b"): + with pytest.raises(ValueError, match="bounded repository path"): + materializer._bounded_repo_path(path) + + +def test_absent_rust_metadata_and_empty_legacy_channel_take_no_toolchain_path( + tmp_path: Path, +) -> None: + """Absent version fields and an empty legacy file select no Rust toolchain.""" + repo = init_repo(tmp_path) + empty = commit(repo, "empty") + assert materializer.declared_rust_version(repo, empty) is None + assert materializer.workspace_member_manifests(repo, empty) == [] + assert materializer.rustup_channel(repo, empty) is None + with pytest.raises(ValueError, match="base SHA"): + materializer.tracked_paths(repo, "main") + + (repo / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n') + (repo / "rust-toolchain").write_text("") + no_version = commit(repo, "no version") + assert materializer.declared_rust_version(repo, no_version) is None + assert materializer.toolchain_channel(repo, no_version) is None + assert materializer.rustup_channel(repo, no_version) is None diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index 408bb95b9..b0f66e978 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -1,5 +1,6 @@ import base64 import json +import subprocess import sys import pytest @@ -46,6 +47,79 @@ def test_run_split_repo_graphql_and_fetch_pr(monkeypatch): assert noema.split_repo("owner/repo") == ("owner", "repo") + +def test_run_retries_transient_github_503(monkeypatch) -> None: + """A GitHub 503 on gh is retried instead of failing the Noema verdict.""" + calls = {"n": 0} + + def fake_run(argv, **_kwargs): + calls["n"] += 1 + if calls["n"] < 3: + return subprocess.CompletedProcess( + argv, + 1, + stdout="", + stderr="gh: No server is currently available to service your request. (HTTP 503)", + ) + return subprocess.CompletedProcess(argv, 0, stdout="ok\n", stderr="") + + monkeypatch.setattr(noema.subprocess, "run", fake_run) + monkeypatch.setattr(noema.time, "sleep", lambda _seconds: None) + monkeypatch.setenv("NOEMA_GH_RETRY_SLEEP", "0") + assert noema.run(["gh", "api", "graphql"]).strip() == "ok" + assert calls["n"] == 3 + assert noema.is_transient_github_error("HTTP 429 Too Many Requests") + + +def test_run_does_not_retry_non_transient_gh_errors(monkeypatch) -> None: + """Permanent gh failures still fail on the first attempt.""" + calls = {"n": 0} + + def fake_run(argv, **_kwargs): + calls["n"] += 1 + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="gh: Not Found (HTTP 404)") + + monkeypatch.setattr(noema.subprocess, "run", fake_run) + with pytest.raises(RuntimeError, match="HTTP 404"): + noema.run(["gh", "api", "graphql"]) + assert calls["n"] == 1 + + +def test_run_exhausts_transient_github_503(monkeypatch) -> None: + """A persistent GitHub 503 fails after the bounded retry budget.""" + calls = {"n": 0} + + def fake_run(argv, **_kwargs): + calls["n"] += 1 + return subprocess.CompletedProcess( + argv, + 1, + stdout="", + stderr="HTTP 503", + ) + + monkeypatch.setattr(noema.subprocess, "run", fake_run) + monkeypatch.setattr(noema.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(noema, "GH_TRANSIENT_RETRY_ATTEMPTS", 2) + with pytest.raises(RuntimeError, match="HTTP 503"): + noema.run(["gh", "api", "user"]) + assert calls["n"] == 2 + + +def test_run_clamps_zero_github_retry_budget(monkeypatch) -> None: + """A zero retry budget still makes one gh attempt.""" + calls = {"n": 0} + + def fake_run(argv, **_kwargs): + calls["n"] += 1 + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="HTTP 503") + + monkeypatch.setattr(noema.subprocess, "run", fake_run) + monkeypatch.setattr(noema, "GH_TRANSIENT_RETRY_ATTEMPTS", 0) + with pytest.raises(RuntimeError, match="HTTP 503"): + noema.run(["gh", "api", "user"]) + assert calls["n"] == 1 + def test_scrub_sensitive_data(): assert noema.scrub_sensitive_data(None) is None assert noema.scrub_sensitive_data("") == "" @@ -147,6 +221,23 @@ def test_review_state_helpers_reject_explicit_previous_head_evidence(): ) +def test_review_matches_current_head_rejects_missing_or_stale_identity(): + """Noema accepts only a review whose commit and optional body head match.""" + assert not noema.review_matches_current_head(review(), "") + assert not noema.review_matches_current_head(review(commit="stale"), "head") + assert noema.review_matches_current_head(review(), "head") + current_head = "a" * 40 + previous_head = "b" * 40 + assert noema.review_matches_current_head( + review(commit=current_head, body=f"Result: APPROVE\nHead SHA: `{current_head}`"), + current_head, + ) + assert not noema.review_matches_current_head( + review(commit=current_head, body=f"Result: APPROVE\nHead SHA: `{previous_head}`"), + current_head, + ) + + def test_check_helpers_and_existing_noema_review(): status_context = {"__typename": "StatusContext", "context": "ci", "state": "FAILURE"} check_run = { @@ -492,7 +583,11 @@ def test_format_findings_and_submit_review(monkeypatch): calls = [] monkeypatch.setenv("NOEMA_REVIEW_TOKEN_SOURCE", "oidc") - monkeypatch.setattr(noema, "run", lambda args, stdin=None: calls.append((args, json.loads(stdin))) or "") + monkeypatch.setattr( + noema, + "run", + lambda args, stdin=None, retry=True: calls.append((args, json.loads(stdin), retry)) or "", + ) noema.submit_review( "owner/repo", 7, @@ -505,17 +600,37 @@ def test_format_findings_and_submit_review(monkeypatch): assert payload["commit_id"] == "head" assert "Noema LLM review" in payload["body"] assert "oidc" in payload["body"] + assert calls[0][2] is False calls.clear() noema.submit_review("owner/repo", 7, make_pr(), "", {"decision": "comment"}) assert calls[0][1]["event"] == "COMMENT" assert "No blocking findings" in calls[0][1]["body"] + assert calls[0][2] is False + + +def test_run_retry_false_skips_transient_gh_retry(monkeypatch) -> None: + """retry=False makes a single gh attempt even on a transient error.""" + calls = {"n": 0} + + def fake_run(argv, **_kwargs): + calls["n"] += 1 + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="HTTP 503") + + monkeypatch.setattr(noema.subprocess, "run", fake_run) + with pytest.raises(RuntimeError, match="HTTP 503"): + noema.run(["gh", "api", "-X", "POST", "repos/owner/repo/pulls/1/reviews"], retry=False) + assert calls["n"] == 1 def test_inspect_and_review_skip_paths(monkeypatch): marker_body = "OpenCode reviewed the current-head bounded evidence and found no blocking issues." clean_pr = make_pr(reviews={"nodes": [review(body=marker_body)]}) calls = [] + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://integrate.api.nvidia.com/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_MODEL", "nvidia/nemotron-3-ultra-550b-a55b") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "nim-key") + monkeypatch.setenv("TARGET_REPOSITORY_PRIVATE", "false") monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: clean_pr) monkeypatch.setattr(noema, "current_actor", lambda: "noema") monkeypatch.setattr(noema, "fetch_diff", lambda repo, number: ("diff", False)) @@ -526,23 +641,159 @@ def test_inspect_and_review_skip_paths(monkeypatch): assert noema.inspect_and_review("owner/repo", 7) == 0 assert calls - cases = [ + existing = make_pr( + reviews={"nodes": [review(login="noema", body="")]} + ) + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number, pr=existing: pr) + assert noema.inspect_and_review("owner/repo", 7) == 0 + + skip_cases = [ (make_pr(), "noema"), (make_pr(isDraft=True), "noema"), - (make_pr(reviews={"nodes": [review(login="noema", body="")]}), "noema"), (make_pr(reviews={"nodes": [review("CHANGES_REQUESTED"), review(body=marker_body)]}), "noema"), (make_pr(reviews={"nodes": [review(body=marker_body)]}, reviewThreads={"nodes": [{"isResolved": False, "isOutdated": False}]}), "noema"), (make_pr(reviews={"nodes": [review(body=marker_body)]}, statusCheckRollup={"contexts": {"nodes": [{"__typename": "StatusContext", "context": "ci", "state": "FAILURE"}]}}), "noema"), (clean_pr, "opencode-agent"), ] - for pr, actor in cases: + for pr, actor in skip_cases: calls.clear() monkeypatch.setattr(noema, "fetch_pr", lambda repo, number, pr=pr: pr) monkeypatch.setattr(noema, "current_actor", lambda actor=actor: actor) - assert noema.inspect_and_review("owner/repo", 7) == 0 + assert noema.inspect_and_review("owner/repo", 7) == 1 assert calls == [] +def test_require_nim_runtime_and_failure_emission(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("NOEMA_LLM_API_URL", raising=False) + monkeypatch.delenv("NOEMA_LLM_MODEL", raising=False) + monkeypatch.delenv("NOEMA_LLM_API_KEY", raising=False) + monkeypatch.delenv("CONTEXTUAL_ORCHESTRATOR_URL", raising=False) + monkeypatch.delenv("TARGET_REPOSITORY_PRIVATE", raising=False) + with pytest.raises(RuntimeError, match="unconfigured"): + noema.require_nim_runtime() + + monkeypatch.setenv("TARGET_REPOSITORY_PRIVATE", "false") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://api.openai.com/v1/chat/completions") + monkeypatch.setenv("NOEMA_LLM_MODEL", "gpt-5.6-sol") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "sk-test") + with pytest.raises(RuntimeError, match="must not use"): + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_MODEL", "github_models/openai/o3") + with pytest.raises(RuntimeError, match="must not use"): + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_MODEL", "nvidia/nemotron-3-ultra-550b-a55b") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https:///v1/chat/completions") + with pytest.raises(RuntimeError, match="hostname"): + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://api.openai.com/v1/chat/completions") + with pytest.raises(RuntimeError, match="integrate.api.nvidia.com"): + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://integrate.api.nvidia.com/v1/chat/completions") + noema.require_nim_runtime() + + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "https://orchestrator.example.test/v1") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://orchestrator.example.test/v1/chat") + noema.require_nim_runtime() + assert "orchestrator.example.test" in noema.allowed_noema_llm_hosts() + + monkeypatch.setenv("TARGET_REPOSITORY_PRIVATE", "true") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://trusted-private-reviewer.example.test/v1/chat") + monkeypatch.setenv("NOEMA_LLM_MODEL", "trusted-private-reviewer") + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://integrate.api.nvidia.com/v1/chat/completions") + with pytest.raises(RuntimeError, match="private repository.*hosted NVIDIA NIM"): + noema.require_nim_runtime() + + monkeypatch.setenv("NOEMA_LLM_API_URL", "http://trusted-private-reviewer.example.test/v1/chat") + with pytest.raises(RuntimeError, match="private repository.*HTTPS"): + noema.require_nim_runtime() + + monkeypatch.setenv("TARGET_REPOSITORY_PRIVATE", "unknown") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://trusted-private-reviewer.example.test/v1/chat") + with pytest.raises(RuntimeError, match="visibility"): + noema.require_nim_runtime() + + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + noema.emit_noema_failure(RuntimeError("token sk-abc-123 leaked")) + err = capsys.readouterr().err + assert "::error::" in err + assert "sk-abc-123" not in err + assert "Noema review failure" in summary.read_text(encoding="utf-8") + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + noema.emit_noema_failure(RuntimeError("HTTP 503")) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_URL", "not-a-url") + assert noema.allowed_noema_llm_hosts() == {noema.NIM_CHAT_HOST} + + +def test_run_github_retries_transient_503(monkeypatch): + monkeypatch.setenv("NOEMA_GH_RETRY_SLEEP", "0") + attempts = {"n": 0} + + def flaky(args, stdin=None): + attempts["n"] += 1 + if attempts["n"] < 3: + raise RuntimeError("Command failed (1): gh\nHTTP 503") + return '{"ok":true}' + + monkeypatch.setattr(noema, "run", flaky) + assert noema.run_github(["gh", "api", "graphql"]) == '{"ok":true}' + assert attempts["n"] == 3 + assert noema.is_transient_github_error("HTTP 502 Bad Gateway") + assert not noema.is_transient_github_error("HTTP 404") + with pytest.raises(TypeError): + noema.run_github("gh api") # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="GitHub request failed"): + noema.run_github(["gh", "api", "user"], attempts=0) + + def permanent(args, stdin=None): + raise RuntimeError("Command failed (1): gh\nHTTP 404") + + monkeypatch.setattr(noema, "run", permanent) + with pytest.raises(RuntimeError, match="404"): + noema.run_github(["gh", "api", "user"]) + + slept: list[float] = [] + monkeypatch.setenv("NOEMA_GH_RETRY_SLEEP", "0.01") + monkeypatch.setattr(noema.time, "sleep", lambda seconds: slept.append(seconds)) + attempts["n"] = 0 + + def flaky_then_ok(args, stdin=None): + attempts["n"] += 1 + if attempts["n"] == 1: + raise RuntimeError("HTTP 429") + return "ok" + + monkeypatch.setattr(noema, "run", flaky_then_ok) + assert noema.run_github(["gh", "api", "user"]) == "ok" + assert slept == [0.01] + + +def test_submit_review_refuses_draft_approve(monkeypatch): + monkeypatch.setattr(noema, "run", lambda *args, **kwargs: "") + with pytest.raises(RuntimeError, match="never receive bot APPROVE"): + noema.submit_review( + "owner/repo", + 7, + make_pr(isDraft=True), + "noema", + {"decision": "approve", "summary": "ok"}, + ) + + +def test_inspect_and_review_emits_fetch_failure(monkeypatch, tmp_path): + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + monkeypatch.setattr(noema, "fetch_pr", lambda repo, number: (_ for _ in ()).throw(RuntimeError("HTTP 503"))) + assert noema.inspect_and_review("owner/repo", 7) == 1 + assert "HTTP 503" in summary.read_text(encoding="utf-8") + + def test_parse_args_and_main(monkeypatch): parsed = noema.parse_args(["--repo", "owner/repo", "--pr-number", "9"]) assert parsed.repo == "owner/repo" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 2360fdb26..54a77b6fa 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -8,6 +8,8 @@ import pytest +from scripts.ci import opencode_review_surfaces as surfaces +from scripts.ci import rust_coverage_policy as rust_policy from scripts.ci.assert_opencode_reasoning_effort import strip_jsonc_comments @@ -28,9 +30,8 @@ def test_code_reviewer_subagent_contract_is_configured(): assert reviewer["color"] == "#7c3aed" # Reasoning effort is model-level only (see the model configs below and the # ci-autofix agent). An agent-level reasoningEffort is applied to every - # candidate the agent runs, including non-reasoning models like - # github-models/openai/gpt-4.1, whose OpenAI backend rejects the - # reasoning_effort request argument outright. + # candidate the agent runs, including non-reasoning NVIDIA NIM models whose + # backends reject the reasoning_effort request argument outright. assert "reasoningEffort" not in reviewer assert "model" not in reviewer assert "Reviews only; never edits code" in reviewer["description"] @@ -65,84 +66,26 @@ def test_code_reviewer_subagent_contract_is_configured(): assert config["permission"]["bash"] == "deny" assert config["permission"]["task"] == "deny" - models = config["provider"]["github-models"]["models"] - high_reasoning_models = { - "openai/gpt-5", - "openai/gpt-5-chat", - "openai/gpt-5-mini", - "openai/gpt-5-nano", - "deepseek/deepseek-r1", - "deepseek/deepseek-r1-0528", - "openai/o3", - "openai/o3-mini", - "openai/o4-mini", - } - for model_name in high_reasoning_models: - assert models[model_name]["reasoning"] is True - assert models[model_name]["options"]["reasoningEffort"] == "high" - assert models[model_name]["variants"]["high"]["reasoningEffort"] == "high" - for model_name, model_config in models.items(): - if model_config.get("reasoning") is True: - assert model_config["options"]["reasoningEffort"] == "high", model_name - assert model_config["variants"]["high"]["reasoningEffort"] == "high", ( - model_name - ) + assert "github-models" not in config["provider"] + assert "STRIX_GITHUB_MODELS_TOKEN" not in Path("opencode.jsonc").read_text(encoding="utf-8") + assert config["enabled_providers"] == ["nvidia-nim"] + assert config["model"].startswith("nvidia-nim/") + assert config["small_model"].startswith("nvidia-nim/") + nim_models = config["provider"]["nvidia-nim"]["models"] + assert "nvidia/llama-3.3-nemotron-super-49b-v1.5" in nim_models + assert "meta/llama-3.3-70b-instruct" in nim_models def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): """Guard every review-pool candidate against silent reasoning-effort drift.""" config = load_opencode_jsonc() workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8") - github_models = config["provider"]["github-models"]["models"] + assert "github-models" not in config["provider"] candidates_match = re.search(r'OPENCODE_MODEL_CANDIDATES: "([^"]+)"', workflow) assert candidates_match is not None - conditional_public_candidate = ( - "${{ needs.validate-pr-metadata.outputs.is_private == 'false' " - "&& 'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 " - "nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 " - "nvidia-nim/nvidia/nemotron-3-super-120b-a12b " - "nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b " - "nvidia-nim/meta/llama-3.3-70b-instruct " - "nvidia-nim/deepseek-ai/deepseek-v4-pro " - "nvidia-nim/mistralai/codestral-22b-instruct-v0.1 " - "opencode-free/nemotron-3-ultra-free " - "opencode-free/deepseek-v4-flash-free " - "opencode-free/north-mini-code-free " - "opencode-free/laguna-s-2.1-free " - "opencode-free/ling-3.0-flash-free " - "opencode-free/big-pickle " - "opencode-free/mimo-v2.5-free " - "opencode-free/hy3-free " - "opencode-free/minimax-m3-free " - "opencode-free/glm-5-free " - "opencode-free/kimi-k2.5-free " - "opencode-free/qwen3.6-plus-free ' || '' }}" - ) candidates_text = candidates_match.group(1) - assert candidates_text.startswith(conditional_public_candidate) - candidates = [ - "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", - "nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1", - "nvidia-nim/nvidia/nemotron-3-super-120b-a12b", - "nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b", - "nvidia-nim/meta/llama-3.3-70b-instruct", - "nvidia-nim/deepseek-ai/deepseek-v4-pro", - "nvidia-nim/mistralai/codestral-22b-instruct-v0.1", - "opencode-free/nemotron-3-ultra-free", - "opencode-free/deepseek-v4-flash-free", - "opencode-free/north-mini-code-free", - "opencode-free/laguna-s-2.1-free", - "opencode-free/ling-3.0-flash-free", - "opencode-free/big-pickle", - "opencode-free/mimo-v2.5-free", - "opencode-free/hy3-free", - "opencode-free/minimax-m3-free", - "opencode-free/glm-5-free", - "opencode-free/kimi-k2.5-free", - "opencode-free/qwen3.6-plus-free", - *candidates_text.removeprefix(conditional_public_candidate).split(), - ] + candidates = candidates_text.split() candidate_pairs = [candidate.split("/", 1) for candidate in candidates] direct_openai_models = [ model_name for provider, model_name in candidate_pairs if provider == "openai" @@ -160,10 +103,7 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): ] assert candidate_pairs - assert all( - not candidate.startswith("nvidia-nim/") - for candidate in candidates_text.removeprefix(conditional_public_candidate).split() - ) + assert candidates[0].startswith("nvidia-nim/") assert candidate_pairs == [ ["nvidia-nim", "nvidia/llama-3.3-nemotron-super-49b-v1.5"], ["nvidia-nim", "nvidia/llama-3.1-nemotron-ultra-253b-v1"], @@ -184,25 +124,19 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): ["opencode-free", "glm-5-free"], ["opencode-free", "kimi-k2.5-free"], ["opencode-free", "qwen3.6-plus-free"], - ["opencode", "gpt-5.6-terra"], - ["github-models", "deepseek/deepseek-v3-0324"], ["openai", "gpt-5.4"], ["openrouter", "deepseek/deepseek-v3.2"], ["openrouter", "qwen/qwen3-coder"], - ["github-models", "openai/gpt-4.1"], - ["github-models", "openai/gpt-5"], - ["github-models", "openai/gpt-5-chat"], - ["github-models", "openai/o3"], - ["github-models", "deepseek/deepseek-r1-0528"], - ["github-models", "deepseek/deepseek-r1"], ] - assert zen_models == ["gpt-5.6-terra"] + assert zen_models == [] + assert github_candidate_models == [] + assert "opencode/gpt-5.6-terra" not in candidates_text + assert "github-models/" not in candidates_text assert direct_openai_models == ["gpt-5.4"] assert openrouter_models == [ "deepseek/deepseek-v3.2", "qwen/qwen3-coder", ] - assert set(github_candidate_models).issubset(set(github_models)) assert '"context": 256000' in workflow assert '"output": 64000' in workflow generated_config_match = re.search( @@ -212,6 +146,17 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): ) assert generated_config_match is not None generated_config = json.loads(generated_config_match.group(1)) + assert "github-models" not in generated_config["provider"] + assert "contextual-orchestrator" not in generated_config["provider"] + assert generated_config["enabled_providers"][0] == "nvidia-nim" + assert "STRIX_GITHUB_MODELS_TOKEN:" not in workflow + assert "secrets.STRIX_GITHUB_MODELS_TOKEN" not in workflow + assert "attach_contextual_orchestrator_provider.py" in workflow + assert ( + "CONTEXTUAL_ORCHESTRATOR_URL: ${{ vars.CONTEXTUAL_ORCHESTRATOR_URL || '' }}" + in workflow + ) + assert "COPILOT_GITHUB_TOKEN" not in workflow nvidia_provider = generated_config["provider"]["nvidia-nim"] assert nvidia_provider["options"] == { "baseURL": "https://integrate.api.nvidia.com/v1", @@ -313,15 +258,6 @@ def test_opencode_model_pool_sets_high_effort_for_capable_candidates(): assert model_config["variants"]["high"]["reasoningEffort"] == "high", ( model_name ) - assert github_candidate_models == [ - "deepseek/deepseek-v3-0324", - "openai/gpt-4.1", - "openai/gpt-5", - "openai/gpt-5-chat", - "openai/o3", - "deepseek/deepseek-r1-0528", - "deepseek/deepseek-r1", - ] banned_review_candidates = { "gpt-5-nano", "openai/gpt-5-nano", @@ -349,19 +285,6 @@ def is_reasoning_capable(model_name: str) -> bool: or model_name.startswith("deepseek/deepseek-r1") ) - for model_name in github_candidate_models: - model_config = github_models[model_name] - if is_reasoning_capable(model_name): - assert model_config["reasoning"] is True, model_name - assert model_config["options"]["reasoningEffort"] == "high", model_name - assert model_config["variants"]["high"]["reasoningEffort"] == "high", ( - model_name - ) - else: - assert model_config.get("reasoning") is not True, model_name - assert "reasoningEffort" not in model_config.get("options", {}), model_name - assert "variants" not in model_config, model_name - def test_model_pool_cannot_synthesize_approval_after_provider_exhaustion(): """Provider exhaustion must remain exhausted without a command-only reviewer.""" @@ -635,7 +558,7 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): in measure_step ) assert 'hash-object --no-filters -- "$relative_lock"' not in measure_step - assert "refusing --trust-lockfile for PR-controlled dependency resolution" in measure_step + assert "refusing PR-controlled dependency resolution" in measure_step assert "prepare_writable_pnpm_store()" in measure_step assert ( 'destination="$(mktemp -d /tmp/opencode-pnpm-store.XXXXXX)"' @@ -736,6 +659,13 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): in measure_step ) assert "CARGO_HOME=/work/.opencode-sandbox-home/.cargo" in measure_step + assert "materialize_base_rust_toolchain.py" in measure_step + rust_materializer = measure_step.split("materialize_base_rust_toolchain.py", 1)[1] + assert '--base-sha "$PR_BASE_SHA"' in rust_materializer.split("cat >", 1)[0] + assert "RUSTUP_HOME=/opt/rustup" in measure_step + assert "CARGO_NET_OFFLINE=true" in measure_step + assert 'PATH="/opt/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"' in measure_step + assert "llvm-tools-preview" in measure_step assert "docker run --rm --init --network=none" in measure_step sandbox_runtime = measure_step.split( " export OPENCODE_SANDBOX_UID=65532", 1 @@ -762,6 +692,10 @@ def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): assert "package.metadata.opencode.coverage.minimum_lines" in measure_step assert "workspace.metadata.opencode.coverage.minimum_lines" in measure_step assert "scripts/ci/rust_coverage_threshold.py" in measure_step + assert "scripts/ci/rust_coverage_policy.py" in measure_step + assert "rust_coverage_plan_line()" in measure_step + assert "Rust repository coverage verifier" in measure_step + assert "Rust toolchain identity" in measure_step assert '--fail-under-lines "$threshold"' in measure_step assert "uv sync --project" not in measure_step assert "uv run --no-project" not in measure_step @@ -874,6 +808,7 @@ def test_opencode_model_exhaustion_retry_stays_owned_by_central_scheduler(): assert "opencode-exhausted-retry:" not in workflow assert "RETRY_DISPATCH_TOKEN" not in workflow assert "contents: write" not in workflow + assert "models: read" not in workflow def test_sandbox_git_config_env_trusts_only_the_validated_worktree(tmp_path): @@ -1396,6 +1331,8 @@ def test_code_reviewer_prompt_preserves_review_only_policy(): ) assert "full-screen blocking layer" in ci_prompt_normalized assert "formerly blank sections receive real data" in ci_prompt_normalized + assert "Coverage is a gate, not the review" in ci_prompt + assert "Never cite `.github/workflows/opencode-review.yml:1`" in ci_prompt assert "deliberate empty states" in ci_prompt assert "demo/visual-QA mode is isolated" in ci_prompt_normalized assert "production API behavior" in ci_prompt @@ -1595,7 +1532,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE" in workflow assert "CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL" in workflow assert ( - 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "5400"' + 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "7200"' in workflow ) assert ( @@ -1667,7 +1604,17 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "implementation_completeness_scan.py" in workflow assert '"## Review outcome"' in workflow assert '"## Check outcome"' not in workflow - assert "publish REQUEST_CHANGES when coverage-evidence blocker states" in workflow + assert "record coverage-evidence blocker states" in workflow + assert "publish_fallback_diff_review" in workflow + assert "opencode_review_surfaces.py build-status" in workflow + assert "opencode_review_surfaces.py build-fallback-review" in workflow + assert "opencode_review_surfaces.py format-request-changes" in workflow + assert 'update_review_overview "$event" "$body"' not in workflow + assert "## Coverage gate" in workflow + assert "materialize_base_rust_toolchain.py" in workflow + assert "llvm-tools-preview" in workflow + assert "cargo llvm-cov --offline --locked" in workflow + assert ".github/workflows/opencode-review.yml:1" not in workflow assert re.search( r"Prepare bounded OpenCode review evidence[\s\S]{0,120}timeout-minutes: 12", workflow, @@ -1681,7 +1628,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' in workflow assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000"' in workflow assert ( @@ -1716,8 +1663,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): in workflow ) assert ( - "needs.validate-pr-metadata.outputs.is_private == 'false' && " - "'nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 " + "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 " "nvidia-nim/nvidia/llama-3.1-nemotron-ultra-253b-v1 " "nvidia-nim/nvidia/nemotron-3-super-120b-a12b " "nvidia-nim/nvidia/nemotron-3-ultra-550b-a55b " @@ -1735,23 +1681,27 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "opencode-free/minimax-m3-free " "opencode-free/glm-5-free " "opencode-free/kimi-k2.5-free " - "opencode-free/qwen3.6-plus-free ' || ''" + "opencode-free/qwen3.6-plus-free " + "openai/gpt-5.4 " + "openrouter/deepseek/deepseek-v3.2 " + "openrouter/qwen/qwen3-coder" ) in workflow + assert "needs.validate-pr-metadata.outputs.is_private == 'false' &&" not in workflow.split( + "OPENCODE_MODEL_CANDIDATES:", 1 + )[1].split("OPENCODE_MODEL_ATTEMPTS:", 1)[0] assert ( - "opencode/gpt-5.6-terra " - "github-models/deepseek/deepseek-v3-0324 " "openai/gpt-5.4 " "openrouter/deepseek/deepseek-v3.2 " - "openrouter/qwen/qwen3-coder " - "github-models/openai/gpt-4.1 " - "github-models/openai/gpt-5 " - "github-models/openai/gpt-5-chat " - "github-models/openai/o3 " - "github-models/deepseek/deepseek-r1-0528 " - "github-models/deepseek/deepseek-r1" + "openrouter/qwen/qwen3-coder" ) in workflow + pool_candidates_match = re.search(r'OPENCODE_MODEL_CANDIDATES: "([^"]+)"', workflow) + assert pool_candidates_match is not None + pool_candidates = pool_candidates_match.group(1) + assert "opencode/gpt-5.6-terra" not in pool_candidates + assert "github-models/" not in pool_candidates + assert "opencode/gpt-5.6-terra" not in workflow assert 'OPENCODE_MODEL_ATTEMPTS: "1"' in workflow - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "180"' in workflow assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11700"' in workflow assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "12000"' in workflow @@ -1761,21 +1711,21 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): "OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt" in workflow ) - assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow - assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow - assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow - assert 'OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "5400"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "7200"' in workflow assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "11700"' in workflow - assert 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "5400"' in workflow + assert 'OPENCODE_DYNAMIC_RUN_TIMEOUT_CAP_SECONDS: "7200"' in workflow assert 'OPENCODE_DYNAMIC_TOTAL_BUDGET_CAP_SECONDS: "11700"' in workflow assert 'OPENCODE_DYNAMIC_MAX_CYCLES_CAP: "1"' in workflow - assert 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "180"' in workflow - assert 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "900"' in workflow + assert 'OPENCODE_NVIDIA_NIM_RUN_TIMEOUT_SECONDS: "7200"' in workflow + assert 'OPENCODE_NVIDIA_NIM_TOTAL_BUDGET_SECONDS: "7200"' in workflow assert 'OPENCODE_FREE_RUN_TIMEOUT_SECONDS: "3600"' in workflow - assert 'OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS: "45"' in workflow + assert "OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS" not in workflow assert 'OPENCODE_DYNAMIC_MAX_CYCLES: "1"' in workflow assert 'OPENCODE_BACKOFF_MAX_SECONDS: "30"' in workflow publish_step = workflow.split(" - name: Publish OpenCode review outcome", 1)[ @@ -1803,7 +1753,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'gh api -X GET "repos/${GH_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate' not in publish_step ) - assert "MODEL: github-models/deepseek/deepseek-v3-0324" in publish_step + assert "MODEL: nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" in publish_step + assert "MODEL: github-models/" not in publish_step assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "120"' in publish_step assert "${OPENCODE_RUN_TIMEOUT_SECONDS:-120}s" in publish_step assert ( @@ -1867,6 +1818,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert ( "OpenCode model pool has no configured model candidates." in model_pool_runner ) + assert ( + "OpenCode model pool requires NVIDIA_NIM_API_KEY; failing closed " + "without GitHub Models fallback." + ) in model_pool_runner assert "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500" in model_pool_runner assert ( "completed a full model-candidate cycle without a valid control conclusion" @@ -1886,17 +1841,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'OPENCODE_MODEL_CANDIDATES: "github-models/openai/gpt-5-nano"' not in workflow ) assert ( - "github-models/deepseek/deepseek-v3-0324 " "openai/gpt-5.4 " "openrouter/deepseek/deepseek-v3.2 " - "openrouter/qwen/qwen3-coder " - "github-models/openai/gpt-4.1 " - "github-models/openai/gpt-5 " - "github-models/openai/gpt-5-chat " - "github-models/openai/o3 " - "github-models/deepseek/deepseek-r1-0528 " - "github-models/deepseek/deepseek-r1" + "openrouter/qwen/qwen3-coder" ) in workflow + assert "github-models/" not in pool_candidates + assert "opencode/gpt-5.6-terra" not in pool_candidates assert "${{ runner.temp }}/opencode-review-model-pool.md" in workflow assert re.search( r'check-runs" \\\n\s+-f per_page=100 \\\n\s+--paginate \\\n\s+--slurp \|\n\s+jq -r "\$jq_filter"', @@ -1995,6 +1945,9 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): ) assert "prefers-reduced-motion: reduce" in prompt_template assert "forced smooth scrolling" in prompt_template + assert "Coverage execution evidence is a separate gate" in prompt_template + assert "Never cite `.github/workflows/opencode-review.yml`" in prompt_template + assert "Never label a crate, package, or language surface as `Changed file (N files)`" in prompt_template def test_opencode_excludes_queue_self_check_from_every_failed_check_path(): @@ -2313,14 +2266,14 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert 'PYTHONPATH=. bash -lc "$2"' not in coverage_job assert "COVERAGE_EOF" not in coverage_job assert "os.urandom(24).hex()" in coverage_job - assert "/^## Coverage Decision$/ { emit = 1 }" in coverage_job + assert 'cp "$summary_file" "$coverage_output_file"' in coverage_job assert 'scripts/ci/sanitize_github_output_summary.py" \\' in coverage_job assert '"$coverage_output_file" "$summary_output_file"' in coverage_job assert ( 'grep -Fqx "$coverage_output_delimiter" "$summary_output_file"' in coverage_job ) assert 'cat "$summary_output_file"' in coverage_job - assert "Published compact coverage decision output" in coverage_job + assert "Published full rust/python/js coverage measurement log" in coverage_job assert "actions: read" in coverage_job assert "contents: read" not in coverage_job assert 'GITHUB_TOKEN: ""' in coverage_job @@ -2393,6 +2346,11 @@ def test_opencode_privileged_review_security_boundaries_are_fail_closed(): assert " opencode-review-target:\n" in bootstrap assert " name: opencode-review\n" in bootstrap assert "authenticated default-branch OpenCode review dispatch" in bootstrap + assert "opencode_review_receipt_gate.py" in bootstrap + assert "opencode_coverage_identity.py" in workflow + assert "draft must never receive bot APPROVE" in workflow + assert "ContextualWisdomLab/Orgmetra" not in bootstrap + assert "ContextualWisdomLab/Orgmetra" not in workflow assert workflow.count("ref: ${{ steps.trusted_source.outputs.ref }}") == 1 assert "TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }}" in workflow assert "ref: ${{ github.workflow_sha }}" not in workflow @@ -2774,7 +2732,10 @@ def test_opencode_model_pool_failure_uses_only_existing_real_model_approval(): r'opencode_review_outcome="\$\{OPENCODE_MODEL_POOL_OUTCOME:-unknown\}"[\s\S]{0,900}' r'if \[ "\$opencode_review_outcome" != "success" \]; then\s+' r"if publish_blockers_after_model_unavailable; then[\s\S]{0,180}" - r"exit 0\s+fi\s+stop_without_review_after_model_unavailable\s+fi", + r"exit 0\s+fi\s+" + r'if \[ "\$\{COVERAGE_EVIDENCE_RESULT:-skipped\}" != "success" \]; then[\s\S]{0,240}' + r"publish_fallback_diff_review[\s\S]{0,180}" + r"stop_without_review_after_model_unavailable\s+fi", workflow, ) assert 'stop_approval_without_review "MODEL_OUTPUT_UNAVAILABLE" "$body"' in workflow @@ -2940,3 +2901,85 @@ def test_r_package_load_deferral_requires_current_head_r_cmd_check(): assert ( "if (!is.na(pkg) && !requireNamespace(pkg, quietly = TRUE))" not in workflow ) + + +def test_originweave_47_review_surfaces_stay_split(tmp_path: Path): + """Dispatch run 31951179896: review body, status comment, and mermaid stay distinct.""" + workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text( + encoding="utf-8" + ) + changed = [ + "crates/originweave-destination/src/lib.rs", + "crates/originweave-destination/src/resolution.rs", + "crates/originweave-destination/tests/resolution_freshness.rs", + ] + review = surfaces.build_fallback_review( + changed_files=changed, + head_sha="79cf275686e2376a51783a2d03128eca21e7c0e5", + run_id="31951179896", + run_attempt="1", + coverage_result="failure", + ) + comment = surfaces.build_status_comment( + result="COVERAGE_BLOCKED", + head_sha="79cf275686e2376a51783a2d03128eca21e7c0e5", + run_id="31951179896", + run_attempt="1", + coverage_result="failure", + model_pool_outcome="skipped", + verdict="COVERAGE_BLOCKED", + formal_review_url=( + "https://github.com/ContextualWisdomLab/OriginWeave/pull/47" + "#pullrequestreview-1" + ), + ) + surfaces.distinct_surfaces(review, comment) + assert review != comment + assert "## Findings" not in comment + assert "needs.coverage-evidence.result != 'cancelled'" in workflow + model_pool = workflow.split("Run OpenCode PR Review model pool", 1)[1] + model_pool = model_pool.split("\n - name:", 1)[0] + assert "needs.coverage-evidence.result == 'success'" not in model_pool + assert ".github/workflows/opencode-review.yml:1" not in review + assert ".github/workflows/opencode-review.yml:1" not in workflow + diagram = surfaces.emit_mermaid(changed) + assert "Changed file (3 files)" not in diagram + assert "originweave-destination" in diagram + + manifest = tmp_path / "Cargo.toml" + manifest.write_text( + '[workspace]\nmembers = ["crates/demo"]\nrust-version = "1.97"\n', + encoding="utf-8", + ) + plan = rust_policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 100 + coverage_fn = workflow.split("request_changes_for_coverage_evidence_failure()", 1)[1] + coverage_fn = coverage_fn.split("create_pull_review_with_payload()", 1)[0] + assert "create_pull_review" not in coverage_fn + assert "build_coverage_evidence_check_failure_body" in coverage_fn + assert 'update_review_overview "COVERAGE_BLOCKED"' in coverage_fn + fallback_fn = workflow.split("publish_fallback_diff_review()", 1)[1] + fallback_fn = fallback_fn.split("request_changes_for_coverage_evidence_failure()", 1)[0] + assert "create_pull_review" in fallback_fn + assert "request_changes_for_coverage_evidence_failure" in fallback_fn + assert fallback_fn.index("create_pull_review") < fallback_fn.index( + "request_changes_for_coverage_evidence_failure" + ) + rust_source = tmp_path / "crates/originweave-destination/src/resolution.rs" + rust_source.parent.mkdir(parents=True) + rust_source.write_text( + "pub struct FreshResolutionSnapshot {}\npub fn resolve_fresh() {}\n", + encoding="utf-8", + ) + class_diagram = surfaces.emit_mermaid( + ["crates/originweave-destination/src/resolution.rs"], + source_root=tmp_path, + ) + assert "classDiagram" in class_diagram + assert "class FreshResolutionSnapshot" in class_diagram + assert "class resolve_fresh" in class_diagram + assert "FreshResolutionSnapshot --> resolve_fresh" not in class_diagram + assert " --> " not in class_diagram + assert "crates/originweave-destination/src/resolution.rs" in review + assert "opencode-review.yml:1" not in review diff --git a/tests/test_opencode_coverage_identity.py b/tests/test_opencode_coverage_identity.py new file mode 100644 index 000000000..8a695b550 --- /dev/null +++ b/tests/test_opencode_coverage_identity.py @@ -0,0 +1,238 @@ +"""Regression tests for exact-head canonical coverage-evidence quoting.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.ci import opencode_coverage_identity as identity + + +def coverage_check( + *, + head: str, + conclusion: str = "success", + workflow: str = "Required OpenCode Review", + name: str = "coverage-evidence", + status: str = "completed", +) -> dict[str, object]: + """Build one GitHub check-run object for coverage identity tests.""" + return { + "name": name, + "head_sha": head, + "status": status, + "conclusion": conclusion, + "check_suite": {"workflow_run": {"workflow": {"name": workflow}}}, + } + + +def test_kaefa_78_and_75_reject_false_failure_quotes() -> None: + """Canonical exact-head success must not be quoted as coverage failure.""" + for head in (identity.KAEFA_78_HEAD, identity.KAEFA_75_HEAD): + checks = [coverage_check(head=head, conclusion="success")] + assert identity.terminal_coverage_result(checks, head) == "success" + with pytest.raises(identity.CoverageQuoteError, match="does not match"): + identity.assert_quoted_matches("failure", checks, head) + assert identity.assert_quoted_matches("success", checks, head) == "success" + + +def test_kaefa_79_missing_canonical_check_fails_closed() -> None: + """A stub-only head without canonical coverage-evidence cannot be quoted.""" + with pytest.raises(identity.CoverageQuoteError, match="no completed canonical"): + identity.terminal_coverage_result([], identity.KAEFA_79_HEAD) + + +def test_identity_helpers_cover_malformed_and_noncanonical_checks() -> None: + """Malformed SHA, other workflows, and in-progress checks fail closed.""" + assert identity.normalize_result("SUCCESS") == "success" + assert identity.normalize_result("nope") == "unknown" + assert identity.check_head_sha({"headSha": "abc"}) == "abc" + assert identity.check_workflow_name({"checkSuite": {"workflowRun": {}}}) == "" + assert identity.check_workflow_name({"app": {"name": "GitHub Actions"}}) == "" + assert identity.check_workflow_name({"check_suite": "bad"}) == "" + assert identity.check_workflow_name({"check_suite": {"workflow_run": "bad"}}) == "" + assert identity.check_workflow_name({"app": "nope"}) == "" + head = identity.KAEFA_78_HEAD + with pytest.raises(identity.CoverageQuoteError, match="40-character"): + identity.terminal_coverage_result([], "deadbeef") + in_progress = coverage_check(head=head, status="in_progress", conclusion="") + assert identity.is_canonical_coverage_check(in_progress, head) is False + other = coverage_check(head=head, name="strix") + assert identity.is_canonical_coverage_check(other, head) is False + wrong_head = coverage_check(head=identity.KAEFA_75_HEAD) + assert identity.is_canonical_coverage_check(wrong_head, head) is False + unnamed = coverage_check(head=head, workflow="") + unnamed["check_suite"] = {"workflow_run": {"workflow": {}}} + assert identity.terminal_coverage_result([unnamed], head) == "success" + string_workflow = coverage_check(head=head) + string_workflow["check_suite"] = {"workflow_run": {"workflow": "Required OpenCode Review"}} + string_workflow["app"] = {"name": "GitHub Actions"} + assert identity.check_workflow_name(string_workflow) == "" + missing_conclusion = coverage_check(head=head, conclusion="") + with pytest.raises(identity.CoverageQuoteError, match="non-terminal"): + identity.terminal_coverage_result([missing_conclusion], head) + + +def test_app_only_check_run_is_still_canonical() -> None: + """A completed exact-head check with only an app.name (the real REST shape, + which never carries check_suite.workflow_run) must still be accepted.""" + head = identity.KAEFA_78_HEAD + app_only = coverage_check(head=head, conclusion="success") + app_only["check_suite"] = {} + app_only["app"] = {"name": "GitHub Actions"} + assert identity.check_workflow_name(app_only) == "" + assert identity.is_canonical_coverage_check(app_only, head) is True + assert identity.terminal_coverage_result([app_only], head) == "success" + + +def test_load_and_cli_verify_quoted_success(tmp_path: Path, capsys, monkeypatch) -> None: + """CLI prints the canonical result and annotates quote mismatches.""" + head = identity.KAEFA_78_HEAD + payload = {"check_runs": [coverage_check(head=head, conclusion="success")]} + path = tmp_path / "checks.json" + path.write_text(json.dumps(payload), encoding="utf-8") + assert identity.main( + ["--head-sha", head, "--quoted-result", "success", "--check-runs-file", str(path)] + ) == 0 + assert capsys.readouterr().out.strip() == "success" + + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + assert identity.main( + ["--head-sha", head, "--quoted-result", "failure", "--check-runs-file", str(path)] + ) == 1 + err = capsys.readouterr().err + assert "does not match" in err + assert "Coverage identity failure" in summary.read_text(encoding="utf-8") + + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + assert identity.main(["--head-sha", head, "--quoted-result", "success"]) == 1 + array_path = tmp_path / "array.json" + array_path.write_text(json.dumps([coverage_check(head=head)]), encoding="utf-8") + assert identity.main( + ["--head-sha", head, "--quoted-result", "success", "--check-runs-file", str(array_path)] + ) == 0 + bad = tmp_path / "bad.json" + bad.write_text("{}", encoding="utf-8") + assert identity.main( + ["--head-sha", head, "--quoted-result", "success", "--check-runs-file", str(bad)] + ) == 1 + stdin_payload = json.dumps([coverage_check(head=head, conclusion="success")]) + monkeypatch.setattr(identity.sys, "stdin", type("Stdin", (), {"read": lambda self: stdin_payload})()) + assert identity.load_check_runs("-")[0]["name"] == "coverage-evidence" + broken = tmp_path / "broken.json" + broken.write_text("{", encoding="utf-8") + assert identity.main( + ["--head-sha", head, "--quoted-result", "success", "--check-runs-file", str(broken)] + ) == 1 + + +def test_fetch_check_runs_rejects_unvalidated_repo_and_head_sha(monkeypatch) -> None: + """A malformed --repo or --head-sha never reaches the gh api path string.""" + + def unexpected_run(args, **kwargs): + raise AssertionError(f"gh must not be invoked with unvalidated input: {args!r}") + + monkeypatch.setattr(identity.subprocess, "run", unexpected_run) + with pytest.raises(identity.CoverageQuoteError, match="owner/repo"): + identity.fetch_check_runs("../evil", identity.KAEFA_78_HEAD) + with pytest.raises(identity.CoverageQuoteError, match="40-character"): + identity.fetch_check_runs("ContextualWisdomLab/kaefa", "not-a-sha") + + +def test_fetch_check_runs_parses_pages(monkeypatch) -> None: + """Paginated gh output and error paths stay fail-closed.""" + page = { + "check_runs": [ + coverage_check(head=identity.KAEFA_78_HEAD, conclusion="success") + ] + } + + def fake_run(args, **kwargs): + assert args[0] == "gh" + assert "--paginate" in args + assert "--slurp" in args + return type("Completed", (), {"returncode": 0, "stdout": json.dumps([page]), "stderr": ""})() + + monkeypatch.setattr(identity.subprocess, "run", fake_run) + loaded = identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD) + assert loaded[0]["name"] == "coverage-evidence" + + def fake_object(args, **kwargs): + return type( + "Completed", + (), + {"returncode": 0, "stdout": json.dumps(page), "stderr": ""}, + )() + + monkeypatch.setattr(identity.subprocess, "run", fake_object) + assert identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD) + + def fake_fail(args, **kwargs): + return type("Completed", (), {"returncode": 1, "stdout": "", "stderr": "boom"})() + + monkeypatch.setattr(identity.subprocess, "run", fake_fail) + with pytest.raises(identity.CoverageQuoteError, match="lookup failed"): + identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD) + + def fake_bad_json(args, **kwargs): + return type("Completed", (), {"returncode": 0, "stdout": '"nope"', "stderr": ""})() + + monkeypatch.setattr(identity.subprocess, "run", fake_bad_json) + with pytest.raises(identity.CoverageQuoteError, match="malformed"): + identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD) + + def fake_list_objects(args, **kwargs): + return type( + "Completed", + (), + { + "returncode": 0, + "stdout": json.dumps([coverage_check(head=identity.KAEFA_78_HEAD)]), + "stderr": "", + }, + )() + + monkeypatch.setattr(identity.subprocess, "run", fake_list_objects) + assert identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD) + + def fake_mixed_pages(args, **kwargs): + return type( + "Completed", + (), + { + "returncode": 0, + "stdout": json.dumps( + [ + {"check_runs": [coverage_check(head=identity.KAEFA_78_HEAD)]}, + coverage_check(head=identity.KAEFA_78_HEAD), + "skip", + ] + ), + "stderr": "", + }, + )() + + monkeypatch.setattr(identity.subprocess, "run", fake_mixed_pages) + assert len(identity.fetch_check_runs("ContextualWisdomLab/kaefa", identity.KAEFA_78_HEAD)) == 2 + + monkeypatch.setattr( + identity, + "fetch_check_runs", + lambda repo, head: [coverage_check(head=head, conclusion="success")], + ) + assert ( + identity.main( + [ + "--repo", + "ContextualWisdomLab/kaefa", + "--head-sha", + identity.KAEFA_78_HEAD, + "--quoted-result", + "success", + ] + ) + == 0 + ) diff --git a/tests/test_opencode_failed_check_fallback_strix_default.py b/tests/test_opencode_failed_check_fallback_strix_default.py new file mode 100644 index 000000000..8eb99d014 --- /dev/null +++ b/tests/test_opencode_failed_check_fallback_strix_default.py @@ -0,0 +1,48 @@ +"""Regression tests for mapping the live visibility-aware Strix default.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FALLBACK_EMITTER = ( + REPOSITORY_ROOT + / "scripts" + / "ci" + / "emit_opencode_failed_check_fallback_findings.sh" +) +LIVE_STRIX_DEFAULT = ( + "github.event.client_payload.strix_llm || " + "(steps.target_visibility.outputs.is_private == 'false' && " + "'nvidia_nim/nvidia/nemotron-3-super-120b-a12b' || 'gpt-5.4')" +) + + +def test_live_strix_visibility_default_maps_to_exact_workflow_line( + tmp_path: Path, +) -> None: + """Emit a source-backed finding for the exact live Strix default.""" + + fixture_repo = tmp_path / "repo" + workflow = fixture_repo / ".github" / "workflows" / "strix.yml" + workflow.parent.mkdir(parents=True) + workflow.write_text(f"STRIX_MODEL: ${{{{ {LIVE_STRIX_DEFAULT} }}}}\n", encoding="utf-8") + evidence = tmp_path / "failed-check-evidence.md" + evidence.write_text( + "## Failed check: Strix Changed Path Quality CI/quality\n\n" + f"Self-test Strix gate script failed: missing '{LIVE_STRIX_DEFAULT}'.\n", + encoding="utf-8", + ) + + completed = subprocess.run( + ["bash", str(FALLBACK_EMITTER), str(evidence), str(fixture_repo)], + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + assert ".github/workflows/strix.yml:1" in completed.stdout + assert "Strix PR scans must default to NVIDIA NIM Nemotron" in completed.stdout diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 08d17f000..1b00d68e5 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -121,7 +121,7 @@ def run_failed_model( evidence_excerpt: str = "", changed_files: list[str] | None = None, extra_env: dict[str, str] | None = None, - model_candidates: str = "github-models/openai/gpt-5", + model_candidates: str = "opencode-free/nemotron-3-ultra-free", prompt_capture: Path | None = None, ) -> subprocess.CompletedProcess[str]: """Run one fake provider failure through the real model-pool launcher.""" @@ -431,6 +431,7 @@ def test_configured_provider_retry_uses_bounded_backoff(tmp_path: Path) -> None: stderr_line="provider unavailable", extra_env={ "OPENCODE_MODEL_ATTEMPTS": "2", + "OPENCODE_SCHEMA_REPAIR_ATTEMPTS": "0", "OPENCODE_BACKOFF_INITIAL_SECONDS": "1", "OPENCODE_BACKOFF_MAX_SECONDS": "1", }, @@ -714,6 +715,7 @@ def test_dynamic_review_cadence_uses_small_change_timeout(tmp_path: Path) -> Non "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS": "99", "OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS": "7", "OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS": "11", + "OPENCODE_SCHEMA_REPAIR_ATTEMPTS": "0", "OPENCODE_DYNAMIC_MAX_CYCLES": "1", }, ) @@ -724,7 +726,7 @@ def test_dynamic_review_cadence_uses_small_change_timeout(tmp_path: Path) -> Non "for 2 changed file(s); max-cycles=1." ) in result.stdout attempt_budget = re.search( - r"OpenCode github-models/openai/gpt-5 attempt 1/1 using (\d+)s run timeout " + r"OpenCode opencode-free/nemotron-3-ultra-free attempt 1/1 using (\d+)s run timeout " r"with (\d+)s retry budget remaining\.", result.stdout, ) @@ -749,11 +751,11 @@ def test_dynamic_review_cadence_caps_large_change_queue_budget(tmp_path: Path) - "OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS": "7200", "OPENCODE_POOL_CYCLE_SLEEP_SECONDS": "0", }, - model_candidates="github-models/deepseek/deepseek-v3-0324", + model_candidates="opencode-free/nemotron-3-ultra-free", ) assert result.returncode == 1 - # Default dynamic timeout cap is now 3600s (hour-class large-repo allowance), + # Default dynamic timeout cap is now 7200s (two-hour NIM allowance), # so per-attempt 3600s is not reduced; only the total budget cap (1s) applies. assert ( "OpenCode dynamic review cadence queue cap applied: per-attempt 3600s -> 3600s, " @@ -774,32 +776,6 @@ def test_dynamic_review_cadence_caps_large_change_queue_budget(tmp_path: Path) - ) -def test_github_gpt5_runtime_cap_preserves_queue_budget(tmp_path: Path) -> None: - """Known constrained GitHub GPT-5 endpoints cannot consume a full cadence slot.""" - result = run_failed_model( - tmp_path, - extra_env={ - "OPENCODE_GITHUB_GPT5_RUN_TIMEOUT_SECONDS": "3", - "OPENCODE_RUN_TIMEOUT_SECONDS": "9", - }, - ) - - assert result.returncode == 1 - assert ( - "OpenCode github-models/openai/gpt-5 runtime cap selected 3s instead of 9s " - "because this provider has a bounded failover window." - ) in result.stdout - attempt_budget = re.search( - r"OpenCode github-models/openai/gpt-5 attempt 1/1 using (\d+)s run timeout " - r"with (\d+)s retry budget remaining\.", - result.stdout, - ) - assert attempt_budget is not None - run_timeout, remaining_budget = map(int, attempt_budget.groups()) - assert run_timeout == 3 - assert run_timeout <= remaining_budget <= 30 - - def test_free_provider_runtime_cap_preserves_queue_budget(tmp_path: Path) -> None: """A stalled free provider cannot consume a full paid-provider cadence slot.""" result = run_failed_model( @@ -821,7 +797,7 @@ def test_free_provider_runtime_cap_preserves_queue_budget(tmp_path: Path) -> Non def test_nvidia_nim_candidate_requires_key( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """NVIDIA NIM is skipped cleanly when its scoped credential is unavailable.""" + """NVIDIA NIM absence fails closed instead of falling through to GitHub Models.""" monkeypatch.setenv("NVIDIA_NIM_API_KEY", "ambient-scoped-key") monkeypatch.setenv("NVIDIA_API_KEY", "ambient-provider-key") result = run_failed_model( @@ -831,7 +807,10 @@ def test_nvidia_nim_candidate_requires_key( ) assert result.returncode == 1 - assert "scoped NVIDIA_NIM_API_KEY is not configured" in result.stdout + assert ( + "OpenCode model pool requires NVIDIA_NIM_API_KEY; failing closed " + "without GitHub Models fallback." + ) in result.stdout assert "attempt 1/1" not in result.stdout @@ -890,10 +869,8 @@ def test_nvidia_nim_combined_budget_preserves_fallback_attempt( assert "schema-repair attempt 2/2" not in result.stdout -def test_github_models_openai_prompt_references_evidence_without_inlining( - tmp_path: Path, -) -> None: - """Small-request GitHub Models OpenAI candidates keep evidence as files.""" +def test_nim_only_prompt_inlines_bounded_evidence_excerpt(tmp_path: Path) -> None: + """NIM-only review prompts keep the current-head evidence packet inline.""" prompt_capture = tmp_path / "captured-prompt.md" evidence_excerpt = "UNIQUE_CURRENT_HEAD_EVIDENCE_PACKET" @@ -905,21 +882,20 @@ def test_github_models_openai_prompt_references_evidence_without_inlining( assert result.returncode == 1 prompt = prompt_capture.read_text(encoding="utf-8") - assert evidence_excerpt not in prompt - assert "Evidence excerpt omitted for `github-models/openai/gpt-5`" in prompt - assert "bounded-review-evidence.md" in prompt - assert "bounded-review-evidence-excerpt.md" in prompt + assert evidence_excerpt in prompt + assert "Evidence excerpt omitted" not in prompt + assert "First review the current-head evidence excerpt in this prompt." in prompt def test_deepseek_prompt_still_inlines_bounded_evidence_excerpt(tmp_path: Path) -> None: - """Large-context DeepSeek candidates retain the current-head prompt packet.""" + """Large-context free-tier candidates retain the current-head prompt packet.""" prompt_capture = tmp_path / "captured-prompt.md" evidence_excerpt = "UNIQUE_DEEPSEEK_INLINE_EVIDENCE_PACKET" result = run_failed_model( tmp_path, evidence_excerpt=evidence_excerpt, - model_candidates="github-models/deepseek/deepseek-v3-0324", + model_candidates="opencode-free/nemotron-3-nano-free", prompt_capture=prompt_capture, ) diff --git a/tests/test_opencode_nim_only_contract.py b/tests/test_opencode_nim_only_contract.py new file mode 100644 index 000000000..d7dad01bb --- /dev/null +++ b/tests/test_opencode_nim_only_contract.py @@ -0,0 +1,39 @@ +"""Focused contracts for the OpenCode provider-boundary migration.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def read_repo_file(path: str) -> str: + """Return UTF-8 repository text for a policy assertion.""" + return (ROOT / path).read_text(encoding="utf-8") + + +def test_checked_in_opencode_config_enables_only_nvidia_nim(): + """The checked-in default cannot silently route review data to GitHub Models.""" + config = read_repo_file("opencode.jsonc") + + assert '"enabled_providers": ["nvidia-nim"]' in config + assert '"model": "nvidia-nim/' in config + assert '"small_model": "nvidia-nim/' in config + assert "github-models" not in config + assert "models.github.ai" not in config + assert "STRIX_GITHUB_MODELS_TOKEN" not in config + + +def test_review_dispatch_uses_scoped_nim_and_has_no_github_models_candidate(): + """Hosted review candidates keep the scoped NIM credential boundary.""" + workflow = read_repo_file(".github/workflows/opencode-review-dispatch.yml") + candidate_line = next( + line for line in workflow.splitlines() if "OPENCODE_MODEL_CANDIDATES:" in line + ) + + assert "nvidia-nim/" in candidate_line + assert "github-models/" not in candidate_line + assert "opencode/gpt-5.6-terra" not in candidate_line + assert "NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}" in workflow + assert "failing closed without GitHub Models fallback" in read_repo_file( + "scripts/ci/run_opencode_review_model_pool.sh" + ) diff --git a/tests/test_opencode_repository_dispatch_orgmetra.py b/tests/test_opencode_repository_dispatch_orgmetra.py new file mode 100644 index 000000000..35bca6af7 --- /dev/null +++ b/tests/test_opencode_repository_dispatch_orgmetra.py @@ -0,0 +1,333 @@ +"""Injected-allowlist regression for exact ContextualWisdomLab/Orgmetra dispatch.""" + +from __future__ import annotations + +import json +import os +import stat +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci.agent_mention_router import eligible_agents, parse_event, parse_repository_allowlist + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ORGMETRA = "ContextualWisdomLab/Orgmetra" +ORGMETRA_26_HEAD = "5c5fb1e548c69c1186e8ddb9ccbf439874b78985" +ORGMETRA_26_BASE_REF = "develop" +ORGMETRA_26_BASE_SHA = "0f1b5fcb0123456789abcdef0123456789abcdef" +ORGMETRA_26_HEAD_REF = "cursor/orgmetra-review-26" +INJECTED_ALLOWLIST = ( + "ContextualWisdomLab/.github,ContextualWisdomLab/naruon," + f"{ORGMETRA},ContextualWisdomLab/kaefa" +) +SHARED_WORKFLOWS = ( + ".github/workflows/opencode-review-dispatch.yml", + ".github/workflows/pr-review-merge-scheduler.yml", + ".github/workflows/pr-review-fix-scheduler.yml", + ".github/workflows/agent-mention-router.yml", + ".github/workflows/opencode-review.yml", + ".github/workflows/noema-review.yml", +) + + +def _extract_run_block(workflow_text: str, step_name: str) -> str: + """Return the bash body of one named workflow step.""" + lines = workflow_text.splitlines() + step_index = next( + index for index, line in enumerate(lines) if line.strip() == f"- name: {step_name}" + ) + run_index = next( + index + for index in range(step_index + 1, len(lines)) + if lines[index].strip() == "run: |" + ) + run_indent = len(lines[run_index]) - len(lines[run_index].lstrip()) + block_lines = [] + for line in lines[run_index + 1 :]: + if line.strip() and len(line) - len(line.lstrip()) <= run_indent: + break + block_lines.append(line[run_indent + 2 :] if len(line) >= run_indent + 2 else "") + return "\n".join(block_lines) + "\n" + + +def orgmetra_pr26_json( + *, + state: str = "open", + base_ref: str = ORGMETRA_26_BASE_REF, + base_sha: str = ORGMETRA_26_BASE_SHA, + head_ref: str = ORGMETRA_26_HEAD_REF, + head_sha: str = ORGMETRA_26_HEAD, + base_repo: str = ORGMETRA, + head_repo: str = ORGMETRA, +) -> str: + """Return live PR JSON for the Orgmetra #26 fixture.""" + return json.dumps( + { + "number": 26, + "state": state, + "base": { + "ref": base_ref, + "sha": base_sha, + "repo": {"full_name": base_repo, "private": False}, + }, + "head": { + "ref": head_ref, + "sha": head_sha, + "repo": {"full_name": head_repo}, + }, + } + ) + + +def _write_fake_gh(tmp_path: Path, payload: str) -> Path: + """Install a PATH-first gh that returns one PR JSON payload.""" + fake = tmp_path / "gh" + fake.write_text( + "#!/bin/bash\n" + "set -euo pipefail\n" + 'if [ "${1:-}" = "api" ]; then\n' + f" cat <<'EOF'\n{payload}\nEOF\n" + " exit 0\n" + "fi\n" + 'echo "unexpected gh $*" >&2\n' + "exit 1\n", + encoding="utf-8", + ) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC) + return fake + + +def _dispatch_env(tmp_path: Path, **overrides: str) -> dict[str, str]: + """Build the validate-step environment for an injected Orgmetra allowlist.""" + env = { + **os.environ, + "EVENT_NAME": "repository_dispatch", + "DISPATCH_ACTOR": "github-actions[bot]", + "DISPATCH_SENDER": "github-actions[bot]", + "ALLOWED_DISPATCH_ACTOR": "github-actions[bot]", + "ALLOWED_DISPATCH_TARGETS": INJECTED_ALLOWLIST, + "TARGET_REPOSITORY": ORGMETRA, + "PR_NUMBER": "26", + "SUPPLIED_BASE_REF": ORGMETRA_26_BASE_REF, + "SUPPLIED_BASE_SHA": ORGMETRA_26_BASE_SHA, + "SUPPLIED_HEAD_REF": ORGMETRA_26_HEAD_REF, + "SUPPLIED_HEAD_SHA": ORGMETRA_26_HEAD, + "GITHUB_OUTPUT": str(tmp_path / "github-output"), + "PATH": f"{tmp_path}:{os.environ.get('PATH', '')}", + } + env.update(overrides) + return env + + +def test_shared_dispatch_surfaces_do_not_hardcode_orgmetra() -> None: + """The inventory lives only in OPENCODE_REPOSITORY_DISPATCH_TARGETS.""" + for relative in SHARED_WORKFLOWS: + text = (REPO_ROOT / relative).read_text(encoding="utf-8") + assert "ContextualWisdomLab/Orgmetra" not in text, relative + if relative.endswith(("noema-review.yml", "opencode-review.yml")): + continue + assert "vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS" in text, relative + + +def test_opencode_repository_dispatch_allows_orgmetra_pr26_exact_head_and_rejects_non_cwl_or_typo_targets( + tmp_path: Path, +) -> None: + """Exact Orgmetra #26 head/base pass only when the injected allowlist names it.""" + workflow = (REPO_ROOT / ".github/workflows/opencode-review-dispatch.yml").read_text( + encoding="utf-8" + ) + shell = _extract_run_block( + workflow, "Bind workflow inputs to live organization pull request metadata" + ) + _write_fake_gh(tmp_path, orgmetra_pr26_json()) + + accepted = subprocess.run( + ["bash", "-c", shell], + env=_dispatch_env(tmp_path), + text=True, + capture_output=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + assert "Authorized repository_dispatch actor=" in accepted.stdout + assert f"target={ORGMETRA}" in accepted.stdout + assert f"Validated current live metadata for {ORGMETRA}#26" in accepted.stdout + assert ORGMETRA_26_HEAD in accepted.stdout + output = Path(_dispatch_env(tmp_path)["GITHUB_OUTPUT"]).read_text(encoding="utf-8") + assert f"target_repository={ORGMETRA}" in output + assert f"head_sha={ORGMETRA_26_HEAD}" in output + assert f"base_ref={ORGMETRA_26_BASE_REF}" in output + + cases = ( + ({"TARGET_REPOSITORY": "OtherOrg/Orgmetra"}, "rejected target=OtherOrg/Orgmetra"), + ( + {"TARGET_REPOSITORY": "ContextualWisdomLab/Orgmetrra"}, + "rejected target=ContextualWisdomLab/Orgmetrra", + ), + ({"TARGET_REPOSITORY": ""}, "rejected target="), + ( + {"SUPPLIED_HEAD_SHA": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, + "does not match the live pull request", + ), + ( + {"SUPPLIED_BASE_SHA": "cafebabecafebabecafebabecafebabecafebabe"}, + "does not match the live pull request", + ), + ( + {"ALLOWED_DISPATCH_TARGETS": "ContextualWisdomLab/.github,ContextualWisdomLab/naruon"}, + f"rejected target={ORGMETRA}", + ), + ) + for overrides, expected in cases: + rejected = subprocess.run( + ["bash", "-c", shell], + env=_dispatch_env(tmp_path, **overrides), + text=True, + capture_output=True, + check=False, + ) + assert rejected.returncode == 1, overrides + assert expected in rejected.stdout + rejected.stderr + + _write_fake_gh(tmp_path, orgmetra_pr26_json(state="closed")) + closed = subprocess.run( + ["bash", "-c", shell], + env=_dispatch_env(tmp_path), + text=True, + capture_output=True, + check=False, + ) + assert closed.returncode == 1 + assert "rejected closed" in closed.stdout + + _write_fake_gh( + tmp_path, + orgmetra_pr26_json(), + ) + regex_rejected = subprocess.run( + ["bash", "-c", shell], + env=_dispatch_env( + tmp_path, + TARGET_REPOSITORY="OtherOrg/Orgmetra", + ALLOWED_DISPATCH_TARGETS=f"{INJECTED_ALLOWLIST},OtherOrg/Orgmetra", + ), + text=True, + capture_output=True, + check=False, + ) + assert regex_rejected.returncode == 1 + assert "outside ContextualWisdomLab" in regex_rejected.stdout + + +def test_merge_and_fix_schedulers_accept_injected_orgmetra(tmp_path: Path) -> None: + """Shared scheduler allowlists accept exact Orgmetra when the variable includes it.""" + merge = (REPO_ROOT / ".github/workflows/pr-review-merge-scheduler.yml").read_text( + encoding="utf-8" + ) + merge_shell = _extract_run_block(merge, "Validate targeted repository dispatch") + _write_fake_gh(tmp_path, orgmetra_pr26_json()) + merge_output = tmp_path / "merge-output" + merge_env = { + **os.environ, + "GITHUB_EVENT_NAME": "repository_dispatch", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "DEFAULT_BRANCH": "main", + "TARGET_REPOSITORY_INPUT": ORGMETRA, + "TARGET_PR_NUMBER": "26", + "TARGET_BASE_BRANCH_INPUT": ORGMETRA_26_BASE_REF, + "ALLOWED_TARGET_REPOSITORIES": INJECTED_ALLOWLIST, + "GITHUB_OUTPUT": str(merge_output), + "PATH": f"{tmp_path}:{os.environ.get('PATH', '')}", + } + accepted = subprocess.run( + ["bash", "-c", merge_shell], + env=merge_env, + text=True, + capture_output=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stdout + accepted.stderr + assert ORGMETRA in merge_output.read_text(encoding="utf-8") + + typo = subprocess.run( + ["bash", "-c", merge_shell], + env={**merge_env, "TARGET_REPOSITORY_INPUT": "ContextualWisdomLab/Orgmetrra"}, + text=True, + capture_output=True, + check=False, + ) + assert typo.returncode == 1 + assert "absent from the configured exact allowlist" in typo.stdout + + fix = (REPO_ROOT / ".github/workflows/pr-review-fix-scheduler.yml").read_text( + encoding="utf-8" + ) + fix_shell = _extract_run_block(fix, "Validate scheduler target and dispatch authority") + fix_env = { + **os.environ, + "EVENT_NAME": "repository_dispatch", + "DISPATCH_ACTOR": "github-actions[bot]", + "DISPATCH_SENDER": "github-actions[bot]", + "ALLOWED_DISPATCH_ACTOR": "github-actions[bot]", + "ALLOWED_TARGET_REPOSITORIES": INJECTED_ALLOWLIST, + "TARGET_REPOSITORY": ORGMETRA, + } + assert ( + subprocess.run( + ["bash", "-c", fix_shell], + env=fix_env, + text=True, + capture_output=True, + check=False, + ).returncode + == 0 + ) + assert ( + subprocess.run( + ["bash", "-c", fix_shell], + env={**fix_env, "TARGET_REPOSITORY": "OtherOrg/Orgmetra"}, + text=True, + capture_output=True, + check=False, + ).returncode + == 1 + ) + + +def test_router_and_sweep_accept_injected_orgmetra_allowlist() -> None: + """Router/sweep treat Orgmetra as dispatchable only from the injected variable.""" + allowlist = parse_repository_allowlist(INJECTED_ALLOWLIST) + assert ORGMETRA in allowlist + with pytest.raises(ValueError, match="invalid repository"): + parse_repository_allowlist("OtherOrg/Orgmetra") + event = { + "repository": {"full_name": ORGMETRA}, + "issue": { + "number": 26, + "pull_request": {"url": "https://api.github.test/pr/26"}, + }, + "comment": { + "id": 91, + "body": "@opencode-agent", + "author_association": "MEMBER", + "user": {"login": "maintainer", "type": "User"}, + }, + "pull_request": { + "state": "open", + "head": {"sha": ORGMETRA_26_HEAD, "ref": ORGMETRA_26_HEAD_REF}, + "base": {"ref": ORGMETRA_26_BASE_REF, "sha": ORGMETRA_26_BASE_SHA}, + }, + } + request = parse_event(event) + assert request is not None + assert eligible_agents(request, opencode_allowlist=allowlist) == ( + ("opencode-agent",), + (), + ) + assert eligible_agents(request, opencode_allowlist=frozenset()) == ( + (), + ("opencode-agent",), + ) diff --git a/tests/test_opencode_review_comment_helpers.py b/tests/test_opencode_review_comment_helpers.py new file mode 100644 index 000000000..9f0d0ca5c --- /dev/null +++ b/tests/test_opencode_review_comment_helpers.py @@ -0,0 +1,54 @@ +"""Tests for the shared OpenCode review mermaid helper.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +HELPER = REPO_ROOT / "scripts/ci/opencode_review_comment_helpers.sh" + + +def test_mermaid_helper_labels_crates_as_rust_crate(tmp_path: Path) -> None: + """Sourcing the publisher helper labels crates/ as a Rust crate surface.""" + bash = shutil.which("bash") + if bash is None: + return + changed = tmp_path / "changed.txt" + changed.write_text( + "crates/originweave-destination/src/lib.rs\n" + "crates/originweave-destination/src/resolution.rs\n" + "crates/originweave-destination/tests/resolution_freshness.rs\n", + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + (fake_bin / "gh").write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + (fake_bin / "gh").chmod(0o755) + script = f""" + set -euo pipefail + . "{HELPER}" + GH_REPOSITORY=ContextualWisdomLab/OriginWeave + PR_NUMBER=47 + OPENCODE_CHANGED_FILES_FILE="{changed}" + emit_change_flow_mermaid_graph UNKNOWN + """ + result = subprocess.run( + [bash, "-c", script], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PATH": f"{fake_bin}:{os.environ.get('PATH', '')}"}, + ) + assert result.returncode == 0, result.stderr + assert "Changed file (3 files)" not in result.stdout + assert "originweave-destination" in result.stdout + + +def test_helper_sources_python_surfaces_module() -> None: + """The shared helper delegates mermaid rendering to the tested Python module.""" + text = HELPER.read_text(encoding="utf-8") + assert "opencode_review_surfaces.py" in text + assert 'add("other", "Changed file"' not in text diff --git a/tests/test_opencode_review_receipt_gate.py b/tests/test_opencode_review_receipt_gate.py new file mode 100644 index 000000000..8ef47273d --- /dev/null +++ b/tests/test_opencode_review_receipt_gate.py @@ -0,0 +1,291 @@ +"""Formal-review receipt tests, including aFIPC stale-head and kaefa stub fixtures.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.ci import opencode_review_receipt_gate as receipt + + +def review( + *, + commit: str, + state: str = "CHANGES_REQUESTED", + login: str = "opencode-agent[bot]", + body: str = "", + review_id: int = 1, +) -> dict[str, object]: + """Build one REST pull-request review object.""" + if not body: + body = ( + "## Pull request overview\n\n" + "OpenCode reviewed the current-head product diff. Coverage is a separate gate.\n\n" + f"- Head SHA: `{commit}`\n" + ) + return { + "id": review_id, + "state": state, + "body": body, + "user": {"login": login}, + "commit_id": commit, + } + + +def test_afipc_230_stale_changes_requested_are_not_current() -> None: + """Stale OpenCode CHANGES_REQUESTED on old aFIPC heads cannot satisfy 5eda857.""" + stale = [ + review(commit=head, review_id=index) + for index, head in enumerate(sorted(receipt.AFIPC_230_STALE_HEADS), start=10) + ] + found, reason = receipt.evaluate_receipts(stale, receipt.AFIPC_230_HEAD) + assert found is None + assert "stale" in reason + current = review(commit=receipt.AFIPC_230_HEAD, state="COMMENTED", review_id=99) + found, reason = receipt.evaluate_receipts([*stale, current], receipt.AFIPC_230_HEAD) + assert found is current + assert "formal review" in reason + + +def test_kaefa_79_stub_has_no_current_head_formal_receipt() -> None: + """A 3-second green stub without a product-file review stays fail-closed.""" + found, reason = receipt.evaluate_receipts([], receipt.KAEFA_79_HEAD) + assert found is None + assert "no current-head formal" in reason + + +def test_draft_never_accepts_bot_approve_as_receipt() -> None: + """Draft PRs may have a COMMENT product review, never a bot APPROVE receipt.""" + approve = review( + commit=receipt.AFIPC_230_HEAD, + state="APPROVED", + body=( + "OpenCode reviewed the current-head bounded evidence and found no blocking issues.\n" + f"- Head SHA: `{receipt.AFIPC_230_HEAD}`\n" + "- Result: APPROVE\n" + ), + ) + found, reason = receipt.evaluate_receipts( + [approve], receipt.AFIPC_230_HEAD, is_draft=True + ) + assert found is None + assert "never receive bot APPROVE" in reason + comment = review(commit=receipt.AFIPC_230_HEAD, state="COMMENTED") + found, _ = receipt.evaluate_receipts( + [comment], receipt.AFIPC_230_HEAD, is_draft=True + ) + assert found is comment + + +def test_status_comment_and_mention_payloads_are_not_receipts() -> None: + """Issue-comment status text and @mentions cannot green the required check.""" + status = review( + commit=receipt.AFIPC_230_HEAD, + body="## OpenCode Review Status\n\n- Gate result: `COMMENT`\n", + ) + ok, reason = receipt.is_formal_receipt( + status, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "status-only" in reason or "malformed" in reason + mention = review(commit=receipt.AFIPC_230_HEAD, body="@opencode-agent please review") + ok, reason = receipt.is_formal_receipt( + mention, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "mention" in reason + empty = review(commit=receipt.AFIPC_230_HEAD, body=" ") + assert receipt.is_mention_or_malformed(str(empty["body"])) is True + mismatched_body = review( + commit=receipt.AFIPC_230_HEAD, + body=( + "## Pull request overview\n\n" + f"- Head SHA: `{next(iter(receipt.AFIPC_230_STALE_HEADS))}`\n" + ), + ) + assert receipt.review_matches_head(mismatched_body, receipt.AFIPC_230_HEAD) is False + found, reason = receipt.evaluate_receipts([mismatched_body], receipt.AFIPC_230_HEAD) + assert found is None + assert "stale" in reason + ok, reason = receipt.is_formal_receipt( + mismatched_body, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "stale" in reason + + +def test_receipt_helpers_cover_graphql_and_invalid_identity() -> None: + """GraphQL-shaped reviews and missing identity fields fail closed.""" + assert receipt.review_author({}) == "" + assert receipt.review_author({"user": "bad"}) == "" + assert receipt.review_commit({}) == "" + assert receipt.review_commit({"commit": "bad"}) == "" + assert receipt.review_matches_head(review(commit=receipt.AFIPC_230_HEAD), "") is False + graphql = { + "id": 7, + "state": "COMMENTED", + "body": "## Pull request overview\nOpenCode reviewed the current-head product diff.\n", + "author": {"login": "github-actions[bot]"}, + "commit": {"oid": receipt.AFIPC_230_HEAD}, + } + assert receipt.review_author(graphql) == "github-actions[bot]" + assert receipt.review_commit(graphql) == receipt.AFIPC_230_HEAD + ok, _ = receipt.is_formal_receipt(graphql, receipt.AFIPC_230_HEAD, is_draft=False) + assert ok is True + found, reason = receipt.evaluate_receipts( + [review(commit=receipt.AFIPC_230_HEAD), "skip"], + receipt.AFIPC_230_HEAD, + ) + assert found is not None + human_then_formal = receipt.evaluate_receipts( + [ + review(commit=receipt.AFIPC_230_HEAD, review_id=2), + review(commit=receipt.AFIPC_230_HEAD, login="seonghobae", review_id=3), + ], + receipt.AFIPC_230_HEAD, + ) + assert human_then_formal[0] is not None + stale_body = review( + commit=receipt.AFIPC_230_HEAD, + body=( + "## Pull request overview\n\n" + f"- Head SHA: `{next(iter(receipt.AFIPC_230_STALE_HEADS))}`\n" + ), + ) + found, reason = receipt.evaluate_receipts( + ["skip", stale_body, stale_body], + receipt.AFIPC_230_HEAD, + ) + assert found is None + assert "stale" in reason + found, reason = receipt.evaluate_receipts([], "deadbeef") + assert found is None + assert "40-character" in reason + human = review(commit=receipt.AFIPC_230_HEAD, login="seonghobae") + ok, reason = receipt.is_formal_receipt( + human, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "not an OpenCode publisher" in reason + pending = review(commit=receipt.AFIPC_230_HEAD, state="PENDING") + ok, reason = receipt.is_formal_receipt( + pending, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "not a formal review verdict" in reason + missing_id = review(commit=receipt.AFIPC_230_HEAD) + missing_id.pop("id") + ok, reason = receipt.is_formal_receipt( + missing_id, receipt.AFIPC_230_HEAD, is_draft=False + ) + assert ok is False + assert "missing pullrequestreview id" in reason + + +def test_receipt_cli_and_fetch(tmp_path: Path, capsys, monkeypatch) -> None: + """CLI accepts a current-head receipt file and annotates a missing receipt.""" + path = tmp_path / "reviews.json" + path.write_text( + json.dumps([review(commit=receipt.AFIPC_230_HEAD, state="COMMENTED")]), + encoding="utf-8", + ) + assert ( + receipt.main( + [ + "--head-sha", + receipt.AFIPC_230_HEAD, + "--reviews-file", + str(path), + ] + ) + == 0 + ) + assert "formal OpenCode receipt" in capsys.readouterr().out + + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + empty = tmp_path / "empty.json" + empty.write_text("[]", encoding="utf-8") + assert ( + receipt.main( + ["--head-sha", receipt.KAEFA_79_HEAD, "--reviews-file", str(empty)] + ) + == 1 + ) + assert "receipt missing" in summary.read_text(encoding="utf-8").lower() or ( + "no current-head" in capsys.readouterr().err + ) + + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + assert receipt.main(["--head-sha", receipt.AFIPC_230_HEAD]) == 1 + + def fake_run(args, **kwargs): + assert args[0] == "gh" + return type( + "Completed", + (), + { + "returncode": 0, + "stdout": json.dumps( + [review(commit=receipt.AFIPC_230_HEAD, state="COMMENTED")] + ), + "stderr": "", + }, + )() + + monkeypatch.setattr(receipt.subprocess, "run", fake_run) + assert ( + receipt.main( + [ + "--repo", + "ContextualWisdomLab/aFIPC", + "--pr-number", + "230", + "--head-sha", + receipt.AFIPC_230_HEAD, + ] + ) + == 0 + ) + + def fake_fail(args, **kwargs): + return type("Completed", (), {"returncode": 1, "stdout": "", "stderr": "nope"})() + + monkeypatch.setattr(receipt.subprocess, "run", fake_fail) + with pytest.raises(receipt.ReceiptGateError, match="lookup failed"): + receipt.fetch_reviews("ContextualWisdomLab/aFIPC", 230) + + def fake_bad(args, **kwargs): + return type("Completed", (), {"returncode": 0, "stdout": "{}", "stderr": ""})() + + monkeypatch.setattr(receipt.subprocess, "run", fake_bad) + with pytest.raises(receipt.ReceiptGateError, match="malformed"): + receipt.fetch_reviews("ContextualWisdomLab/aFIPC", 230) + + bad_file = tmp_path / "obj.json" + bad_file.write_text("{}", encoding="utf-8") + with pytest.raises(receipt.ReceiptGateError, match="JSON array"): + receipt.load_reviews(str(bad_file)) + + def unexpected_run(args, **kwargs): + raise AssertionError(f"gh must not be invoked with unvalidated input: {args!r}") + + monkeypatch.setattr(receipt.subprocess, "run", unexpected_run) + with pytest.raises(receipt.ReceiptGateError, match="owner/repo"): + receipt.fetch_reviews("../evil", 230) + monkeypatch.setattr( + receipt.sys, + "stdin", + type( + "Stdin", + (), + { + "read": lambda self: json.dumps( + [review(commit=receipt.AFIPC_230_HEAD, state="COMMENTED")] + ) + }, + )(), + ) + assert receipt.load_reviews("-")[0]["commit_id"] == receipt.AFIPC_230_HEAD diff --git a/tests/test_opencode_review_surfaces.py b/tests/test_opencode_review_surfaces.py new file mode 100644 index 000000000..d05fd10e8 --- /dev/null +++ b/tests/test_opencode_review_surfaces.py @@ -0,0 +1,692 @@ +"""Regression tests for distinct OpenCode review and status surfaces.""" + +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +import pytest + +from scripts.ci import opencode_review_surfaces as surfaces + +ROOT = Path(__file__).resolve().parents[1] +ORIGINWEAVE_47_FILES = [ + "crates/originweave-destination/src/lib.rs", + "crates/originweave-destination/src/resolution.rs", + "crates/originweave-destination/tests/resolution_freshness.rs", +] +HEAD = "79cf275686e2376a51783a2d03128eca21e7c0e5" + + +def test_crates_paths_are_rust_crate_surfaces() -> None: + """OriginWeave-style crates/ changes are Rust crate surfaces, not 'Changed file'.""" + classified = surfaces.classify_surfaces(ORIGINWEAVE_47_FILES) + assert len(classified) == 1 + assert classified[0]["kind"] == "rust-crate" + assert "Rust crate: originweave-destination" in classified[0]["surface"] + assert "3 files" in classified[0]["surface"] + assert classified[0]["surface"].startswith("Changed file") is False + + +def test_src_layouts_are_language_surfaces() -> None: + """src/ Python and TypeScript layouts keep language-specific labels.""" + python_surface = surfaces.classify_changed_path("src/originweave/resolution.py") + typescript_surface = surfaces.classify_changed_path("src/lib/resolution.ts") + assert python_surface["kind"] == "python" + assert python_surface["surface"].startswith("Python package:") + assert typescript_surface["kind"] == "typescript" + assert typescript_surface["surface"].startswith("TypeScript/JavaScript:") + + +def test_mermaid_labels_originweave_crate_not_changed_file_inventory() -> None: + """The #47 mermaid must name the Rust crate instead of 'Changed file (3 files)'.""" + diagram = surfaces.emit_mermaid(ORIGINWEAVE_47_FILES) + assert "Changed file (3 files)" not in diagram + assert "originweave-destination" in diagram + assert "sequenceDiagram" in diagram or "classDiagram" in diagram + + +def test_mermaid_uses_public_rust_api_when_source_exists(tmp_path: Path) -> None: + """A class diagram is preferred when the changed crate exposes public types.""" + source = tmp_path / "crates/originweave-destination/src/resolution.rs" + source.parent.mkdir(parents=True) + source.write_text( + "pub struct FreshResolutionSnapshot {\n address: String,\n}\n" + "pub fn resolve_fresh() {}\n", + encoding="utf-8", + ) + diagram = surfaces.emit_mermaid( + ["crates/originweave-destination/src/resolution.rs"], + source_root=tmp_path, + ) + assert "classDiagram" in diagram + assert "FreshResolutionSnapshot" in diagram + assert "resolve_fresh" in diagram + assert "class FreshResolutionSnapshot" in diagram + assert "class resolve_fresh" in diagram + assert "FreshResolutionSnapshot --> resolve_fresh" not in diagram + assert " --> " not in diagram + assert "Changed file" not in diagram + + +def test_coverage_fail_review_mentions_crate_files_not_central_workflow() -> None: + """A coverage-gate failure still produces a review of the changed crate files.""" + review = surfaces.build_fallback_review( + changed_files=ORIGINWEAVE_47_FILES, + head_sha=HEAD, + run_id="31951179896", + run_attempt="1", + coverage_result="failure", + ) + comment = surfaces.build_status_comment( + result="COVERAGE_BLOCKED", + head_sha=HEAD, + run_id="31951179896", + run_attempt="1", + coverage_result="failure", + coverage_summary="## Coverage Decision\n\n- Result: FAIL\n", + ) + surfaces.distinct_surfaces(review, comment) + assert review != comment + for path in ORIGINWEAVE_47_FILES: + assert path in review + assert path not in comment + assert ".github/workflows/opencode-review.yml:1" not in review + assert "Coverage gate: `failure`" in review + assert "Coverage gate: `failure`" in comment + assert "## Pull request overview" in review + assert "## Pull request overview" not in comment + assert "## Findings" not in comment + + +def test_workflow_anchor_forbidden_unless_file_is_in_diff() -> None: + """The central workflow file is not a finding on an unrelated product PR.""" + assert ( + surfaces.coverage_anchor_allowed( + ".github/workflows/opencode-review.yml", + ORIGINWEAVE_47_FILES, + ) + is False + ) + assert ( + surfaces.coverage_anchor_allowed( + ".github/workflows/opencode-review.yml", + [".github/workflows/opencode-review.yml"], + ) + is True + ) + + +def test_korean_status_and_review_keep_identifiers() -> None: + """Korean PRs stay Korean while crate paths remain unchanged.""" + review = surfaces.build_fallback_review( + changed_files=ORIGINWEAVE_47_FILES, + head_sha=HEAD, + run_id="1", + run_attempt="1", + language="korean", + coverage_result="failure", + ) + comment = surfaces.build_status_comment( + result="COVERAGE_BLOCKED", + head_sha=HEAD, + run_id="1", + run_attempt="1", + coverage_result="failure", + language="korean", + ) + surfaces.distinct_surfaces(review, comment) + assert "변경 파일" in review + assert "게이트 상태" in comment + assert "originweave-destination" in review + + +def test_review_event_keeps_request_changes_and_downgrades_approve() -> None: + """Coverage failure may not publish APPROVE; code findings stay REQUEST_CHANGES.""" + assert surfaces.review_event_when_coverage_blocks("APPROVE") == "COMMENT" + assert surfaces.review_event_when_coverage_blocks("REQUEST_CHANGES") == "REQUEST_CHANGES" + assert surfaces.review_event_when_coverage_blocks("COMMENT") == "COMMENT" + + +def test_distinct_surfaces_reject_duplicated_overview() -> None: + """The #47 publication shape — identical overview on both surfaces — fails.""" + body = "## Pull request overview\n\n## Findings\n" + with pytest.raises(ValueError, match="must not equal"): + surfaces.distinct_surfaces(body, body) + with pytest.raises(ValueError, match="status comment must not contain"): + surfaces.distinct_surfaces("review", "## Pull request overview\n") + with pytest.raises(ValueError, match="formal review must not reuse"): + surfaces.distinct_surfaces("## OpenCode Review Overview\n", "status") + with pytest.raises(ValueError, match="formal review must not reuse"): + surfaces.distinct_surfaces("## OpenCode Review Status\n", "status") + + +def test_rejects_path_traversal() -> None: + """Publisher path classification fails closed on parent-directory segments.""" + with pytest.raises(ValueError, match="bounded repository path"): + surfaces.posix_path("../secrets") + assert surfaces.posix_path("./crates/originweave-destination/src/lib.rs") == ( + "crates/originweave-destination/src/lib.rs" + ) + assert ( + surfaces.classify_changed_path("./.github/workflows/ci.yml")["kind"] == "workflow" + ) + + +def test_empty_paths_use_generic_evidence_diagram() -> None: + """No changed files still produce a bounded evidence flowchart.""" + assert "OpenCode evidence" in surfaces.emit_mermaid([]) + + +def test_conflict_state_marks_blocked_paths() -> None: + """DIRTY merge state keeps the conflict node on classified surfaces.""" + diagram = surfaces.emit_mermaid(["docs/readme.md"], merge_state="DIRTY") + assert "Merge conflict blocks this path" in diagram + assert "Docs: readme.md" in diagram + + +def test_cli_renders_originweave_surfaces( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """The workflow CLI emits the split surfaces used by the publisher.""" + changed = tmp_path / "changed.txt" + changed.write_text("\n".join(ORIGINWEAVE_47_FILES) + "\n", encoding="utf-8") + assert ( + surfaces.main( + [ + "emit-mermaid", + "--changed-files-file", + str(changed), + ] + ) + == 0 + ) + mermaid = capsys.readouterr().out + assert "Changed file (3 files)" not in mermaid + assert "originweave-destination" in mermaid + + assert ( + surfaces.main( + [ + "build-status", + "--result", + "COVERAGE_BLOCKED", + "--head-sha", + HEAD, + "--run-id", + "31951179896", + "--run-attempt", + "1", + "--coverage-result", + "failure", + "--coverage-summary", + "llvm-tools-preview missing", + ] + ) + == 0 + ) + status = capsys.readouterr().out + assert "## Pull request overview" not in status + assert "## Findings" not in status + assert "llvm-tools-preview missing" not in status + assert "Coverage gate: `failure`" in status + + assert ( + surfaces.main( + [ + "build-fallback-review", + "--changed-files-file", + str(changed), + "--head-sha", + HEAD, + "--run-id", + "31951179896", + "--run-attempt", + "1", + "--coverage-result", + "failure", + ] + ) + == 0 + ) + review = capsys.readouterr().out + assert "crates/originweave-destination/src/resolution.rs" in review + assert ".github/workflows/opencode-review.yml:1" not in review + surfaces.distinct_surfaces(review, status) + + +def test_script_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The executable workflow entrypoint delegates to main.""" + changed = tmp_path / "changed.txt" + changed.write_text("docs/guide.md\n", encoding="utf-8") + script = Path(surfaces.__file__) + monkeypatch.setattr( + sys, + "argv", + [str(script), "emit-mermaid", "--changed-files-file", str(changed)], + ) + with pytest.raises(SystemExit, match="0"): + runpy.run_path(str(script), run_name="__main__") + + +def test_remaining_classifiers_cover_common_layouts() -> None: + """Workflow, CI, backend, frontend, Go, and loose files keep specific labels.""" + assert surfaces.classify_changed_path(".github/workflows/ci.yml")["kind"] == "workflow" + assert surfaces.classify_changed_path("scripts/ci/gate.sh")["kind"] == "ci" + assert surfaces.classify_changed_path("backend/api.py")["kind"] == "backend" + assert surfaces.classify_changed_path("frontend/app.tsx")["kind"] == "frontend" + assert surfaces.classify_changed_path("pkg/main.go")["kind"] == "go" + assert surfaces.classify_changed_path("lib.rs")["kind"] == "rust" + assert surfaces.classify_changed_path("module.py")["kind"] == "python" + assert surfaces.classify_changed_path("app.ts")["kind"] == "typescript" + assert surfaces.classify_changed_path("tests/test_resolution.py")["kind"] == "tests" + assert surfaces.classify_changed_path("tests/fixture.rs")["kind"] == "tests" + assert surfaces.classify_changed_path("LICENSE")["kind"] == "other" + + +def test_central_workflow_in_diff_is_a_workflow_surface_not_line_one_finding() -> None: + """When the central workflow actually changed, name it as a workflow surface.""" + review = surfaces.build_fallback_review( + changed_files=[".github/workflows/opencode-review.yml"], + head_sha=HEAD, + run_id="1", + run_attempt="1", + ) + assert ".github/workflows/opencode-review.yml" in review + assert ".github/workflows/opencode-review.yml:1" not in review + assert "Workflow: opencode-review.yml" in surfaces.emit_mermaid( + [".github/workflows/opencode-review.yml"] + ) + + +def test_fallback_review_empty_file_list() -> None: + """Missing changed-file evidence still produces a distinct review body.""" + review = surfaces.build_fallback_review( + changed_files=[], + head_sha=HEAD, + run_id="1", + run_attempt="1", + ) + assert "No changed product files" in review + assert ".github/workflows/opencode-review.yml:1" not in review + + +def test_fallback_review_rejects_accidental_central_workflow_citation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A synthesized body may not mention the central workflow unless it changed.""" + monkeypatch.setattr(surfaces, "CENTRAL_WORKFLOW_ANCHOR", "Coverage is a separate gate") + with pytest.raises(ValueError, match="must not cite"): + surfaces.build_fallback_review( + changed_files=ORIGINWEAVE_47_FILES, + head_sha=HEAD, + run_id="1", + run_attempt="1", + ) + + +def test_distinct_surfaces_reject_findings_on_status_comment() -> None: + """Status comments cannot carry the formal findings block.""" + with pytest.raises(ValueError, match="status comment must not contain"): + surfaces.distinct_surfaces("review", "## Findings\n") + + +def test_rust_api_symbols_skip_missing_and_symlink_sources(tmp_path: Path) -> None: + """Public-API extraction ignores absent or symlinked sources.""" + assert surfaces.rust_api_symbols(None, ORIGINWEAVE_47_FILES) == [] + missing = surfaces.rust_api_symbols( + tmp_path, ["crates/originweave-destination/src/lib.rs"] + ) + assert missing == [] + target = tmp_path / "outside.rs" + target.write_text("pub struct Leak {}\n", encoding="utf-8") + linked = tmp_path / "crates/originweave-destination/src/lib.rs" + linked.parent.mkdir(parents=True) + linked.symlink_to(target) + assert ( + surfaces.rust_api_symbols(tmp_path, ["crates/originweave-destination/src/lib.rs"]) + == [] + ) + + +def test_rust_api_symbols_replace_invalid_utf8(tmp_path: Path) -> None: + """A malformed Rust text blob cannot abort review-surface publication.""" + source = tmp_path / "lib.rs" + source.write_bytes(b"pub struct BrokenEncoding {\xff\n}\n") + + assert surfaces.rust_api_symbols(tmp_path, ["lib.rs"]) == ["BrokenEncoding"] + + +def test_crates_root_and_grouped_python_surfaces() -> None: + """A bare crates/ path and repeated src/ files keep specific labels.""" + assert surfaces.classify_changed_path("crates")["kind"] == "rust-crate" + grouped = surfaces.classify_surfaces( + ["src/one.py", "src/two.py", "docs/a.md", "docs/b.md"] + ) + python = next(item for item in grouped if item["kind"] == "python") + docs = next(item for item in grouped if item["kind"] == "docs") + assert "2 files" in python["surface"] + assert "2 files" in docs["surface"] + + +def test_surfaces_cover_remaining_review_branches(tmp_path: Path) -> None: + """Empty paths, duplicate symbols, loose Rust files, and control blocks are covered.""" + assert surfaces.classify_surfaces(["", " "]) == [] + source = tmp_path / "lib.rs" + source.write_text( + "pub struct Once {}\npub struct Once {}\n", + encoding="utf-8", + ) + assert surfaces.rust_api_symbols(tmp_path, ["README.md", "lib.rs"]) == ["Once"] + diagram = surfaces.emit_mermaid(["lib.rs"], source_root=tmp_path) + assert "classDiagram" in diagram + assert "Once -->" not in diagram + loose = surfaces.emit_mermaid(["src/resolution.rs"]) + assert "Rust crate" in loose + quoted = surfaces._quote_label('Fresh\r\n"Snapshot"') + assert '"' not in quoted + assert "\n" not in quoted + status = surfaces.build_status_comment( + result="APPROVE", + head_sha=HEAD, + run_id="1", + run_attempt="1", + coverage_result="success", + language="korean", + control_block="", + ) + assert "커버리지 증거 작업이 통과하지 않아" not in status + assert "opencode-review-control-v1" in status + review = surfaces.build_fallback_review( + changed_files=["lib.rs"], + head_sha=HEAD, + run_id="1", + run_attempt="1", + source_root=tmp_path, + language="korean", + ) + assert "변경 API" in review + assert "`Once`" in review + + +def test_cargo_toml_is_a_rust_surface() -> None: + """Root Cargo.toml is a Rust manifest, not a generic changed file.""" + classified = surfaces.classify_changed_path("Cargo.toml") + assert classified["kind"] == "rust" + assert classified["surface"].startswith("Rust manifest:") + diagram = surfaces.emit_mermaid(["Cargo.toml", "crates/demo/src/lib.rs"]) + assert "Changed file" not in diagram + assert "demo" in diagram or "Rust" in diagram + + +def test_extract_model_prose_strips_sentinel_and_control() -> None: + """Publisher keeps walkthrough text and drops the control-plane trailer.""" + raw = ( + "## Verdict\n\nREQUEST_CHANGES\n\n" + "Walkthrough of crates/originweave-destination/src/resolution.rs\n" + "\n" + "\n" + ) + prose = surfaces.extract_model_prose(raw) + assert "Walkthrough of crates/originweave-destination/src/resolution.rs" in prose + assert "opencode-review-gate" not in prose + assert "opencode-review-control-v1" not in prose + + +def test_format_request_changes_keeps_model_prose_and_strips_fake_anchor() -> None: + """REQUEST_CHANGES keeps the model walkthrough and never cites workflow:1.""" + body = surfaces.format_request_changes_review( + model_prose=( + "## Pull request overview\n\n" + "Reviewed resolution.rs and the freshness test.\n\n" + "```mermaid\nsequenceDiagram\n Caller->>Crate: resolve\n```\n" + ), + findings=[ + { + "severity": "HIGH", + "path": ".github/workflows/opencode-review.yml", + "line": 1, + "title": "Coverage evidence failed", + "problem": "gate failed", + "root_cause": "sandbox", + "fix_direction": "fix rustc", + "regression_test_direction": "rerun", + } + ], + head_sha=HEAD, + run_id="31951179896", + run_attempt="1", + reason="coverage blocked", + changed_files=ORIGINWEAVE_47_FILES, + ) + assert "Reviewed resolution.rs and the freshness test." in body + assert "sequenceDiagram" in body + assert "## Findings" in body + assert ".github/workflows/opencode-review.yml:1" not in body + assert "Review process" in body + + +def test_format_request_changes_rebuilds_when_model_prose_missing() -> None: + """Without model prose, structured findings still form a review body.""" + body = surfaces.format_request_changes_review( + model_prose="", + findings=[ + { + "severity": "P1", + "path": "crates/originweave-destination/src/resolution.rs", + "line": 12, + "title": "Stale snapshot", + } + ], + head_sha=HEAD, + run_id="1", + run_attempt="1", + ) + assert "## Verdict" in body + assert "crates/originweave-destination/src/resolution.rs:12" in body + + +def test_cli_extract_and_format_request_changes( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Workflow CLIs keep model prose and emit a status-safe comment separately.""" + model = tmp_path / "model.md" + model.write_text( + "## Verdict\n\nREQUEST_CHANGES\n\nRelated PRs: none\n" + "\n", + encoding="utf-8", + ) + findings = tmp_path / "findings.json" + findings.write_text( + '[{"severity":"HIGH","path":"crates/demo/src/lib.rs","line":4,"title":"Bug"}]', + encoding="utf-8", + ) + changed = tmp_path / "changed.txt" + changed.write_text("crates/demo/src/lib.rs\n", encoding="utf-8") + assert surfaces.main(["extract-prose", "--model-body-file", str(model)]) == 0 + assert "Related PRs: none" in capsys.readouterr().out + assert ( + surfaces.main( + [ + "format-request-changes", + "--head-sha", + HEAD, + "--run-id", + "1", + "--run-attempt", + "1", + "--model-body-file", + str(model), + "--findings-json-file", + str(findings), + "--changed-files-file", + str(changed), + "--reason", + "bug", + ] + ) + == 0 + ) + rendered = capsys.readouterr().out + assert "Related PRs: none" in rendered + assert "crates/demo/src/lib.rs:4" in rendered + assert ( + surfaces.main( + [ + "build-status", + "--result", + "REQUEST_CHANGES", + "--head-sha", + HEAD, + "--run-id", + "1", + "--run-attempt", + "1", + "--coverage-result", + "failure", + "--model-pool-outcome", + "success", + "--verdict", + "REQUEST_CHANGES", + "--formal-review-url", + "https://github.com/ContextualWisdomLab/OriginWeave/pull/47#pullrequestreview-1", + ] + ) + == 0 + ) + status = capsys.readouterr().out + assert "## Findings" not in status + assert "Model pool: `success`" in status + assert "Verdict: `REQUEST_CHANGES`" in status + assert "pullrequestreview-1" in status + + +def test_central_workflow_line_one_kept_when_that_file_changed() -> None: + """A real edit to the central workflow may cite that file, including line 1.""" + body = surfaces.format_request_changes_review( + model_prose="Inspected `.github/workflows/opencode-review.yml:1`.\n", + findings=[ + { + "path": ".github/workflows/opencode-review.yml", + "line": 1, + "title": "Workflow contract", + } + ], + head_sha=HEAD, + run_id="1", + run_attempt="1", + changed_files=[".github/workflows/opencode-review.yml"], + ) + assert ".github/workflows/opencode-review.yml:1" in body + + +def test_format_request_changes_keeps_existing_findings_heading() -> None: + """Structured findings append under an existing Findings heading.""" + body = surfaces.format_request_changes_review( + model_prose="## Findings\n\nModel already started the findings list.\n", + structured_findings="### 1. HIGH crates/demo/src/lib.rs:3 - Extra", + head_sha=HEAD, + run_id="1", + run_attempt="1", + ) + assert body.count("## Findings") == 1 + assert "Model already started the findings list." in body + assert "crates/demo/src/lib.rs:3" in body + + +def test_format_request_changes_skips_duplicate_identity_and_string_findings() -> None: + """Already-rendered identity/reason lines are not duplicated.""" + prose = ( + "## Verdict\n\nREQUEST_CHANGES\n\n" + f"- Head SHA: `{HEAD}`\n" + "- Reason: already stated\n" + ) + body = surfaces.format_request_changes_review( + model_prose=prose, + structured_findings="### 1. HIGH crates/demo/src/lib.rs:2 - Bug", + head_sha=HEAD, + run_id="1", + run_attempt="1", + reason="already stated", + ) + assert body.count(f"- Head SHA: `{HEAD}`") == 1 + assert body.count("- Reason: already stated") == 1 + assert "crates/demo/src/lib.rs:2" in body + + +def test_format_request_changes_cli_handles_object_findings_json( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """A non-list findings document is ignored instead of crashing publish.""" + findings = tmp_path / "findings.json" + findings.write_text('{"nope": true}', encoding="utf-8") + assert ( + surfaces.main( + [ + "format-request-changes", + "--head-sha", + HEAD, + "--run-id", + "1", + "--run-attempt", + "1", + "--findings-json-file", + str(findings), + ] + ) + == 0 + ) + assert "## Verdict" in capsys.readouterr().out + assert ( + surfaces.main( + [ + "format-request-changes", + "--head-sha", + HEAD, + "--run-id", + "1", + "--run-attempt", + "1", + ] + ) + == 0 + ) + assert "REQUEST_CHANGES" in capsys.readouterr().out + + +def test_format_structured_findings_skips_non_mappings() -> None: + """Non-object findings are ignored so a bad control array cannot crash publish.""" + assert surfaces.format_structured_findings(["skip", 1]) == "" + + +def test_publisher_workflow_cannot_replace_review_with_coverage_finding( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The #47 publisher shape — coverage REQUEST_CHANGES as the whole review — is gone.""" + monkeypatch.chdir(tmp_path) + workflow = (ROOT / ".github/workflows/opencode-review-dispatch.yml").read_text( + encoding="utf-8" + ) + assert "publish_fallback_diff_review" in workflow + assert "opencode_review_surfaces.py build-status" in workflow + assert "opencode_review_surfaces.py build-fallback-review" in workflow + assert ".github/workflows/opencode-review.yml:1" not in workflow + coverage_fn = workflow.split("request_changes_for_coverage_evidence_failure()", 1)[1] + coverage_fn = coverage_fn.split("create_pull_review_with_payload()", 1)[0] + assert "create_pull_review" not in coverage_fn + assert "update_review_overview" in coverage_fn + assert 'update_review_overview "COVERAGE_BLOCKED"' in coverage_fn + fallback_fn = workflow.split("publish_fallback_diff_review()", 1)[1] + fallback_fn = fallback_fn.split("request_changes_for_coverage_evidence_failure()", 1)[0] + assert "create_pull_review" in fallback_fn + assert "request_changes_for_coverage_evidence_failure" in fallback_fn + assert fallback_fn.index("create_pull_review") < fallback_fn.index( + "request_changes_for_coverage_evidence_failure" + ) + model_skip = workflow.split("if [ \"$opencode_review_outcome\" != \"success\" ]; then", 1)[1] + model_skip = model_skip.split("selected_review_output_file=", 1)[0] + assert "publish_fallback_diff_review" in model_skip diff --git a/tests/test_opencode_self_modifying_strix_review.py b/tests/test_opencode_self_modifying_strix_review.py new file mode 100644 index 000000000..3fc7ceb40 --- /dev/null +++ b/tests/test_opencode_self_modifying_strix_review.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import subprocess +import textwrap +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/opencode-review-dispatch.yml" + + +def git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _workflow_function(name: str, next_name: str) -> str: + workflow = WORKFLOW.read_text(encoding="utf-8") + start_marker = f" {name}() {{\n" + end_marker = f"\n\n {next_name}() {{\n" + start = workflow.index(start_marker) + end = workflow.index(end_marker, start) + return textwrap.dedent(workflow[start:end]) + + +def _fixture_repo(tmp_path: Path, changed_path: str) -> tuple[Path, str, str]: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + target = repo / changed_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("base\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-m", "base") + base_sha = git(repo, "rev-parse", "HEAD") + target.write_text("head\n", encoding="utf-8") + git(repo, "add", ".") + git(repo, "commit", "-m", "head") + return repo, base_sha, git(repo, "rev-parse", "HEAD") + + +def _classify( + repo: Path, + base_sha: str, + head_sha: str, + evidence: str, + tmp_path: Path, +) -> int: + evidence_file = tmp_path / "evidence.log" + evidence_file.write_text(evidence, encoding="utf-8") + function = _workflow_function( + "self_modifying_strix_base_failure", + "leave_review_unchanged_for_self_modifying_strix_if_present", + ) + script = "\n".join( + ( + "set -euo pipefail", + "self_healed_strix_dependency_base_failure() { return 1; }", + function, + 'self_modifying_strix_base_failure "$1"', + ) + ) + return subprocess.run( + ["bash", "-c", script, "classifier", str(evidence_file)], + cwd=repo, + env={ + "PATH": "/usr/bin:/bin", + "OPENCODE_SOURCE_WORKDIR": str(repo), + "PR_BASE_SHA": base_sha, + "PR_HEAD_SHA": head_sha, + }, + check=False, + ).returncode + + +def _provider_failure(base_sha: str) -> str: + return "\n".join( + ( + f"2026-08-24T13:15:09.1271786Z [command]/usr/bin/git checkout --progress --force {base_sha}", + f"2026-08-24T13:15:09.1651569Z HEAD is now at {base_sha[:7]} trusted gate", + "2026-08-24T13:36:17.3938869Z Primary model unavailable; retrying with fallback 'openai-direct/gpt-5.6-luna'.", + "2026-08-24T13:36:22.0325148Z │ Error: 404 page not found │", + "2026-08-24T13:36:22.1229143Z Strix run failed for model 'openai-direct/gpt-5.6-luna' after 5s (exit code 1).", + "2026-08-24T13:36:22.3504809Z Strix fallback model 'openai-direct/gpt-5.6-luna' emitted provider infrastructure or failure-signal output; trying next configured fallback if available.", + "", + ) + ) + + +def test_provider_failure_from_exact_trusted_base_is_predecessor_evidence( + tmp_path: Path, +) -> None: + """A required Strix run executing the changed gate's base must not author a source verdict.""" + repo, base_sha, head_sha = _fixture_repo( + tmp_path, "scripts/ci/strix_quick_gate.sh" + ) + assert _classify( + repo, base_sha, head_sha, _provider_failure(base_sha), tmp_path + ) == 0 + + +@pytest.mark.parametrize( + "changed_path", + ("README.md", ".github/workflows/unrelated.yml"), +) +def test_unrelated_pr_cannot_suppress_provider_failure( + tmp_path: Path, changed_path: str +) -> None: + repo, base_sha, head_sha = _fixture_repo(tmp_path, changed_path) + assert _classify( + repo, base_sha, head_sha, _provider_failure(base_sha), tmp_path + ) != 0 + + +def test_missing_exact_base_checkout_cannot_suppress_failure(tmp_path: Path) -> None: + repo, base_sha, head_sha = _fixture_repo( + tmp_path, "scripts/ci/strix_quick_gate.sh" + ) + evidence = _provider_failure(base_sha).replace( + f"git checkout --progress --force {base_sha}", + f"git checkout --progress --force {head_sha}", + ) + assert _classify(repo, base_sha, head_sha, evidence, tmp_path) != 0 + + +def test_authoritative_vulnerability_evidence_remains_source_backed( + tmp_path: Path, +) -> None: + repo, base_sha, head_sha = _fixture_repo( + tmp_path, "scripts/ci/strix_quick_gate.sh" + ) + evidence = _provider_failure(base_sha) + ( + "2026-08-24T13:30:00.0000000Z │ Vulnerabilities 1 │\n" + "2026-08-24T13:30:00.0000001Z │ Severity: HIGH │\n" + ) + assert _classify(repo, base_sha, head_sha, evidence, tmp_path) != 0 diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 16a83b935..5284510eb 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "ed3f7b44f9afdd6ab295426e5d0440aeca6bdfb5" +REVIEW_DISPATCH_BLOB_SHA = "18e9c181dfde85c7d8a3210c657ae32e0c20e5f1" def _workflow_text(path: Path) -> str: diff --git a/tests/test_render_opencode_prompt_template.py b/tests/test_render_opencode_prompt_template.py index 7c543338c..8ec858fde 100644 --- a/tests/test_render_opencode_prompt_template.py +++ b/tests/test_render_opencode_prompt_template.py @@ -22,13 +22,13 @@ def test_render_prompt_replaces_only_explicit_placeholders(): "PR_NUMBER": "193", "OPENCODE_SOURCE_WORKDIR": "/tmp/pr-head", "OPENCODE_REVIEW_INTRO": "Use the shared review template.", - "PROMPT_MODEL_CANDIDATE": "github-models/openai/o4-mini", + "PROMPT_MODEL_CANDIDATE": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", }, ) assert rendered.startswith("Use the shared review template.") assert "Review PR #193 in /tmp/pr-head" in rendered - assert "github-models/openai/o4-mini" in rendered + assert "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5" in rendered assert '"$OPENCODE_SOURCE_WORKDIR"' in rendered assert "`python3 scripts/ci/sandboxed_verify.py" in rendered assert "$(echo should_not_run)" in rendered diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 1d79f1daa..6b3f52f58 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -459,14 +459,14 @@ def test_noema_review_credentials_and_llm_configuration_fail_closed() -> None: in workflow ) assert "Resolve Noema target repository visibility" in workflow - assert ( - 'if [ "$TARGET_REPOSITORY_PRIVATE" = "false" ] && ' - '[ -n "${NVIDIA_NIM_API_KEY:-}" ]' - ) in workflow + assert 'case "$TARGET_REPOSITORY_PRIVATE" in' in workflow + assert "Private diff evidence is not sent to the hosted NVIDIA NIM endpoint" in workflow assert "https://integrate.api.nvidia.com/v1/chat/completions" in workflow assert 'export NOEMA_LLM_MODEL="nvidia/nemotron-3-ultra-550b-a55b"' in workflow assert "NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}" in workflow assert "Noema LLM is unconfigured:" in workflow + assert "ContextualWisdomLab/Orgmetra" not in workflow + assert "^ContextualWisdomLab/[A-Za-z0-9_.-]+$" in workflow assert "mark_unconfigured()" not in workflow assert "review skipped until Noema is deployed" not in workflow assert "Noema app token is unavailable; review skipped." not in workflow @@ -545,6 +545,74 @@ def test_nvidia_nim_defaults_preserve_existing_fallbacks_without_secret( assert noema_probe.read_text() == "synthetic-openai-key" +def test_noema_visibility_keeps_private_diffs_off_public_nim() -> None: + """Route only public-repository review data to the hosted NIM endpoint.""" + noema_script = textwrap.dedent( + workflow_step( + workflow_text("noema-review.yml"), + "Run Noema LLM review and submit verdict", + ).split(" run: |\n", 1)[1] + ).split("python3 scripts/ci/noema_review_gate.py", 1)[0] + + def resolve_configuration(**overrides: str) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "PR_NUMBER": "1", + "GH_TOKEN": "synthetic-review-token", + "NOEMA_LLM_API_URL": "", + "NOEMA_LLM_MODEL": "", + "NOEMA_LLM_API_KEY": "", + "NVIDIA_NIM_API_KEY": "synthetic-nim-key", + "TARGET_REPOSITORY_PRIVATE": "false", + **overrides, + } + return subprocess.run( + ["bash", "-c", noema_script + "env\n"], + env=env, + capture_output=True, + text=True, + check=False, + ) + + def noema_environment(completed: subprocess.CompletedProcess[str]) -> dict[str, str]: + return { + key: value + for line in completed.stdout.splitlines() + if line.startswith("NOEMA_LLM_") + for key, value in [line.split("=", 1)] + } + + public = resolve_configuration() + assert public.returncode == 0 + assert noema_environment(public) == { + "NOEMA_LLM_API_URL": "https://integrate.api.nvidia.com/v1/chat/completions", + "NOEMA_LLM_MODEL": "nvidia/nemotron-3-ultra-550b-a55b", + "NOEMA_LLM_API_KEY": "synthetic-nim-key", + } + + private = resolve_configuration( + TARGET_REPOSITORY_PRIVATE="true", + NOEMA_LLM_API_URL="https://trusted-noema.internal/v1/chat/completions", + NOEMA_LLM_MODEL="trusted-private-reviewer", + NOEMA_LLM_API_KEY="synthetic-private-key", + ) + assert private.returncode == 0 + assert noema_environment(private) == { + "NOEMA_LLM_API_URL": "https://trusted-noema.internal/v1/chat/completions", + "NOEMA_LLM_MODEL": "trusted-private-reviewer", + "NOEMA_LLM_API_KEY": "synthetic-private-key", + } + + private_without_explicit_endpoint = resolve_configuration( + TARGET_REPOSITORY_PRIVATE="true", + ) + assert private_without_explicit_endpoint.returncode != 0 + assert "private repository requires an explicitly configured" in ( + private_without_explicit_endpoint.stdout + + private_without_explicit_endpoint.stderr + ) + + def test_noema_workflow_run_without_pull_request_skips_before_token_exchange() -> None: """Skip unassociated workflow runs before requesting review credentials.""" workflow = workflow_text("noema-review.yml") @@ -1455,13 +1523,22 @@ def test_pr_sarif_upload_rate_limits_do_not_mask_scanner_gates() -> None: def test_standalone_osv_scan_delegates_sarif_upload_to_central_gate() -> None: - """The supplemental OSV diff must not duplicate the central SARIF upload.""" + """The supplemental OSV scan must not duplicate the central SARIF upload.""" standalone = workflow_text("osv-scanner-pr.yml") central = workflow_text("security-scan.yml") - assert "upload-sarif: false" in standalone - assert "pinned upstream reusable workflow declares this permission" in standalone - assert "security-events: write" in standalone + assert ( + "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml" + not in standalone + ) + assert "osv-reporter-action" not in standalone + assert "github/codeql-action/upload-sarif" not in standalone + assert "security-events: write" not in standalone + assert ( + "google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a" + in standalone + ) + assert "continue-on-error: true" in standalone assert "--fail-on-vuln=true" in central assert "Print OSV findings being compared" in central assert "Upload OSV SARIF to code scanning" in central diff --git a/tests/test_rust_coverage_policy.py b/tests/test_rust_coverage_policy.py new file mode 100644 index 000000000..4758e767e --- /dev/null +++ b/tests/test_rust_coverage_policy.py @@ -0,0 +1,226 @@ +"""Tests for central Rust coverage policy selection.""" + +from __future__ import annotations + +import runpy +import sys +from pathlib import Path + +import pytest + +from scripts.ci import rust_coverage_policy as policy + + +def _write_manifest(root: Path, text: str) -> Path: + """Write a Cargo.toml under ``root`` and return its path.""" + manifest = root / "Cargo.toml" + manifest.write_text(text, encoding="utf-8") + return manifest + + +def test_metadata_uses_repository_threshold(tmp_path: Path) -> None: + """workspace.metadata.opencode.coverage keeps the llvm-cov threshold path.""" + manifest = _write_manifest( + tmp_path, + """ +[workspace] +members = ["crates/demo"] +rust-version = "1.97" + +[workspace.metadata.opencode.coverage] +minimum_lines = 80 +""", + ) + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 80 + assert plan.verifier is None + + +def test_originweave_style_verifier_skips_default_100(tmp_path: Path) -> None: + """A rust-version 1.97 workspace with verify_coverage.py is not default 100.""" + manifest = _write_manifest( + tmp_path, + """ +[workspace] +members = ["crates/demo"] +rust-version = "1.97" +""", + ) + verifier = tmp_path / "scripts" / "ci" / "verify_coverage.py" + verifier.parent.mkdir(parents=True) + verifier.write_text("print('ok')\n", encoding="utf-8") + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "repo-verifier" + assert plan.fail_under is None + assert plan.verifier == verifier + + +def test_shell_verifier_is_accepted(tmp_path: Path) -> None: + """A non-symlink verify_coverage.sh is a repo verifier.""" + manifest = _write_manifest(tmp_path, "[workspace]\nmembers = []\n") + verifier = tmp_path / "scripts" / "ci" / "verify_coverage.sh" + verifier.parent.mkdir(parents=True) + verifier.write_text("#!/bin/sh\n", encoding="utf-8") + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "repo-verifier" + assert plan.verifier == verifier + + +def test_symlink_verifier_is_ignored(tmp_path: Path) -> None: + """Symlinked verifiers are not trusted coverage evidence.""" + manifest = _write_manifest(tmp_path, "[workspace]\nmembers = []\n") + target = tmp_path / "outside.py" + target.write_text("print('leak')\n", encoding="utf-8") + verifier = tmp_path / "scripts" / "ci" / "verify_coverage.py" + verifier.parent.mkdir(parents=True) + verifier.symlink_to(target) + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 100 + + +def test_no_metadata_and_no_verifier_defaults_to_100(tmp_path: Path) -> None: + """Only a workspace with neither metadata nor a verifier inherits 100.""" + manifest = _write_manifest( + tmp_path, + """ +[workspace] +members = ["crates/demo"] +rust-version = "1.97" +""", + ) + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 100 + + +def test_empty_coverage_table_still_uses_threshold_path(tmp_path: Path) -> None: + """An empty opencode.coverage table keeps llvm-cov and defaults to 100.""" + manifest = _write_manifest( + tmp_path, + """ +[workspace.metadata.opencode.coverage] +""", + ) + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 100 + + +def test_package_metadata_uses_threshold(tmp_path: Path) -> None: + """package.metadata.opencode.coverage is a repository-owned baseline.""" + manifest = _write_manifest( + tmp_path, + """ +[package] +name = "demo" +version = "0.1.0" + +[package.metadata.opencode.coverage] +minimum_lines = 70 +""", + ) + plan = policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + assert plan.mode == "llvm-cov-threshold" + assert plan.fail_under == 70 + + +def test_invalid_minimum_lines_fails_closed(tmp_path: Path) -> None: + """A non-numeric coverage baseline is not silently defaulted.""" + manifest = _write_manifest( + tmp_path, + """ +[workspace.metadata.opencode.coverage] +minimum_lines = true +""", + ) + with pytest.raises(ValueError, match="must be a number"): + policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + + +def test_parse_manifest_rejects_non_table(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """A non-table TOML root fails closed.""" + manifest = _write_manifest(tmp_path, "[workspace]\n") + monkeypatch.setattr(policy.tomllib, "loads", lambda _text: []) + with pytest.raises(ValueError, match="root must be a table"): + policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + + +def test_invalid_toml_raises(tmp_path: Path) -> None: + """Malformed Cargo.toml fails closed.""" + manifest = _write_manifest(tmp_path, "[workspace\n") + with pytest.raises(ValueError, match="invalid Cargo.toml"): + policy.coverage_plan(repo_root=tmp_path, manifest=manifest) + + +def test_metadata_lookup_ignores_non_tables() -> None: + """Non-table workspace/metadata/opencode/coverage values are not metadata.""" + assert policy._opencode_coverage_metadata({}) is None + assert policy._opencode_coverage_metadata({"workspace": []}) is None + assert policy._opencode_coverage_metadata({"package": []}) is None + assert policy._opencode_coverage_metadata({"package": {"metadata": []}}) is None + assert policy._opencode_coverage_metadata( + {"package": {"metadata": {"opencode": []}}} + ) is None + assert policy._opencode_coverage_metadata({"workspace": {}}) is None + assert policy._opencode_coverage_metadata({"workspace": {"metadata": []}}) is None + assert policy._opencode_coverage_metadata( + {"workspace": {"metadata": {"opencode": []}}} + ) is None + assert policy._opencode_coverage_metadata( + {"workspace": {"metadata": {"opencode": {}}}} + ) is None + assert policy._opencode_coverage_metadata( + {"workspace": {"metadata": {"opencode": {"coverage": []}}}} + ) is None + assert policy._opencode_coverage_metadata( + {"package": {"metadata": {"opencode": {"coverage": {"minimum_lines": 70}}}}} + ) == {"minimum_lines": 70} + + +def test_rustc_cargo_version_log_includes_optional_rustup() -> None: + """Toolchain identity is formatted for coverage_summary.""" + assert policy.rustc_cargo_version_log(rustc="", cargo="") == ( + "rustc: unavailable\ncargo: unavailable\n" + ) + logged = policy.rustc_cargo_version_log( + rustc="rustc 1.97.1", + cargo="cargo 1.97.1", + rustup_show="1.97.1-x86_64-unknown-linux-gnu", + ) + assert "rustc: rustc 1.97.1" in logged + assert "cargo: cargo 1.97.1" in logged + assert "rustup show: 1.97.1-x86_64-unknown-linux-gnu" in logged + + +def test_plan_fields_and_cli(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + """The workflow CLI emits tab-separated mode, threshold, and verifier.""" + manifest = _write_manifest(tmp_path, "[workspace]\nmembers = []\n") + assert ( + policy.main( + ["--repo-root", str(tmp_path), "--manifest", str(manifest)] + ) + == 0 + ) + assert capsys.readouterr().out == "llvm-cov-threshold\t100\t\n" + assert policy.main(["--repo-root", str(tmp_path), "--manifest", str(tmp_path / "missing.toml")]) == 2 + assert "invalid Rust coverage policy" in capsys.readouterr().err + + +def test_script_entrypoint(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """The executable coverage-policy entrypoint delegates to main.""" + manifest = _write_manifest(tmp_path, "[workspace]\nmembers = []\n") + monkeypatch.setattr( + sys, + "argv", + [ + "rust_coverage_policy.py", + "--repo-root", + str(tmp_path), + "--manifest", + str(manifest), + ], + ) + with pytest.raises(SystemExit, match="0"): + runpy.run_path(str(Path(policy.__file__)), run_name="__main__") diff --git a/tests/test_strix_nvidia_nim_not_found_fallback.py b/tests/test_strix_nvidia_nim_not_found_fallback.py index 17f0e9a30..5f6c1ef0e 100644 --- a/tests/test_strix_nvidia_nim_not_found_fallback.py +++ b/tests/test_strix_nvidia_nim_not_found_fallback.py @@ -73,6 +73,34 @@ def _classifies_as_nvidia_not_found(log_text: str) -> bool: return completed.returncode == 0 +def _child_model_for_api_base(model: str, llm_api_base_value: str) -> str: + """Execute the production model-alias normalizer against one input pair.""" + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + function_source = "\n".join( + _function_block(gate_source, name) + for name in ( + "is_github_models_api_base", + "is_github_models_model", + "child_model_for_api_base", + ) + ) + script = "\n".join( + ( + "set -euo pipefail", + function_source, + 'child_model_for_api_base "$1" "$2"', + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-normalizer", model, llm_api_base_value], + check=True, + capture_output=True, + text=True, + ) + return completed.stdout.strip() + + def _workflow_signal_pattern(workflow: str, variable_name: str) -> str: """Extract one single-quoted POSIX ERE assigned in the Strix workflow.""" @@ -213,6 +241,40 @@ def test_workflow_uses_available_free_first_nvidia_plan(self) -> None: )[0] self.assertNotIn(RETIRED_PRIMARY_MODEL, default_gate) + def test_gate_normalizes_hyphenated_openai_direct_fallback_alias(self) -> None: + """Route the NIM-exhaustion fallback alias to a real LiteLLM provider. + + `STRIX_FALLBACK_MODELS`' NVIDIA NIM entry ends in the hyphenated + `openai-direct/gpt-5.4` alias (the workflow's user-facing input + spelling, also pinned verbatim by protected main's own trusted + `strix_required_workflow_smoke.sh`). The gate must still resolve it + to LiteLLM's `openai/` provider -- the same target the underscored + `openai_direct/` alias already reaches -- or NVIDIA NIM rate-limiting + the primary and first fallback model leaves the run one hop from + `litellm.BadRequestError: LLM Provider NOT provided`. + """ + + self.assertEqual( + _child_model_for_api_base("openai-direct/gpt-5.4", ""), + "openai/gpt-5.4", + ) + self.assertEqual( + _child_model_for_api_base("openai_direct/gpt-5.4", ""), + "openai/gpt-5.4", + ) + + def test_direct_openai_aliases_share_one_reachable_case_arm(self) -> None: + """Keep both supported spellings without a shadowed duplicate arm.""" + + gate = STRIX_GATE.read_text(encoding="utf-8") + normalizer = _function_block(gate, "child_model_for_api_base") + + self.assertEqual( + normalizer.count("openai_direct/* | openai-direct/*)"), + 1, + ) + self.assertNotIn("\n\topenai-direct/*)", normalizer) + def test_outer_workflow_requires_litellm_context_for_nvidia_404(self) -> None: """Reject provider-like target text in the outer neutralization gate.""" diff --git a/tests/test_uv_export_isolation_contract.py b/tests/test_uv_export_isolation_contract.py index 76b72fdc7..615f48bd2 100644 --- a/tests/test_uv_export_isolation_contract.py +++ b/tests/test_uv_export_isolation_contract.py @@ -37,6 +37,7 @@ def fake_run(command: list[str], **kwargs): "--no-progress", "--color", "never", + "--all-extras", "--no-emit-project", "--no-editable", "--format",