diff --git a/scripts/ci/strix_quick_gate.sh b/scripts/ci/strix_quick_gate.sh index 8a8f8b31..c2c72a62 100755 --- a/scripts/ci/strix_quick_gate.sh +++ b/scripts/ci/strix_quick_gate.sh @@ -1178,6 +1178,7 @@ pull_request_scope_context_files() { local needs_frontend_email_api_context=0 local needs_deployment_context=0 local changed_file normalized_changed_file + local -a sql_migration_dirs=() for changed_file in "$@"; do normalized_changed_file="$(normalize_changed_file_path "$changed_file")" || return 2 case "$normalized_changed_file" in @@ -1199,6 +1200,28 @@ pull_request_scope_context_files() { needs_deployment_context=1 ;; esac + # A single SQL migration references schema objects (tables, columns) that its + # sibling migrations create. Diff-scoping one migration in isolation makes the + # scanner report phantom "relation does not exist" / "table does not exist" + # findings — a false positive whose PoC depends only on files excluded from the + # scope. Collect each touched migrations directory so the whole ordered set is + # supplied from the PR head as read-only context. Generic across repositories; + # no project-specific paths are hard-coded. + case "$normalized_changed_file" in + */migrations/*.sql | migrations/*.sql) + local migration_dir="${normalized_changed_file%/*}" + local seen_migration_dir=0 known_migration_dir + for known_migration_dir in "${sql_migration_dirs[@]}"; do + if [ "$known_migration_dir" = "$migration_dir" ]; then + seen_migration_dir=1 + break + fi + done + if [ "$seen_migration_dir" -eq 0 ]; then + sql_migration_dirs+=("$migration_dir") + fi + ;; + esac done if [ "$needs_backend_python" -eq 1 ]; then @@ -1278,6 +1301,25 @@ render.yaml VERSION EOF fi + + # Enumerate sibling SQL migrations from the PR-head tree so cross-migration + # schema references resolve during the scan. Enumeration is read-only and + # fails open: when the head SHA is unavailable or invalid, no context is added + # and the base changed-file scan is unaffected. Changed migrations are copied + # from PR-head blobs; unchanged siblings come from the trusted materialized + # checkout. Both paths are normalized and supplied only as scanner context. + if [ "${#sql_migration_dirs[@]}" -gt 0 ]; then + local head_sha_for_migration_context migration_context_dir + head_sha_for_migration_context="$(trim_whitespace "${PR_HEAD_SHA:-}")" + if [ -n "$head_sha_for_migration_context" ] && + is_valid_git_commit_sha "$head_sha_for_migration_context" && + git rev-parse --verify --quiet "$head_sha_for_migration_context^{commit}" >/dev/null; then + for migration_context_dir in "${sql_migration_dirs[@]}"; do + git -c core.quotepath=false ls-tree -r --name-only "$head_sha_for_migration_context" -- "$migration_context_dir/" 2>/dev/null | + grep -E '\.sql$' || true + done + fi + fi } changed_file_list_contains() { @@ -2750,12 +2792,25 @@ is_vertex_not_found_error() { } github_models_api_base_is_active() { - if [ -z "$LLM_API_BASE_FILE" ]; then + local api_base_file="${LLM_API_BASE_FILE:-}" + local api_base_file_label="LLM_API_BASE_FILE" + # Cross-provider fallback: when the primary scan uses direct-OpenAI, + # LLM_API_BASE_FILE is not set, but github_models/* fallback models + # route through the GitHub Models endpoint supplied by + # STRIX_GITHUB_MODELS_API_BASE_FILE. Recognise either source so that + # github_models_rate_limit_should_skip_same_model_retry correctly skips + # same-model retries for rate-limited cross-provider fallback models. + if [ -z "$api_base_file" ] && [ -n "${STRIX_GITHUB_MODELS_API_BASE_FILE:-}" ]; then + api_base_file="$STRIX_GITHUB_MODELS_API_BASE_FILE" + api_base_file_label="STRIX_GITHUB_MODELS_API_BASE_FILE" + fi + + if [ -z "$api_base_file" ]; then return 1 fi local resolved_llm_api_base_file - if ! resolved_llm_api_base_file="$(resolve_trusted_input_file "LLM_API_BASE_FILE" "$LLM_API_BASE_FILE" 2>/dev/null)"; then + if ! resolved_llm_api_base_file="$(resolve_trusted_input_file "$api_base_file_label" "$api_base_file" 2>/dev/null)"; then return 1 fi diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index aae63c5d..ba6a3d40 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -164,6 +164,53 @@ assert_strix_pr_scope_includes_deployment_context() { assert_file_contains "$GATE_SCRIPT" "scripts/ci/test_*.sh" "strix gate excludes large CI self-test harnesses from PR scan targets" } +assert_strix_pr_scope_includes_sql_migration_context() { + assert_file_contains "$GATE_SCRIPT" "*/migrations/*.sql | migrations/*.sql" "strix gate recognizes SQL migration files for sibling context" + assert_file_contains "$GATE_SCRIPT" "sql_migration_dirs+=(\"\$migration_dir\")" "strix gate collects each touched migrations directory" + assert_file_contains "$GATE_SCRIPT" "git -c core.quotepath=false ls-tree -r --name-only \"\$head_sha_for_migration_context\" -- \"\$migration_context_dir/\"" "strix gate enumerates sibling migrations from the PR head without quoting non-ASCII paths" + assert_file_contains "$GATE_SCRIPT" "fails open" "strix gate migration context enumeration is documented as fail-open" +} + +assert_strix_pr_scope_migration_siblings_functional() { + # A migration-only diff must resolve schema references from sibling migrations, + # not report a phantom "relation does not exist" finding. Enumerate the context + # a three-migration directory produces when the second and third files change. + local tmp_dir + tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/strix-migration-context.XXXXXX")" + ( + cd "$tmp_dir" + git init -q + git config user.email ci@example.com + git config user.name ci + mkdir -p "server/db with space/migrations" + printf 'CREATE TABLE t (id int);\n' >"server/db with space/migrations/0001_기초.sql" + printf 'ALTER TABLE t ADD COLUMN c text;\n' >"server/db with space/migrations/0002_add_col.sql" + printf 'ALTER TABLE t ADD COLUMN d text;\n' >"server/db with space/migrations/0003_add_second_col.sql" + git add -A + git commit -qm base + head_sha="$(git rev-parse HEAD)" + + # shellcheck source=/dev/null + REPO_ROOT="$tmp_dir" PR_HEAD_SHA="$head_sha" \ + bash -c ' + set -euo pipefail + REPO_ROOT="'"$tmp_dir"'" + trim_whitespace() { printf "%s" "$1"; } + is_valid_git_commit_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; } + normalize_changed_file_path() { printf "%s" "$1"; } + '"$(sed -n "/^pull_request_scope_context_files()/,/^}/p" "$GATE_SCRIPT")"' + pull_request_scope_context_files \ + "server/db with space/migrations/0002_add_col.sql" \ + "server/db with space/migrations/0003_add_second_col.sql" + ' >"$tmp_dir/out.txt" + ) + assert_file_contains "$tmp_dir/out.txt" "server/db with space/migrations/0001_기초.sql" "strix gate preserves spaces and non-ASCII sibling migration paths" + local sibling_count + sibling_count="$(grep -Fc "server/db with space/migrations/0001_기초.sql" "$tmp_dir/out.txt" || true)" + assert_equals "1" "$sibling_count" "strix gate deduplicates a migration directory containing spaces" + rm -rf "$tmp_dir" +} + assert_strix_workflow_pr_trigger_hardened() { local workflow_file="$REPO_ROOT/.github/workflows/strix.yml" @@ -3276,6 +3323,31 @@ REPORT ;; esac ;; + openai-direct-github-models-fallback-ratelimit-skip) + # Primary openai_direct fails; first github_models fallback hits GitHub + # Models rate limit. The gate must detect the GitHub Models API base via + # STRIX_GITHUB_MODELS_API_BASE_FILE and skip same-model retries of the + # rate-limited fallback, moving directly to the second fallback. + case "${STRIX_LLM:-}" in + openai/gpt-5.6-luna) + echo "Error getting response: Error code: 429 - {'error': {'message': 'You exceeded your current quota, please check your plan and billing details.', 'type': 'insufficient_quota', 'code': 'insufficient_quota'}}" + echo "openai.RateLimitError: Error code: 429" + exit 1 + ;; + openai/gpt-5-chat) + echo "RateLimitError: Too many requests. For more on scraping GitHub and how it may affect your rights, please review our Terms of Service (https://docs.github.com/en/site-policy/github-terms/github-terms-of-service)." + exit 1 + ;; + openai/o3) + echo "scan ok with second GitHub Models fallback" + exit 0 + ;; + *) + echo "unexpected model ${STRIX_LLM:-}" >&2 + exit 9 + ;; + esac + ;; vertex-all-notfound) echo "Error: litellm.NotFoundError: Vertex_aiException - x" echo '"status": "NOT_FOUND"' @@ -5386,7 +5458,8 @@ PY FAKE_STRIX_OUTSIDE_REPORT_DIR="$repo_root_dir/outside-strix-report" ) fi - if [ "$scenario" = "openai-direct-quota-github-models-fallback-success" ]; then + if [ "$scenario" = "openai-direct-quota-github-models-fallback-success" ] || + [ "$scenario" = "openai-direct-github-models-fallback-ratelimit-skip" ]; then printf '%s' 'https://models.github.ai/inference' >"$tmp_dir/github_models_api_base.txt" printf '%s' 'github-models-fallback-token' >"$tmp_dir/github_models_key.txt" env_cmd+=(STRIX_GITHUB_MODELS_API_BASE_FILE="$tmp_dir/github_models_api_base.txt") @@ -5582,6 +5655,17 @@ PY "scenario=$scenario does not sleep in same-model retry after GitHub Models rate limiting" fi + if [ "$scenario" = "openai-direct-github-models-fallback-ratelimit-skip" ]; then + assert_file_contains \ + "$output_log" \ + "GitHub Models rate limit detected for model 'github_models/openai/gpt-5-chat'; skipping same-model retry and moving directly to fallback models or current-head neutral classification." \ + "scenario=$scenario logs why same-model retry was skipped for cross-provider fallback" + assert_file_not_contains \ + "$output_log" \ + "Retrying model 'github_models/openai/gpt-5-chat' due to rate limit" \ + "scenario=$scenario does not retry rate-limited cross-provider fallback model" + fi + if [ "$scenario" = "pr-changed-scope-full-set" ]; then assert_internal_pr_scope_targets "$target_log" "$repo_root_dir" "$expected_calls" fi @@ -5788,6 +5872,36 @@ run_filtered_gate_case_if_requested() { "" \ "github_models/openai/o3" ;; + openai-direct-github-models-fallback-ratelimit-skip) + run_gate_case "openai-direct-github-models-fallback-ratelimit-skip" \ + "openai_direct/gpt-5.6-luna" \ + "" \ + "0" \ + "REGEX:Strix quick scan succeeded with fallback model 'github_models/openai/o3' in [0-9]+s\\." \ + "5" \ + "openai/gpt-5.6-luna|openai/gpt-5.6-luna|openai/gpt-5.6-luna|openai/gpt-5-chat|openai/o3" \ + "|||https://models.github.ai/inference|https://models.github.ai/inference" \ + "vertex_ai" \ + "" \ + "" \ + "2" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "github_models/openai/gpt-5-chat github_models/openai/o3" + ;; gemini-timeout-fallback-success) run_gate_case_allow_provider_signal "gemini-timeout-fallback-success" \ "gemini/timeout-fallback-primary" \ @@ -8546,6 +8660,10 @@ assert_strix_workflow_pr_trigger_hardened assert_strix_pr_scope_includes_deployment_context +assert_strix_pr_scope_includes_sql_migration_context + +assert_strix_pr_scope_migration_siblings_functional + assert_strix_gpt54_model_guard_cases assert_strix_gate_target_scope_separated @@ -11666,6 +11784,41 @@ run_gate_case "openai-direct-quota-github-models-fallback-success" \ "" \ "github_models/openai/o3" +# Direct-OpenAI primary hits a quota/rate-limit error (retries 3x, as +# openai_direct is not subject to the GitHub Models skip), then the first +# github_models fallback immediately hits a GitHub Models rate limit. +# The gate must detect GitHub Models is active via STRIX_GITHUB_MODELS_API_BASE_FILE +# (not LLM_API_BASE_FILE, which is unset for openai_direct) and skip same-model retries +# of the rate-limited fallback, moving directly to the second fallback (5 calls total). +run_gate_case "openai-direct-github-models-fallback-ratelimit-skip" \ + "openai_direct/gpt-5.6-luna" \ + "" \ + "0" \ + "REGEX:Strix quick scan succeeded with fallback model 'github_models/openai/o3' in [0-9]+s\\." \ + "5" \ + "openai/gpt-5.6-luna|openai/gpt-5.6-luna|openai/gpt-5.6-luna|openai/gpt-5-chat|openai/o3" \ + "|||https://models.github.ai/inference|https://models.github.ai/inference" \ + "vertex_ai" \ + "" \ + "" \ + "2" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "" \ + "github_models/openai/gpt-5-chat github_models/openai/o3" + run_gate_case "github-models-fallback-success-deepseek-v3" \ "vertex_ai/missing-primary" \ "github_models/deepseek/deepseek-r1-0528 github_models/deepseek/deepseek-v3-0324" \