diff --git a/.github/scripts/run-autofix-review-verification.sh b/.github/scripts/run-autofix-review-verification.sh index 57d16a065b4..fb22f7b04ad 100755 --- a/.github/scripts/run-autofix-review-verification.sh +++ b/.github/scripts/run-autofix-review-verification.sh @@ -83,6 +83,7 @@ git checkout "${BRANCH}" GATE_LOG="${WORKDIR}/gate-output.log" : > "${GATE_LOG}" +rm -f "${GATE_LOG}.bite" reject_fix() { local label="${1}" local preexisting="${2:-false}" @@ -353,6 +354,7 @@ if git diff --quiet "origin/${BRANCH}...${BRANCH}"; then if [[ -s "${WORKDIR}/no-action.md" ]]; then echo "🟰 No action needed:" cat "${WORKDIR}/no-action.md" + echo "verified_head=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" echo "outcome=noop" >> "${GITHUB_OUTPUT}" exit 0 fi @@ -367,6 +369,287 @@ if [[ ! -s "${WORKDIR}/address-summary.md" ]]; then exit 1 fi +# --- Content-based validity checks ------------------------------------------- +# Feedback validity is judged by CONTENT, never by AUTHOR: a maintainer's +# comment, the review bot's finding, and a model-drafted suggestion pasted by +# a human all drive the agent the same way, so the gate checks what the round +# DID, not who asked for it. Two deterministic checks below (sensitive-area +# footprint here, the bite check after the package tests) plus one advisory +# (test deletion). All three read only git state and run before/around the +# existing deterministic re-checks. + +# Sensitive-area footprint: a review round must not EXPAND into CI or +# verification machinery the PR itself was never about — a single review +# comment (any author) must not be able to alter the loop's own guardrails. +# Judged by AREA CLASS, not file: a PR whose own pre-round diff already +# touches a class (an infra PR under takeover) keeps full freedom there; +# a round reaching into a class the PR never touched is rejected. Retryable: +# the repair pass can revert the offending files in a follow-up commit. +# `scripts` sections of workspace manifests are their own class because the +# gate's every command resolves through them (`npm run build/typecheck/ +# lint/test`) — a scripts edit can hollow out the gate while every check +# "passes". Only the root manifest and DECLARED workspace manifests count +# (resolver-backed, nested workspaces included): fixture manifests deeper +# in a src tree are ordinary test data. +was_workspace_dir() { + # Pre-round workspace membership without the on-disk resolver: match the + # dir against the workspaces globs recorded in the REF's root manifest. + # Used where the tree can no longer answer (deleted manifests/dirs). + # PATH-AWARE matching: npm workspaces globs are wildmatch-style, where + # '*' stops at '/'; a bash case '*' would span slashes and swallow + # nested fixture dirs. Translate to an anchored regex ('**'→.*, + # '*'→[^/]*, '?'→[^/]). Negated ('!') entries are skipped — ignoring a + # subtraction only ever classifies MORE dirs as workspaces, the + # conservative direction for a protection class. + local ref="${1}" d="${2}" g re + while IFS= read -r g; do + [[ -n "${g}" && "${g}" != '!'* ]] || continue + re="$(printf '%s' "${g}" | sed -e 's/[.^$+(){}|[]/\\&/g' -e 's/]/\\]/g' -e 's/\*\*/\x01/g' -e 's/\*/[^\/]*/g' -e 's/?/[^\/]/g' -e 's/\x01/.*/g')" + [[ "${d}" =~ ^${re}$ ]] && return 0 + done < <(git show "${ref}:package.json" 2> /dev/null | jq -r '.workspaces[]?' 2> /dev/null) + return 1 +} +at_workspace_root() { + # True when the path sits at the repo root or at a DECLARED workspace's + # root (resolved through the same trusted resolver the package-test loop + # uses — nested workspaces like packages/channels/* included). Deeper + # copies are fixtures/templates: ordinary data, not machinery. + local f="${1}" d + [[ "${f}" == */* ]] || return 0 + d="${f%/*}" + [[ "$(printf '%s\n' "${f}" | bash "${RUNNER_TEMP}/resolve-owning-packages.sh")" == "${d}" ]] +} +sensitive_class_of() { + # Prints the class name for a path, or nothing. Kept as one function so + # the round scan and the PR-footprint scan cannot drift. Classes are + # NARROW on purpose: a PR that only edits issue templates must not + # thereby license rounds to rewrite workflows, and the loop's OWN + # enforcement files are their own classes — no footprint short of + # touching them themselves licenses a round to rewrite the referee. + # scripts/tests/** is ordinary test code the gate never executes. + local f="${1}" + case "${f}" in + *$'\n'*) + # A newline-bearing path cannot round-trip the line-based resolver or + # the class ledger — fail CLOSED as its own class instead of open. + echo 'suspicious-path' ;; + .github/workflows/qwen-autofix*.yml | .github/workflows/qwen-triage*.yml | .github/workflows/qwen-pr-safety-precheck.yml) echo 'autofix-loop' ;; + .github/scripts/run-autofix-review-verification.sh | .github/scripts/resolve-owning-packages.sh | .github/scripts/check-settings-schema.sh | .github/scripts/check-autofix-contracts.sh | .github/scripts/resolve-sandbox-image.mjs | .github/scripts/pr-safety-precheck.mjs) echo 'autofix-loop' ;; + .github/workflows/* | .github/actions/*) echo 'ci-workflows' ;; + .github/scripts/*) echo 'ci-scripts' ;; + .github/*) echo 'gh-metadata' ;; + .husky/*) echo 'git-hooks' ;; + .qwen/*) echo 'agent-skills' ;; + AGENTS.md | CLAUDE.md) echo 'agent-policy' ;; + scripts/tests/*) ;; + scripts/*) echo 'repo-scripts' ;; + .npmrc | .nvmrc | */.npmrc | */.nvmrc) echo 'toolchain-config' ;; + package-lock.json | npm-shrinkwrap.json | */package-lock.json | */npm-shrinkwrap.json | patches/*) echo 'supply-chain' ;; + .gitattributes | */.gitattributes) echo 'measurement-config' ;; + *) case "${f##*/}" in + eslint.config.* | eslint.legacy-filenames.mjs | vitest.config.* | tsconfig.json | tsconfig.*.json) + # Workspace-root configs are machinery; a scaffold template deep in + # a src tree is test/fixture data (same exemption manifests get). + if at_workspace_root "${f}"; then + case "${f##*/}" in + eslint.config.* | eslint.legacy-filenames.mjs) echo 'lint-config' ;; + vitest.config.*) echo 'test-config' ;; + *) echo 'ts-config' ;; + esac + fi ;; + esac ;; + esac +} +manifest_scripts_changed() { + # True when the gate-relevant sections of a manifest differ between two + # refs. For the ROOT manifest that is scripts AND the workspaces array — + # both steer what the gate's npm commands execute (a negated workspaces + # entry silently drops a package from build/typecheck). Missing file on + # either side reads as {}. + local f="${1}" from="${2}" to="${3}" filt a b + filt='{s: (.scripts // {}), e: (.exports // {}), m: (.main // ""), t: (.types // "")}' + [[ "${f}" == 'package.json' ]] && filt='{s: (.scripts // {}), w: (.workspaces // []), e: (.exports // {}), m: (.main // ""), t: (.types // ""), l: (."lint-staged" // {}), c: (.config // {})}' + a="$(git show "${from}:${f}" 2> /dev/null | jq -cS "${filt}" 2> /dev/null)" || a='{}' + b="$(git show "${to}:${f}" 2> /dev/null | jq -cS "${filt}" 2> /dev/null)" || b='{}' + [[ "${a}" != "${b}" ]] +} +ROUND_RANGE="origin/${BRANCH}...${BRANCH}" +PR_RANGE="origin/main...origin/${BRANCH}" +# Content comparisons for the PR footprint anchor at the MERGE BASE, not a +# moving origin/main: main-side drift on a manifest must not read as "the +# PR touched scripts" and license a round to rewrite the command surface. +PR_BASE="$(git merge-base origin/main "origin/${BRANCH}" 2> /dev/null)" || PR_BASE='origin/main' +ROUND_CLASSES='' +while IFS= read -r -d '' f; do + [[ -n "${f}" ]] || continue + # A round that merges origin/main makes ROUND_RANGE degenerate (the + # pre-round head is an ancestor), attributing every incoming main-side + # change to the round. Content identical to current main is merge + # freight, not the round's authorship — skip it. + if git diff --quiet origin/main "${BRANCH}" -- "${f}" 2> /dev/null; then + continue + fi + c="$(sensitive_class_of "${f}")" + case "${c}" in + lint-config | test-config | ts-config) + # Only a config born WITH its round-added workspace is the round's + # own surface: added into a pre-existing workspace, it is new + # machinery the gate's legs will execute. + if ! git cat-file -e "origin/${BRANCH}:${f}" 2> /dev/null; then + d="${f%/*}"; [[ "${f}" != */* ]] && d='.' + if [[ "${d}" == '.' ]] || git cat-file -e "origin/${BRANCH}:${d}/package.json" 2> /dev/null; then + : # pre-existing home → keep the class + else + c='' + fi + fi ;; + esac + if [[ -z "${c}" ]]; then + case "${f}" in + package.json | */package.json) + # DELETED workspace manifests never resolve on the round's tree — + # classify them from pre-round existence instead (deleting a + # workspace removes command surface the gate dispatched over). + if [[ ! -e "${f}" ]]; then + # Same fixture exemption as the alive arm, answered from the + # PRE-ROUND root manifest's workspaces globs (the on-disk + # resolver can no longer see a deleted dir): only a deleted + # DECLARED workspace manifest is command surface. + if git cat-file -e "origin/${BRANCH}:${f}" 2> /dev/null; then + if [[ "${f}" == 'package.json' ]]; then + c='manifest-scripts-root' + elif was_workspace_dir "origin/${BRANCH}" "${f%/package.json}"; then + c='manifest-scripts-ws' + fi + fi + [[ -n "${c}" ]] && ROUND_CLASSES+="${c} ${f}"$'\n' + continue + fi + # Any DECLARED workspace manifest (nested included) is command + # surface; fixture manifests deeper in a src tree are data. A + # manifest the round ADDED (a new workspace) is the round's own + # new surface, not a rewrite of commands the gate already ran — + # only edits to a manifest that existed pre-round count. Root and + # workspace manifests are SEPARATE classes: a workspace-scripts + # footprint must not license rewriting the root dispatcher. + at_workspace_root "${f}" || continue + git cat-file -e "origin/${BRANCH}:${f}" 2> /dev/null || continue + if manifest_scripts_changed "${f}" "origin/${BRANCH}" "${BRANCH}"; then + c='manifest-scripts-ws' + [[ "${f}" == 'package.json' ]] && c='manifest-scripts-root' + fi ;; + esac + fi + [[ -n "${c}" ]] && ROUND_CLASSES+="${c} ${f}"$'\n' +# -z --no-renames: NUL-delimited raw paths (a specially named file is not +# core.quotePath-mangled past the case patterns), and a rename decomposes +# into A+D so the VACATED sensitive path is classified too — moving a +# workflow out of .github/ is a removal of verification machinery. +done < <(git diff --name-only -z --no-renames "${ROUND_RANGE}") +if [[ -n "${ROUND_CLASSES}" ]]; then + PR_CLASSES='' + while IFS= read -r -d '' f; do + [[ -n "${f}" ]] || continue + c="$(sensitive_class_of "${f}")" + if [[ -z "${c}" ]]; then + case "${f}" in + package.json | */package.json) + # The footprint describes the PR (main → origin/BRANCH); the + # round's on-disk tree must not answer for it — a round-deleted, + # PR-added workspace manifest is alive at origin/BRANCH and its + # class must stay granted, or the round's own deletion walls. + if ! git cat-file -e "origin/${BRANCH}:${f}" 2> /dev/null; then + # Deleted BY THE PR itself: membership from the merge base. + if [[ "${f}" == 'package.json' ]]; then + c='manifest-scripts-root' + elif was_workspace_dir "${PR_BASE}" "${f%/package.json}"; then + c='manifest-scripts-ws' + fi + [[ -n "${c}" ]] && PR_CLASSES+="${c}"$'\n' + continue + fi + if [[ -e "${f}" ]]; then + at_workspace_root "${f}" || continue + else + was_workspace_dir "origin/${BRANCH}" "${f%/package.json}" || [[ "${f}" == 'package.json' ]] || continue + fi + if manifest_scripts_changed "${f}" "${PR_BASE}" "origin/${BRANCH}"; then + c='manifest-scripts-ws' + [[ "${f}" == 'package.json' ]] && c='manifest-scripts-root' + fi ;; + esac + fi + [[ -n "${c}" ]] && PR_CLASSES+="${c}"$'\n' + done < <(git diff --name-only -z --no-renames "${PR_RANGE}") + VIOLATIONS="$(while IFS= read -r line; do + [[ -n "${line}" ]] || continue + cls="${line%% *}" + grep -qx "${cls}" <<< "${PR_CLASSES}" || printf '%s\n' "${line}" + done <<< "${ROUND_CLASSES}")" + if [[ -n "${VIOLATIONS}" ]]; then + { + echo 'This round modified CI/verification machinery in area(s) the PR itself never touched:' + # Branch-controlled paths in a trusted-voice document: same safe + # charset as the advisory renderer. + printf '%s\n' "${VIOLATIONS//[^A-Za-z0-9._\/ -]/?}" + echo 'Review feedback alone — from ANY author — cannot authorize changes to the loop'"'"'s own guardrails. Revert these files; if the feedback genuinely requires them, escalate it to a maintainer as an open question instead of implementing it.' + } >> "${GATE_LOG}" + reject_fix 'round expands into CI/verification machinery outside the PR footprint' + fi +fi + +# Merge freight (content identical to current main) is not the round's +# authorship — the same doctrine the class scan applies. Filter it out of +# every bite input so a base-merging round is judged on its own changes. +not_merge_freight() { + while IFS= read -r -d '' f; do + git diff --quiet origin/main "${BRANCH}" -- "${f}" 2> /dev/null || printf '%s\0' "${f}" + done +} +# Test-deletion advisory: deleting or shrinking tests is sometimes right +# (the pinned behavior was wrong, or coverage is duplicated) and the agent +# is required to justify it in its summary — but the SURFACING must not be +# the agent's own prose. The gate writes its own advisory into the round +# report so a maintainer always sees exactly which tests disappeared, +# whoever suggested it. +TEST_PATHSPEC=(':(glob)**/*.test.*' ':(glob)**/*.spec.*' ':(glob)**/__snapshots__/**' ':(glob)**/__tests__/**' ':(glob)**/test-utils/**' ':(glob)integration-tests/**') +DELETED_TESTS="$(git diff --name-only -z --no-renames --diff-filter=D "${ROUND_RANGE}" -- "${TEST_PATHSPEC[@]}" | + not_merge_freight | tr '\0' '\n')" +# Per-file sum with the merge-freight skip the class scan applies: a +# base-merging round must not be charged (or credited) main-side test +# churn in trusted-voice advisory text. -z numstat records are +# adddelpath NUL-terminated (renames are disabled above). +NET_TEST_LINES="$(git diff --numstat -z --no-renames "${ROUND_RANGE}" -- "${TEST_PATHSPEC[@]}" | + { total=0 + while IFS=$'\t' read -r -d '' add del path; do + [[ -n "${path}" ]] || continue + git diff --quiet origin/main "${BRANCH}" -- "${path}" 2> /dev/null && continue + [[ "${add}" != '-' ]] && total=$(( total + add )) + [[ "${del}" != '-' ]] && total=$(( total - del )) + done + echo "${total}"; })" +rm -f "${WORKDIR}/gate-advisories.md" +if [[ -n "${DELETED_TESTS}" || "${NET_TEST_LINES}" -le -25 ]]; then + { + echo '⚖️ **Gate advisory — test coverage shrank this round** (machine-measured, not agent-authored): '"net ${NET_TEST_LINES} test lines." + if [[ -n "${DELETED_TESTS}" ]]; then + echo + echo 'Deleted test files:' + # Filenames are branch-controlled bytes rendered inside a gate-authored + # (trusted-voice) document: a backtick in a legal git filename would + # close the code span and let the name forge "machine-measured" text. + # Render through a conservative safe-character set; anything else + # (backticks, newlines, control bytes) becomes '?'. + while IFS= read -r f; do + [[ -n "${f}" ]] && echo "- \`${f//[^A-Za-z0-9._\/ -]/?}\`" + done <<< "${DELETED_TESTS}" + fi + echo + echo 'The justification must be in the round summary above; a deletion is only sound when the pinned behavior itself was wrong (evidence shown) or the coverage demonstrably survives elsewhere. · 本轮测试覆盖净减少(门自动测量,非 agent 文本);删除是否成立请对照上方轮次摘要中的理由——仅当被钉住的行为本身有误(需给出证据)或覆盖确有替代时才合理。' + } > "${WORKDIR}/gate-advisories.md" + echo '⚖️ test coverage shrank this round — advisory written for the report' | tee -a "${GATE_LOG}" +fi + echo '🔬 Re-running deterministic checks (independent of the agent)...' run_check 'build failed on the agent-committed fix' npm run build # Typecheck consumes core's dist (sdk-typescript resolves @@ -418,6 +701,263 @@ else npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests done fi + +# Bite check: run this round's changed tests against the PRE-ROUND tree +# (origin/ sources + the round's test files). If EVERY changed test +# also passes there, the tests demonstrate nothing — the classic shape of a +# plausible-but-false finding implemented as a "fix" whose regression test +# was green all along. +# +# INTENT decides the consequence, and intent is read from the round's own +# machine-readable artifacts, not inferred from the diff shape: a round is +# a DEFECT-CLAIM round only when resolved-comments.txt marks a finding +# resolved-in-code whose thread is Critical-tagged or belongs to a +# CHANGES_REQUESTED review (matched in rc.json/rv.json). Those rounds get a +# non-retryable rejection on all-green — the 18-minute repair pass cannot +# make a nonexistent defect reproduce; the next full round re-reads the +# feedback with the evidence in LAST_REJECTION and can decline or escalate +# instead. Every OTHER src+test round (a refactor pinning existing +# behavior, an optional cleanup adding coverage) legitimately produces +# all-green pre-round tests, so all-green there is a gate-authored ADVISORY +# in the report, never a rejection. +# Scope guards (all fail OPEN — only the clean "ran and all passed" verdict +# has consequences): +# - Runnable unit tests only: *.test.* / *.spec.* files. Snapshots and +# integration-tests/ are not directly runnable here. +# - Single-package rounds only: on the detached pre-round tree, gitignored +# dist/ still carries the ROUND's build, so a cross-package fix leaks +# into the baseline through dist-resolved imports and would read as +# "no bite" — the same dist confound that A/B-exempts typecheck above. +# Same-package imports resolve through vitest src aliases and relative +# paths, which the detach does revert. +# - A test that fails on the pre-round tree for ANY reason (assertion, +# collection, import of a round-added symbol) counts as biting; the +# check's power is the all-green case, which no honest defect fix +# produces. KNOWN LIMIT, deliberate: the verdict is existential over +# the batch, so in a mixed Critical round one genuinely biting test +# vouches for the batch — binding each behavior to its own probe needs +# per-test result parsing and is out of scope here. Also known: a +# re-raised finding whose fix already sits in origin/ is +# legitimately all-green (SKILL directs re-verified items into +# resolved-comments.txt); the rejection text tells the agent to +# resolve such items in a no-code round of their own. +BITE_RUNNER="${BITE_RUNNER:-bite_runner_default}" +bite_runner_default() { + # $1 = workspace dir, rest = test paths relative to the workspace. + local ws="${1}" + shift + npm run test --workspace "${ws}" --if-present -- "$@" +} +mapfile -d '' -t BITE_FILES < <(git diff --name-only -z --no-renames --diff-filter=AM "${ROUND_RANGE}" \ + -- ':(glob)**/*.test.*' ':(glob)**/*.spec.*' ':(exclude,glob)**/__snapshots__/**' \ + ':(exclude,glob)integration-tests/**' | not_merge_freight || true) +# Changed snapshots ride the overlay (a fix proven by a regenerated +# snapshot must not revert to the pre-round snapshot and read as green) +# but are never passed to the runner as test-file arguments. +mapfile -d '' -t BITE_SNAPS < <(git diff --name-only -z --no-renames --diff-filter=AM "${ROUND_RANGE}" \ + -- ':(glob)**/__snapshots__/**' | not_merge_freight || true) +# No blanket *.md exclusion: .qwen/skills/**/*.md is EXECUTABLE agent +# behavior (and scripts/tests pins it), so markdown counts as source; the +# consequence gating above keeps doc-only rounds from ever being rejected. +BITE_SRC="$(git diff --name-only -z --no-renames "${ROUND_RANGE}" \ + -- ':(exclude,glob)**/*.test.*' ':(exclude,glob)**/*.spec.*' \ + ':(exclude,glob)**/__snapshots__/**' ':(exclude,glob)**/__tests__/**' \ + ':(exclude,glob)**/test-utils/**' ':(exclude,glob)integration-tests/**' | + not_merge_freight | tr '\0' '\n')" +# Does this round RESOLVE a Critical-tagged or CHANGES_REQUESTED finding in +# code? resolved-comments.txt is the agent's own machine-readable claim of +# what it fixed; rc.json/rv.json carry the thread bodies and review states +# the scan already fetched. Absent/empty inputs read as "no defect claim". +BITE_ENFORCE='false' +if [[ -s "${WORKDIR}/resolved-comments.txt" && -s "${WORKDIR}/rc.json" ]]; then + # Ids tolerate the rc: prefix and CR the other consumers strip (SKILL + # tells the agent to write the rc: handle); a reply resolved inside a + # Critical-rooted thread is a defect claim too, matching how the feedback + # renderers classify replies. + BITE_ENFORCE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ + --slurpfile reviews "${WORKDIR}/rv.json" ' + (add // []) as $comments + | ($reviews | add // []) as $reviews + | ($ids | split("\n") + | map(sub("^rc:"; "") | sub("\r$"; "") + | select(test("^[0-9]+$")) | tonumber)) as $resolved + | def critical($c): + (($c.body // "") | contains("**[Critical]**")) + or (($c.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; + .id == $root and ((.body // "") | contains("**[Critical]**")))) + or (($c.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); + any($comments[]; (.id as $id | $resolved | index($id) != null) and critical(.))' \ + "${WORKDIR}/rc.json" 2> /dev/null)" || BITE_ENFORCE='false' + [[ "${BITE_ENFORCE}" == 'true' ]] || BITE_ENFORCE='false' + # A defect claim whose EVERY resolved-Critical thread sits on a test file + # is a test-side claim ("this test asserts the wrong behavior"): its fixed + # test legitimately passes on the pre-round tree, so it takes the advisory + # arm, never the rejection. + if [[ "${BITE_ENFORCE}" == 'true' ]]; then + TESTSIDE="$(jq -rs --rawfile ids "${WORKDIR}/resolved-comments.txt" \ + --slurpfile reviews "${WORKDIR}/rv.json" ' + (add // []) as $comments + | ($reviews | add // []) as $reviews + | ($ids | split("\n") + | map(sub("^rc:"; "") | sub("\r$"; "") + | select(test("^[0-9]+$")) | tonumber)) as $resolved + | def critical($c): + (($c.body // "") | contains("**[Critical]**")) + or (($c.in_reply_to_id // null) as $root + | $root != null + and any($comments[]; + .id == $root and ((.body // "") | contains("**[Critical]**")))) + or (($c.pull_request_review_id // null) as $review + | $review != null + and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED"))); + [ $comments[] + | select(.id as $id | $resolved | index($id) != null) + | select(critical(.)) | (.path // "") ] + | (length > 0) and all(.[]; + test("\\.(test|spec)\\.") or test("__tests__/|__snapshots__/|test-utils/|^integration-tests/"))' \ + "${WORKDIR}/rc.json" 2> /dev/null)" || TESTSIDE='false' + [[ "${TESTSIDE}" == 'true' ]] && BITE_ENFORCE='advisory' + fi +fi +if [[ -z "${BITE_SRC}" && ( "${BITE_ENFORCE}" == 'true' || "${BITE_ENFORCE}" == 'advisory' ) ]]; then + # A defect-claim round that changed only tests cannot be bite-checked + # (a fixed test legitimately passes on the pre-round tree) — surface + # that the claim went unverified rather than skipping silently. + { + echo '🦷 **Gate advisory — this round resolves a Critical/Request-changes finding with test-only changes** (machine-measured): the bite check cannot verify a test-side fix, so the resolution rests on the round summary alone. · 本轮以纯测试改动解决 Critical/Request-changes 反馈(门自动测量):bite 检查无法验证测试侧修复,该解决仅以轮次摘要为凭。' + } >> "${WORKDIR}/gate-advisories.md" + echo "🦷 defect-claim round changed only tests — advisory written (bite not applicable)" \ + | tee -a "${GATE_LOG}" +fi +if [[ "${#BITE_FILES[@]}" -gt 0 && -n "${BITE_SRC}" ]]; then + BITE_PKGS="$(printf '%s\n' "${BITE_FILES[@]}" "${BITE_SRC}" | + bash "${RUNNER_TEMP}/resolve-owning-packages.sh")" + # The resolver silently drops files owned by NO workspace (repo-level + # scripts, root configs): the single-workspace verdict below would then + # judge only the workspace subset. Detect strays directly — every input + # path must live under the one resolved workspace. + BITE_STRAY='false' + while IFS= read -r f; do + [[ -z "${f}" ]] && continue + [[ "${f}" == "${BITE_PKGS}"/* ]] || BITE_STRAY='true' + done < <(printf '%s\n' "${BITE_FILES[@]}" "${BITE_SRC}") + # Read the test script from the PRE-ROUND tree: that is the manifest the + # detached runner will actually execute (the round tree's copy can + # differ on infra PRs). + BITE_TEST_SCRIPT="$(git show "origin/${BRANCH}:${BITE_PKGS}/package.json" 2> /dev/null | + node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(JSON.parse(d).scripts?.test||"")}catch{}})' 2> /dev/null)" || BITE_TEST_SCRIPT='' + BITE_SELF_IMPORT='false' + if [[ -n "${BITE_PKGS}" && -f "${BITE_PKGS}/package.json" ]]; then + BITE_PKG_NAME="$(node -e 'const fs=require("node:fs");process.stdout.write(JSON.parse(fs.readFileSync(process.argv[1],"utf8")).name||"")' "${BITE_PKGS}/package.json" 2> /dev/null)" || BITE_PKG_NAME='' + if [[ -n "${BITE_PKG_NAME}" ]] && + git grep -qE "[\"']${BITE_PKG_NAME}[\"'/]" "${BRANCH}" -- "${BITE_FILES[@]}" 2> /dev/null; then + # A test importing its own package BY NAME resolves through the + # package exports into round-built dist/ on the detached tree — the + # fix leaks into the "pre-round" run (packages/core has no self-alias + # in its vitest config). Fail open. + BITE_SELF_IMPORT='true' + fi + fi + if [[ "$(wc -l <<< "${BITE_PKGS}")" -ne 1 || -z "${BITE_PKGS}" || "${BITE_STRAY}" == 'true' ]]; then + echo "🦷 bite check skipped: round spans multiple/no workspaces (dist confound)" \ + | tee -a "${GATE_LOG}" + elif [[ "${BITE_TEST_SCRIPT}" != *vitest* ]]; then + # Mirrors the deterministic package-test loop's guard: a workspace + # without a vitest test script would run NOTHING under --if-present + # (or a non-vitest runner whose exit reflects environment health), and + # a vacuous "all passed" must never reject a round. + echo "🦷 bite check skipped: ${BITE_PKGS} test script is not Vitest" \ + | tee -a "${GATE_LOG}" + elif [[ "${BITE_SELF_IMPORT}" == 'true' ]]; then + echo "🦷 bite check skipped: changed tests import ${BITE_PKG_NAME} by package name (dist confound)" \ + | tee -a "${GATE_LOG}" + else + echo "🦷 bite check: running this round's changed tests on the pre-round tree" \ + | tee -a "${GATE_LOG}" + git restore -- . 2>> "${GATE_LOG}" || true + if git checkout --quiet --detach "origin/${BRANCH}" 2>> "${GATE_LOG}"; then + BITE_BIT='false' + BITE_RAN='false' + if git checkout --quiet "${BRANCH}" -- "${BITE_FILES[@]}" "${BITE_SNAPS[@]}" 2>> "${GATE_LOG}"; then + BITE_ARGS=() + for f in "${BITE_FILES[@]}"; do + BITE_ARGS+=("${f#"${BITE_PKGS}"/}") + done + BITE_RAN='true' + if ! "${BITE_RUNNER}" "${BITE_PKGS}" "${BITE_ARGS[@]}" \ + > "${GATE_LOG}.bite" 2>&1; then + BITE_BIT='true' + fi + else + echo "🦷 bite check skipped: could not overlay the round's tests" \ + | tee -a "${GATE_LOG}" + fi + git checkout --quiet --force "${BRANCH}" 2>> "${GATE_LOG}" || { + # Same crash contract as the baseline A/B: the tree is no longer the + # one under verification, and a plain outcome=failed would advance + # the watermark on a verdict the gate never reached. Leave outcome + # unset so the next scan retries on a fresh checkout. + echo "❌ could not restore the verification tree after the bite check" + { + echo '**could not restore the verification tree after the bite check**' + echo + echo '````' + tail -c 3000 "${GATE_LOG}" 2> /dev/null + echo '````' + } > "${WORKDIR}/gate-rejection.md" || true + exit 1 + } + git reset --quiet 2>> "${GATE_LOG}" || true + if [[ "${BITE_RAN}" == 'true' && "${BITE_BIT}" == 'false' && "${BITE_ENFORCE}" == 'true' ]]; then + { + echo 'Every test this round added or changed ALSO PASSES on the pre-round tree (the branch as pushed, with only your test files overlaid). This round resolves a Critical / Request-changes finding in code, and a defect fix must come with a test that fails before the fix and passes after it — an all-green result here means the claimed defect does not reproduce, no matter who reported it.' + echo + echo 'If the finding does not reproduce, do not implement it: decline it (for a disproved finding) or escalate it as an open question, attaching this measurement as the evidence.' + echo + echo 'If the finding was already fixed by an EARLIER commit on this branch (a re-raised item you re-verified), resolve it in a round of its own without bundling new code changes — re-verification is a no-code claim and is never bite-checked.' + echo + echo 'Changed tests measured:' + for bf in "${BITE_FILES[@]}"; do + echo "- ${bf//[^A-Za-z0-9._\/ -]/?}" + done + # No fence here: reject_fix wraps this whole tail in its own + # 4-backtick fence, and CommonMark closes a fence at any inner + # run of >= the opener's length — so collapse any backtick run in + # the branch-controlled runner output below the opener's length. + tail -c 1200 "${GATE_LOG}.bite" 2> /dev/null | sed 's/\x60\x60\x60\x60*/```/g' + } >> "${GATE_LOG}" + reject_fix 'bite check: changed tests pass on the pre-round tree (claimed defect does not reproduce)' 'false' 'false' + elif [[ "${BITE_RAN}" == 'true' && "${BITE_BIT}" == 'false' ]]; then + # All-green without rejection: either no defect claim (refactor or + # coverage addition — legitimate) or a TEST-SIDE claim, whose fixed + # test is EXPECTED to pass pre-round. Say which. + if [[ "${BITE_ENFORCE}" == 'advisory' ]]; then + { + echo '🦷 **Gate advisory — test-side defect claim, changed tests all pass on the pre-round tree** (machine-measured, not agent-authored). Expected when the defect was in the test itself; the resolution rests on the round summary. · 本轮为测试侧缺陷声明,改动的测试在轮前树上全部通过(门自动测量)。若缺陷在测试本身属预期;该解决以轮次摘要为凭。' + } >> "${WORKDIR}/gate-advisories.md" + echo "🦷 test-side defect claim — advisory written (all-green is the expected shape)" \ + | tee -a "${GATE_LOG}" + else + { + echo '🦷 **Gate advisory — this round'"'"'s changed tests all pass on the pre-round tree** (machine-measured, not agent-authored). Expected for a refactor or coverage addition; if this round was meant to FIX a defect, that defect did not reproduce. · 本轮改动的测试在轮前树上全部通过(门自动测量,非 agent 文本)。对重构或补充覆盖属正常;若本轮意在修复缺陷,则该缺陷未能复现。' + } >> "${WORKDIR}/gate-advisories.md" + echo "🦷 changed tests all pass on the pre-round tree — advisory written (no defect claim in this round)" \ + | tee -a "${GATE_LOG}" + fi + elif [[ "${BITE_BIT}" == 'true' ]]; then + echo "🦷 bite confirmed: at least one changed test fails on the pre-round tree" \ + | tee -a "${GATE_LOG}" + fi + else + echo "🦷 bite check skipped: could not detach to the pre-round tree" \ + | tee -a "${GATE_LOG}" + fi + fi +fi assert_verification_tree echo "verified_head=${VERIFICATION_HEAD}" >> "${GITHUB_OUTPUT}" echo "outcome=fixed" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index 57018b1e17c..3fd5b54442a 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -5313,7 +5313,7 @@ jobs: if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true fi - for f in feedback.md address-summary.md no-action.md failure.md handoff.md gate-rejection.md agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json pr.diff; do + for f in feedback.md address-summary.md no-action.md failure.md handoff.md gate-rejection.md gate-advisories.md agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json pr.diff; do if [[ -f "${WORKDIR}/${f}" ]]; then echo "=============== ${f} ===============" cat "${WORKDIR}/${f}" @@ -5402,7 +5402,158 @@ jobs: exit 1 fi - # Take this PAT-bearing step off every mutable host git surface — + # Shared by the pushed and no-op outcomes: a no-op round may + # resolve re-verified findings (verified_head is the unchanged, + # previously verified origin head) and must post its declines' + # replies — silence in still-open threads was a no-op-only gap. + resolve_and_reply_threads() { + CAN_RESOLVE_THREADS='false' + if [[ -s "${WORKDIR}/resolved-comments.txt" ]]; then + LOCAL_PUSHED_HEAD="$(git rev-parse HEAD)" + if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then + echo "::warning::skipping review-thread resolution because the pushed head includes commits merged after deterministic verification" + elif [[ -z "${VERIFIED_HEAD}" || "${LOCAL_PUSHED_HEAD}" != "${VERIFIED_HEAD}" ]]; then + echo "::warning::skipping review-thread resolution because the pushed head is not the exact deterministically verified commit" + elif LIVE_PR_HEAD="$(gh pr view "${PR}" --repo "${REPO}" --json headRefOid --jq '.headRefOid // ""' 2> /dev/null)" && + [[ -n "${LIVE_PR_HEAD}" && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" ]]; then + CAN_RESOLVE_THREADS='true' + else + echo "::warning::skipping review-thread resolution because the live PR head could not be proven equal to the deterministically verified commit" + fi + fi + # Resolve the review threads whose findings the agent actually + # IMPLEMENTED, so a human re-reviewing sees only what is still open + # instead of re-reading every thread to work out what was handled. + # The agent cannot do this itself - its sandbox carries no token - + # so it records the inline-comment ids it implemented and this step, + # which already holds the PAT, maps each to its thread. Findings it + # DECLINED or deferred are deliberately left open. Best-effort + # throughout: a resolve failure must never fail a good push. + # Both this resolve block and the reply block below map an + # inline-comment id to its review thread, so the threads are + # fetched once here and shared. Hoisted above both so a round that + # only replies (no resolved-comments.txt) still has them. + # first-100 page cap: a comment in a thread past this page is not + # mapped, and each block falls back to the id as given. + if [[ -s "${WORKDIR}/resolved-comments.txt" || -s "${WORKDIR}/comment-replies.json" ]]; then + THREADS_RAW="$(gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f query=' + query($owner:String!,$name:String!,$pr:Int!){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100){nodes{id isResolved comments(first:100){nodes{databaseId}}} pageInfo{hasNextPage}} + } + } + }' --jq '(.data.repository.pullRequest.reviewThreads // {nodes:[]})' 2> /dev/null || echo '{"nodes":[]}')" + THREADS_JSON="$(jq '.nodes' <<< "${THREADS_RAW}")" + if [[ "$(jq -r '.pageInfo.hasNextPage // false' <<< "${THREADS_RAW}")" == "true" ]]; then + echo "::warning::PR has more than 100 review threads; threads past the first page will not be resolved or answered in-thread" + fi + fi + if [[ "${CAN_RESOLVE_THREADS}" == 'true' ]]; then + CONFIRMED_RESOLVED_N=0 + read_thread_guard() { + gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f threadId="${1}" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$threadId:ID!){ + repository(owner:$owner,name:$name){pullRequest(number:$pr){headRefOid}} + node(id:$threadId){... on PullRequestReviewThread{isResolved}} + }' --jq '[.data.repository.pullRequest.headRefOid // "", .data.node.isResolved] | @tsv' + } + while IFS= read -r rc_id || [[ -n "${rc_id}" ]]; do + rc_id="${rc_id%$'\r'}" + rc_id="${rc_id#rc:}" + [[ "${rc_id}" =~ ^[0-9]+$ ]] || continue + thread_id="$(jq -r --argjson id "${rc_id}" \ + 'map(select(.isResolved | not) + | select(any(.comments.nodes[]; .databaseId == $id))) + | .[0].id // ""' <<< "${THREADS_JSON}")" + if [[ -z "${thread_id}" ]]; then + echo "::warning::comment ${rc_id} matched no open review thread" + continue + fi + if ! IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null) || + [[ -z "${LIVE_PR_HEAD}" || "${LIVE_PR_HEAD}" != "${VERIFIED_HEAD}" ]]; then + echo "::warning::stopping review-thread resolution because the live PR head moved before resolving comment ${rc_id}" + break + elif [[ "${THREAD_IS_RESOLVED}" == 'true' ]]; then + echo "::warning::comment ${rc_id} was resolved by another actor before this round could resolve it" + continue + elif [[ "${THREAD_IS_RESOLVED}" != 'false' ]]; then + echo "::warning::stopping review-thread resolution because the state of comment ${rc_id} could not be proven" + break + fi + RESOLVE_SUCCEEDED='false' + if gh api graphql -f threadId="${thread_id}" -f query=' + mutation($threadId:ID!){ + resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}} + }' > /dev/null 2>&1; then + RESOLVE_SUCCEEDED='true' + fi + POST_GUARD_OK='false' + if IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null); then + POST_GUARD_OK='true' + fi + if [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'true' ]]; then + if [[ "${RESOLVE_SUCCEEDED}" != 'true' ]]; then + echo "::warning::comment ${rc_id} is resolved after an unsuccessful mutation command; another actor or a lost response may be responsible" + fi + CONFIRMED_RESOLVED_N=$(( CONFIRMED_RESOLVED_N + 1 )) + elif [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'false' && "${RESOLVE_SUCCEEDED}" == 'false' ]]; then + echo "::warning::could not resolve the review thread for comment ${rc_id}" + else + echo "::warning::the live PR head or thread state could not be proven after resolving comment ${rc_id}; stopping review-thread resolution" + break + fi + done < "${WORKDIR}/resolved-comments.txt" + echo "🧵 confirmed ${CONFIRMED_RESOLVED_N} selected review thread(s) resolved while the verified head remained live" + fi + # The mirror of the resolve above: a finding the agent did NOT + # resolve keeps its thread open, and this answers it IN that thread. + # Without it the reason sits only in the round summary, so the + # reviewer who opens the still-open thread sees silence and cannot + # tell their finding was read. Same neutralisation as the summary + # body — a reply is model output posted verbatim under the bot + # identity, so it could otherwise smuggle a forged control marker. + # Best-effort: a reply failure must never fail a good push. + if [[ -s "${WORKDIR}/comment-replies.json" ]] && + jq -e 'type == "array"' "${WORKDIR}/comment-replies.json" > /dev/null 2>&1; then + REPLIED_N=0 + while IFS=$'\t' read -r rc_id reply_b64; do + [[ "${rc_id}" =~ ^[0-9]+$ && -n "${reply_b64}" ]] || continue + # A finding cannot be both resolved and replied to; the resolve + # block above already closed anything in resolved-comments.txt, + # so skip it here rather than answer a thread we just resolved. + # Match tolerates the rc: prefix and a trailing CR, as the + # resolve block's own parsing does. + if [[ -f "${WORKDIR}/resolved-comments.txt" ]] && + tr -d '\r' < "${WORKDIR}/resolved-comments.txt" | + grep -qxE "(rc:)?${rc_id}"; then + continue + fi + REPLY_BODY="$(base64 -d <<< "${reply_b64}" | sed 's/ sits on another line, while jq scan() matches across newlines. // Proven end-to-end on a split forged marker. @@ -9682,7 +10449,7 @@ exit 1 // backslashes — a NO-OP on both GNU and BSD sed, verified) left the count // at four and this test green, shipping an unescaped publish site. const escapeSites = workflow.match(/sed 's\/