This is an automated email from the ASF dual-hosted git repository. shuke987 pushed a commit to branch codex/review-receipt-hardening in repository https://gitbox.apache.org/repos/asf/doris-skills.git
commit 6fb7266dd0cc3e9a964e4b9ad33c58d0341e023d Author: shuke <[email protected]> AuthorDate: Wed Aug 19 19:34:23 2026 +0800 fix: verify local review results before posting --- README.md | 4 +- skills/doris-repo-review/SKILL.md | 26 +-- .../references/pr-comment-format.md | 8 +- .../doris-repo-review/scripts/post-pass-comment.sh | 71 +++++-- .../scripts/record-review-runtime.sh | 6 +- skills/doris-repo-review/scripts/verify-anchors.py | 194 ++++++++++++++++-- verify/repo-review/run.sh | 225 +++++++++++++++++---- 7 files changed, 446 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 3224c21..353d599 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ deterministic review-receipt contract suite: - **L3 — behavior**: the skills *behave* as written — evidence-first and safety guardrails, an end-to-end advisor→DDL→live-cluster loopback, and skill-router triggering — exercised through a nested `claude -p`. -- **Review receipt**: a mocked-`gh` suite checks the qualified-runtime gate, review-run and commit - binding, automatic create/update behavior, and core refusal paths without writing to GitHub. +- **Review receipt**: a mocked-`gh` suite checks qualified-runtime binding, exact commit matching, + automatic create/update behavior, and every important refusal path without writing to GitHub. A skill that makes no DDL or doriscli claim (`doris-debug`, `doris-profile-reader`, `doris-repo-review`) has no L1/L2 coverage. `doris-repo-review` instead has a deterministic diff --git a/skills/doris-repo-review/SKILL.md b/skills/doris-repo-review/SKILL.md index 7385396..3b38eaa 100644 --- a/skills/doris-repo-review/SKILL.md +++ b/skills/doris-repo-review/SKILL.md @@ -43,7 +43,7 @@ $S/post-pass-comment.sh --ctx "$CTX" ... # step 10: |---|---| | `scripts/align-to-pr.sh` | Resolve the PR, diagnose how the current directory relates to it, align it to the PR head | | `scripts/prepare-review-context.sh` | Produce the authoritative diff, new-side line ranges, required AGENTS.md list, existing comments, ledger skeleton | -| `scripts/verify-anchors.py` | Check that every `path:line` anchor exists and that both documents expose the same finding IDs | +| `scripts/verify-anchors.py` | Check anchors and EN/ZH agreement, then bind verdict/counts/rounds and document hashes to this review run | | `scripts/record-review-runtime.sh` | Validate and bind the explicitly selected qualified reviewer runtime to the reviewed head | | `scripts/post-pass-comment.sh` | Render and post the machine-readable PASS comment; refuses everything that is not a pass | | `references/prompts.md` | Subagent prompt templates (CI wording, carried over verbatim) | @@ -368,8 +368,10 @@ python3 $S/verify-anchors.py --ctx "$CTX" \ ``` It checks that anchor paths exist, that line numbers are inside the file, that every finding has at -least one anchor, and that the EN and ZH finding-ID sets match; it also flags anchors pointing at -unchanged context lines - which is usually where a miscomputed line number shows up. +least one anchor, and that the EN and ZH finding IDs, severities, verdict, rounds, and convergence +match. On success it atomically writes the run-and-commit-bound `review-result.env` consumed by the +poster. It also flags anchors pointing at unchanged context lines - which is usually where a +miscomputed line number shows up. --- @@ -389,8 +391,6 @@ cat > "$CTX/pr-comment-notes.md" <<'EOF' EOF $S/post-pass-comment.sh --ctx "$CTX" \ - --findings <blocker>,<major>,<minor>,<nit> \ - --rounds <r> --converged true \ --notes-file "$CTX/pr-comment-notes.md" ``` @@ -398,8 +398,10 @@ $S/post-pass-comment.sh --ctx "$CTX" \ environment fallbacks, `unknown`, low efforts, and unapproved models are rejected. `--dry-run` remains available for maintainers testing the script, but it is not part of the normal skill flow. -- Findings counts and rounds must match the accepted findings and convergence result in the two - documents. They are supplied only after `verify-anchors.py` passes. +- Verdict, findings counts, rounds, convergence, document paths, and document hashes come only + from `review-result.env`, which `verify-anchors.py` replaces after successfully checking the + standard EN/ZH documents. Any later document edit invalidates the receipt until verification is + rerun. - The script refuses to post when: any `Blocker`/`Major` is present; the live PR head no longer equals the reviewed commit; the runtime record names another commit; model/effort/source is not eligible; the PR is not open (`--allow-closed` overrides); rounds are outside 1-3; the review did @@ -409,8 +411,8 @@ $S/post-pass-comment.sh --ctx "$CTX" \ the whole skill instead of posting. - An earlier v1 comment from the same account for the **same** commit is edited in place; a new commit gets a new comment, so each push leaves exactly one record. -- The comment is automatic after a qualified, converged pass. `--dry-run` is available only for - maintainer testing and is not a confirmation step in the normal flow. +- Never pass verdict, finding counts, rounds, or convergence to the poster by hand; it accepts only + the verifier's result for the current run and commit. - `references/pr-comment-format.md` holds the schema, the field meanings, and the parser snippet. **Never hand-write or hand-edit this comment** - the format is a contract other programs read. @@ -485,6 +487,6 @@ Tell the user: - **A pass is not a human merge approval.** Repository policy may accept the receipt for its exact commit, but it carries no Apache sign-off. The `<sub>` disclaimer line says exactly that - keep it. -- **Counts drift between the documents and the comment.** `--findings` must be the accepted - findings of step 9, not the candidate count from the ledger; re-count from the written documents - before posting. +- **Counts drift between the documents and the comment.** Never supply or edit counts separately. + The verifier derives them from the accepted findings in both step-9 documents, and the poster + re-verifies those documents immediately before posting. diff --git a/skills/doris-repo-review/references/pr-comment-format.md b/skills/doris-repo-review/references/pr-comment-format.md index 1db6cf3..8a7c6cc 100644 --- a/skills/doris-repo-review/references/pr-comment-format.md +++ b/skills/doris-repo-review/references/pr-comment-format.md @@ -60,9 +60,9 @@ receipt for the matching commit; it is not a human Apache approval.</sub> | `reviewer` | GitHub login | `gh api user`, falling back to GraphQL `viewer` and `gh auth status` | | `model` | exact eligible model id | review-run-and-commit-bound `review-runtime.env`, created before source review | | `effort` | `xhigh`, `max`, or `ultra` | review-run-and-commit-bound `review-runtime.env`; no environment or `unknown` fallback | -| `findings` | inline map | accepted EN/ZH document counts per severity; `blocker` and `major` are 0 in a PASS | -| `rounds` | int | convergence rounds actually run (1-3) | -| `converged` | bool | the producer posts only `true` | +| `findings` | inline map | verified EN/ZH document counts per severity; `blocker` and `major` are 0 in a PASS | +| `rounds` | int | verified EN/ZH convergence rounds (1-3) | +| `converged` | bool | verified EN/ZH result; the producer posts only `true` | Rules the script enforces, so they cannot drift: @@ -74,6 +74,8 @@ Rules the script enforces, so they cannot drift: independently rechecks the model allowlist, `xhigh`-or-higher effort, runtime source, run id, and recorded head. A runtime record cannot be reused by a later review of the same commit. - **The review must have converged in 1-3 rounds.** A non-converged run never posts a PASS receipt. +- **The documents must remain unchanged after verification.** The poster recomputes both SHA-256 + hashes from `review-result.env` before every create or update. - **At most 5 notes**, each a `- ` bullet (2-space indented continuation lines allowed). Longer material belongs in the local review documents. - **Same commit ⇒ update in place.** An earlier v1 comment by the same account carrying the same diff --git a/skills/doris-repo-review/scripts/post-pass-comment.sh b/skills/doris-repo-review/scripts/post-pass-comment.sh index 26624f9..d753934 100755 --- a/skills/doris-repo-review/scripts/post-pass-comment.sh +++ b/skills/doris-repo-review/scripts/post-pass-comment.sh @@ -5,10 +5,7 @@ # post-pass-comment.sh --ctx <dir> [options] # # --ctx <dir> review context directory (must contain meta.env and a -# qualified runtime record) -# --findings b,m,mi,n blocker,major,minor,nit counts (default 0,0,0,0) -# --rounds <n> convergence rounds actually run (default 1) -# --converged true|false (default true) +# qualified runtime record; standard EN/ZH docs must exist) # --notes-file <f> markdown bullet list for "Notes for maintainers" # --dry-run run every precondition, render the body, post nothing # --force-new always create a new comment, never update in place @@ -22,9 +19,6 @@ set -euo pipefail CTX="" -FINDINGS="0,0,0,0" -ROUNDS="1" -CONVERGED="true" NOTES_FILE="" DRY_RUN=0 FORCE_NEW=0 @@ -33,9 +27,6 @@ ALLOW_CLOSED=0 while [ $# -gt 0 ]; do case "$1" in --ctx) CTX="$2"; shift 2 ;; - --findings) FINDINGS="$2"; shift 2 ;; - --rounds) ROUNDS="$2"; shift 2 ;; - --converged) CONVERGED="$2"; shift 2 ;; --notes-file) NOTES_FILE="$2"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; --force-new) FORCE_NEW=1; shift ;; @@ -58,6 +49,7 @@ RUNTIME_FILE="$CTX/review-runtime.env" echo "ERROR: $RUNTIME_FILE not found - record an explicitly selected qualified reviewer first." >&2 exit 2 } +RESULT_FILE="$CTX/review-result.env" command -v gh >/dev/null 2>&1 || { echo "ERROR: gh CLI is required." >&2; exit 1; } command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required." >&2; exit 1; } command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 is required." >&2; exit 1; } @@ -71,6 +63,7 @@ BASE_SHA="$(read_meta BASE_SHA)" HEAD_SHA="$(read_meta HEAD_SHA)" REVIEW_RUN_ID="$(read_meta REVIEW_RUN_ID)" REPO_ROOT="$(read_meta REPO_ROOT)" +DOCS_ROOT="$(read_meta DOCS_ROOT)" MODEL="$(read_value "$RUNTIME_FILE" REVIEW_MODEL)" EFFORT="$(read_value "$RUNTIME_FILE" REVIEW_EFFORT)" RUNTIME_HEAD_SHA="$(read_value "$RUNTIME_FILE" REVIEW_HEAD_SHA)" @@ -108,12 +101,62 @@ if ! is_valid_review_run_id "$REVIEW_RUN_ID" \ exit 2 fi -case "$CONVERGED" in true) ;; *) echo "ERROR: a pipeline-equivalent PASS requires --converged true." >&2; exit 2 ;; esac -[[ "$ROUNDS" =~ ^[0-9]+$ ]] || { echo "ERROR: --rounds takes a number." >&2; exit 2; } -[ "$ROUNDS" -ge 1 ] && [ "$ROUNDS" -le 3 ] || { echo "ERROR: --rounds must be between 1 and 3." >&2; exit 2; } +# Re-run document verification immediately before rendering. This overwrites any stale or edited +# result file, so verdict/counts/rounds cannot be supplied by hand. +[ -n "$DOCS_ROOT" ] || DOCS_ROOT="$REPO_ROOT" +EXPECTED_EN_DOC="$DOCS_ROOT/review-docs/pr-${PR_NUMBER}-review.en.md" +EXPECTED_ZH_DOC="$DOCS_ROOT/review-docs/pr-${PR_NUMBER}-review.zh.md" +EXPECTED_EN_DOC="$(python3 -c 'import pathlib, sys; print(pathlib.Path(sys.argv[1]).resolve())' "$EXPECTED_EN_DOC")" +EXPECTED_ZH_DOC="$(python3 -c 'import pathlib, sys; print(pathlib.Path(sys.argv[1]).resolve())' "$EXPECTED_ZH_DOC")" +python3 "$SCRIPT_DIR/verify-anchors.py" --ctx "$CTX" \ + --doc "$EXPECTED_EN_DOC" --doc "$EXPECTED_ZH_DOC" || { + echo "ERROR: review documents did not pass final verification. Nothing was posted." >&2 + exit 2 +} +[ -f "$RESULT_FILE" ] || { echo "ERROR: verifier did not produce $RESULT_FILE." >&2; exit 2; } + +RESULT_RUN_ID="$(read_value "$RESULT_FILE" REVIEW_RUN_ID)" +RESULT_HEAD_SHA="$(read_value "$RESULT_FILE" REVIEW_HEAD_SHA)" +VERDICT="$(read_value "$RESULT_FILE" VERDICT)" +FINDINGS="$(read_value "$RESULT_FILE" FINDINGS)" +ROUNDS="$(read_value "$RESULT_FILE" ROUNDS)" +CONVERGED="$(read_value "$RESULT_FILE" CONVERGED)" +EN_DOC="$(read_value "$RESULT_FILE" EN_DOC)" +EN_DOC_SHA256="$(read_value "$RESULT_FILE" EN_DOC_SHA256)" +ZH_DOC="$(read_value "$RESULT_FILE" ZH_DOC)" +ZH_DOC_SHA256="$(read_value "$RESULT_FILE" ZH_DOC_SHA256)" + +[ "$RESULT_RUN_ID" = "$REVIEW_RUN_ID" ] && [ "$RESULT_HEAD_SHA" = "$HEAD_SHA" ] || { + echo "ERROR: verified review result belongs to a different run or commit." >&2 + exit 2 +} +[ "$EN_DOC" = "$EXPECTED_EN_DOC" ] && [ "$ZH_DOC" = "$EXPECTED_ZH_DOC" ] || { + echo "ERROR: verifier result names unexpected review documents." >&2 + exit 2 +} +[ "$VERDICT" = "APPROVE" ] || { + echo "ERROR: verified review verdict is '$VERDICT', not APPROVE. Nothing was posted." >&2 + exit 1 +} + +document_sha256() { + python3 -c 'import hashlib, pathlib, sys; print(hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest())' "$1" +} +for doc in "$EN_DOC" "$ZH_DOC"; do + [ -f "$doc" ] || { echo "ERROR: verified review document no longer exists: $doc" >&2; exit 2; } +done +[ "$(document_sha256 "$EN_DOC")" = "$EN_DOC_SHA256" ] \ + && [ "$(document_sha256 "$ZH_DOC")" = "$ZH_DOC_SHA256" ] || { + echo "ERROR: a review document changed after anchor verification; verify both documents again." >&2 + exit 2 +} + +case "$CONVERGED" in true) ;; *) echo "ERROR: the verified review did not converge." >&2; exit 2 ;; esac +[[ "$ROUNDS" =~ ^[0-9]+$ ]] || { echo "ERROR: verified rounds must be numeric." >&2; exit 2; } +[ "$ROUNDS" -ge 1 ] && [ "$ROUNDS" -le 3 ] || { echo "ERROR: verified rounds must be between 1 and 3." >&2; exit 2; } IFS=',' read -r F_BLOCKER F_MAJOR F_MINOR F_NIT <<<"$FINDINGS" for v in "$F_BLOCKER" "$F_MAJOR" "$F_MINOR" "$F_NIT"; do - [[ "$v" =~ ^[0-9]+$ ]] || { echo "ERROR: --findings takes blocker,major,minor,nit as four numbers." >&2; exit 2; } + [[ "$v" =~ ^[0-9]+$ ]] || { echo "ERROR: verified findings must be blocker,major,minor,nit as four numbers." >&2; exit 2; } done # The verdict rule: any Blocker or Major means REQUEST_CHANGES, and a diff --git a/skills/doris-repo-review/scripts/record-review-runtime.sh b/skills/doris-repo-review/scripts/record-review-runtime.sh index dc921ab..d6efbca 100755 --- a/skills/doris-repo-review/scripts/record-review-runtime.sh +++ b/skills/doris-repo-review/scripts/record-review-runtime.sh @@ -42,8 +42,10 @@ is_valid_review_run_id "$REVIEW_RUN_ID" || { } RUNTIME_FILE="$CTX/review-runtime.env" -# A rejected replacement must not leave a previously qualified runtime available for reuse. -rm -f "$RUNTIME_FILE" "$CTX/pr-comment.md" "$CTX/pr-comment.url" +RESULT_FILE="$CTX/review-result.env" +# Every recording attempt starts a new attestation for this run. A rejected replacement must not +# leave a previously qualified runtime or verified result available for accidental reuse. +rm -f "$RUNTIME_FILE" "$RESULT_FILE" "$CTX/pr-comment.md" "$CTX/pr-comment.url" is_eligible_review_model "$MODEL" || { echo "ERROR: model '$MODEL' is not eligible for pipeline-equivalent review." >&2 diff --git a/skills/doris-repo-review/scripts/verify-anchors.py b/skills/doris-repo-review/scripts/verify-anchors.py index 8eefd3d..321f596 100755 --- a/skills/doris-repo-review/scripts/verify-anchors.py +++ b/skills/doris-repo-review/scripts/verify-anchors.py @@ -15,12 +15,15 @@ Anchors must be written inside backticks as `path:line` or `path:start-end`. Usage: verify-anchors.py --ctx <context-dir> --doc <en.md> --doc <zh.md> -Exits non-zero when an anchor is broken or the two documents disagree. +Exits non-zero when an anchor is broken or the two documents disagree. On success for a PR, it +also replaces `<ctx>/review-result.env` with the run id, head, verdict, severity counts, rounds, +convergence, standard document paths, and SHA-256 hashes consumed by the PASS poster. """ from __future__ import annotations import argparse +import hashlib import re import sys from collections import defaultdict @@ -29,6 +32,19 @@ from pathlib import Path ANCHOR_RE = re.compile(r"`([A-Za-z0-9_][A-Za-z0-9_./+-]*\.[A-Za-z0-9_+-]+):(\d+)(?:-(\d+))?`") FINDING_RE = re.compile(r"^#{2,4}\s+(?:\[)?(F-\d+)(?:\])?\b") CODE_FENCE_RE = re.compile(r"^\s*```") +UUID_RE = re.compile( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + r"[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}" +) +SEVERITY_RE = re.compile( + r"^-\s+\*\*(?:Severity|等级)\*\*\s*[::]\s*(Blocker|Major|Minor|Nit)\s*$", + re.IGNORECASE, +) +VERDICT_RE = re.compile( + r"^\|\s*(?:Verdict|结论)\s*\|\s*\*\*(APPROVE|REQUEST_CHANGES)\*\*\s*\|" +) +ROUNDS_ROW_RE = re.compile(r"^\|\s*(?:Rounds|轮次)\s*\|(.*?)\|\s*$") +SEVERITIES = ("Blocker", "Major", "Minor", "Nit") def parse_args() -> argparse.Namespace: @@ -62,11 +78,14 @@ def load_changed_ranges(ctx: Path) -> dict[str, list[tuple[int, int]]]: return ranges -def scan_document(doc: Path) -> tuple[list[str], dict[str, list[tuple[int, int, int | None]]], list[tuple[int, int, int | None]]]: +Anchor = tuple[str, int, int | None, int] + + +def scan_document(doc: Path) -> tuple[list[str], dict[str, list[Anchor]], list[Anchor]]: """Return (finding ids in order, anchors per finding, all anchors).""" findings: list[str] = [] - per_finding: dict[str, list[tuple[int, int, int | None]]] = defaultdict(list) - all_anchors: list[tuple[str, int, int | None, int]] = [] + per_finding: dict[str, list[Anchor]] = defaultdict(list) + all_anchors: list[Anchor] = [] current: str | None = None in_fence = False @@ -74,13 +93,13 @@ def scan_document(doc: Path) -> tuple[list[str], dict[str, list[tuple[int, int, if CODE_FENCE_RE.match(line): in_fence = not in_fence continue + if in_fence: + continue heading = FINDING_RE.match(line) if heading: current = heading.group(1) if current not in findings: findings.append(current) - if in_fence: - continue for match in ANCHOR_RE.finditer(line): path, start, end = match.group(1), int(match.group(2)), match.group(3) anchor = (path, start, int(end) if end else None, lineno) @@ -95,16 +114,136 @@ def in_changed_range(ranges: list[tuple[int, int]], start: int, end: int | None) return any(not (last < lo or start > hi) for lo, hi in ranges) +def parse_review_summary( + doc: Path, +) -> tuple[str | None, int | None, bool | None, dict[str, str], list[str]]: + """Return verdict, rounds, convergence, per-finding severity, and format errors.""" + current: str | None = None + in_fence = False + verdict: str | None = None + rounds: int | None = None + converged: bool | None = None + severities: dict[str, str] = {} + errors: list[str] = [] + + for lineno, line in enumerate(doc.read_text(encoding="utf-8").splitlines(), start=1): + if CODE_FENCE_RE.match(line): + in_fence = not in_fence + continue + if in_fence: + continue + heading = FINDING_RE.match(line) + if heading: + current = heading.group(1) + continue + severity_match = SEVERITY_RE.match(line) + if severity_match: + if current is None: + errors.append(f"{doc}:{lineno}: severity is not under a finding heading") + elif current in severities: + errors.append(f"{doc}:{lineno}: duplicate severity for {current}") + else: + severities[current] = severity_match.group(1).title() + verdict_match = VERDICT_RE.match(line) + if verdict_match: + if verdict is not None: + errors.append(f"{doc}:{lineno}: duplicate verdict row") + verdict = verdict_match.group(1) + rounds_match = ROUNDS_ROW_RE.match(line) + if rounds_match: + if rounds is not None: + errors.append(f"{doc}:{lineno}: duplicate rounds row") + continue + value = rounds_match.group(1) + number = re.search(r"\d+", value) + if number is None: + errors.append(f"{doc}:{lineno}: rounds row has no number") + else: + rounds = int(number.group(0)) + lowered = value.casefold() + if "not converged" in lowered or "未收敛" in value: + converged = False + elif "converged" in lowered or "已收敛" in value: + converged = True + else: + errors.append(f"{doc}:{lineno}: rounds row does not state convergence") + + if verdict is None: + errors.append(f"{doc}: missing Verdict/结论 row") + if rounds is None: + errors.append(f"{doc}: missing Rounds/轮次 row") + elif not 1 <= rounds <= 3: + errors.append(f"{doc}: rounds must be between 1 and 3") + return verdict, rounds, converged, severities, errors + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_review_result( + ctx: Path, + meta: dict[str, str], + docs: dict[str, Path], + verdict: str, + findings: tuple[int, int, int, int], + rounds: int, + converged: bool, +) -> Path: + result = ctx / "review-result.env" + temporary = ctx / ".review-result.env.tmp" + lines = [ + f"REVIEW_RUN_ID={meta['REVIEW_RUN_ID']}", + f"REVIEW_HEAD_SHA={meta['HEAD_SHA']}", + f"VERDICT={verdict}", + f"FINDINGS={','.join(str(value) for value in findings)}", + f"ROUNDS={rounds}", + f"CONVERGED={'true' if converged else 'false'}", + f"EN_DOC={docs['en']}", + f"EN_DOC_SHA256={sha256_file(docs['en'])}", + f"ZH_DOC={docs['zh']}", + f"ZH_DOC_SHA256={sha256_file(docs['zh'])}", + ] + temporary.write_text("\n".join(lines) + "\n", encoding="utf-8") + temporary.replace(result) + return result + + def main() -> int: args = parse_args() ctx = args.ctx.resolve() meta = read_meta(ctx) + result_path = ctx / "review-result.env" + try: + result_path.unlink() + except FileNotFoundError: + pass repo_root = (args.repo_root or Path(meta.get("REPO_ROOT", "."))).resolve() changed_ranges = load_changed_ranges(ctx) errors: list[str] = [] warnings: list[str] = [] finding_sets: dict[Path, list[str]] = {} + summaries: dict[Path, tuple[str | None, int | None, bool | None, dict[str, str]]] = {} + + if len(args.doc) != 2: + errors.append("exactly two review documents (EN and ZH) are required") + + pr_number = meta.get("PR_NUMBER", "") + docs_root = Path(meta.get("DOCS_ROOT", repo_root)).resolve() + expected_docs: dict[str, Path] = {} + if pr_number: + expected_docs = { + "en": (docs_root / "review-docs" / f"pr-{pr_number}-review.en.md").resolve(), + "zh": (docs_root / "review-docs" / f"pr-{pr_number}-review.zh.md").resolve(), + } + actual_docs = {doc.resolve() for doc in args.doc} + if actual_docs != set(expected_docs.values()): + errors.append("review documents must use the standard PR-specific EN/ZH paths") for doc in args.doc: doc = doc.resolve() @@ -113,6 +252,9 @@ def main() -> int: continue findings, per_finding, anchors = scan_document(doc) finding_sets[doc] = findings + verdict, rounds, converged, severities, summary_errors = parse_review_summary(doc) + summaries[doc] = (verdict, rounds, converged, severities) + errors.extend(summary_errors) print(f"\n=== {doc} ===") print(f"findings: {len(findings)} anchors: {len(anchors)}") @@ -139,18 +281,27 @@ def main() -> int: for finding in findings: if not per_finding.get(finding): errors.append(f"{doc}: finding {finding} has no `path:line` anchor") + if finding not in severities: + errors.append(f"{doc}: finding {finding} has no severity") docs = list(finding_sets) if len(docs) >= 2: - base_doc, base_ids = docs[0], set(finding_sets[docs[0]]) + base_doc, base_ids = docs[0], finding_sets[docs[0]] for other in docs[1:]: - other_ids = set(finding_sets[other]) - missing = sorted(base_ids - other_ids) - extra = sorted(other_ids - base_ids) - if missing: - errors.append(f"{other}: missing finding IDs present in {base_doc.name}: {', '.join(missing)}") - if extra: - errors.append(f"{other}: has finding IDs absent from {base_doc.name}: {', '.join(extra)}") + other_ids = finding_sets[other] + if other_ids != base_ids: + errors.append(f"{other}: finding IDs or order differ from {base_doc.name}") + if summaries[other][3] != summaries[base_doc][3]: + errors.append(f"{other}: finding severities differ from {base_doc.name}") + if summaries[other][:3] != summaries[base_doc][:3]: + errors.append(f"{other}: verdict, rounds, or convergence differs from {base_doc.name}") + + if docs: + verdict, rounds, converged, severities = summaries[docs[0]] + counts = tuple(sum(1 for value in severities.values() if value == name) for name in SEVERITIES) + expected_verdict = "REQUEST_CHANGES" if counts[0] or counts[1] else "APPROVE" + if verdict is not None and verdict != expected_verdict: + errors.append(f"{docs[0]}: verdict {verdict} conflicts with finding severities") if warnings: print("\n--- notes ---") @@ -161,10 +312,23 @@ def main() -> int: print("\n--- errors ---") for error in errors: print(f" ERROR: {error}") - print(f"\nFAILED: {len(errors)} anchor problem(s).") + print(f"\nFAILED: {len(errors)} review document problem(s).") return 1 + if not pr_number or set(expected_docs.values()) != set(docs): + print("\nOK: every anchor resolves and both documents expose the same findings.") + print("No PR-bound review result was written.") + return 0 + + run_id = meta.get("REVIEW_RUN_ID", "") + head_sha = meta.get("HEAD_SHA", "") + if not UUID_RE.fullmatch(run_id) or not re.fullmatch(r"[0-9a-fA-F]{40}", head_sha): + print("\nFAILED: meta.env lacks a valid REVIEW_RUN_ID or HEAD_SHA.") + return 1 + assert verdict is not None and rounds is not None and converged is not None + result = write_review_result(ctx, meta, expected_docs, verdict, counts, rounds, converged) print("\nOK: every anchor resolves and both documents expose the same findings.") + print(f"review result: {result}") return 0 diff --git a/verify/repo-review/run.sh b/verify/repo-review/run.sh index 545b764..cd5c9d0 100755 --- a/verify/repo-review/run.sh +++ b/verify/repo-review/run.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash -# Core contract tests for qualified doris-repo-review PASS receipts. No network writes. +# Deterministic producer tests for doris-repo-review PASS receipts. No network writes. set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" S="$ROOT/skills/doris-repo-review/scripts" TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/doris-repo-review-test.XXXXXX")" trap 'rm -rf "$TMP_ROOT"' EXIT -mkdir -p "$TMP_ROOT/bin" "$TMP_ROOT/repo" +mkdir -p "$TMP_ROOT/bin" "$TMP_ROOT/repo/src" "$TMP_ROOT/repo/review-docs" +FIXTURE_REPO="$TMP_ROOT/repo" HEAD_SHA="1111111111111111111111111111111111111111" OTHER_SHA="3333333333333333333333333333333333333333" BASE_SHA="2222222222222222222222222222222222222222" @@ -15,6 +16,8 @@ RUN_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" OTHER_RUN_ID="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" PASS_COUNT=0 +printf 'int answer = 42;\n' > "$FIXTURE_REPO/src/Foo.java" + fail() { echo "FAIL: $*" >&2 exit 1 @@ -64,18 +67,62 @@ PR_URL=https://github.com/apache/doris/pull/123 BASE_SHA=$BASE_SHA HEAD_SHA=$head REVIEW_RUN_ID=$run_id -REPO_ROOT=$TMP_ROOT/repo +REPO_ROOT=$FIXTURE_REPO +DOCS_ROOT=$FIXTURE_REPO +EOF + printf 'src/Foo.java\t1\t1\n' > "$ctx/changed_line_ranges.tsv" +} + +write_docs() { + local verdict="$1" + local severity="$2" + local rounds="$3" + local convergence="$4" + local zh_convergence="已收敛" + [ "$convergence" = "converged" ] || zh_convergence="未收敛" + cat > "$FIXTURE_REPO/review-docs/pr-123-review.en.md" <<EOF +# Code Review — PR #123: fixture + +| | | +|---|---| +| Verdict | **$verdict** | +| Rounds | $rounds of max 3, $convergence | + +## Findings + +### F-01 · fixture finding + +- **Severity**: $severity +- **Where**: \`src/Foo.java:1\` +EOF + cat > "$FIXTURE_REPO/review-docs/pr-123-review.zh.md" <<EOF +# 代码评审 — PR #123:fixture + +| | | +|---|---| +| 结论 | **$verdict** | +| 轮次 | 共 $rounds 轮(上限 3),$zh_convergence | + +## 问题清单 + +### F-01 · fixture finding + +- **等级**:$severity +- **位置**:\`src/Foo.java:1\` EOF } record_codex_runtime() { - "$S/record-review-runtime.sh" --ctx "$1" \ + local ctx="$1" + "$S/record-review-runtime.sh" --ctx "$ctx" \ --model gpt-5.6-sol --effort xhigh --source codex-explicit-spawn } -post_pass() { - "$S/post-pass-comment.sh" --ctx "$1" \ - --findings 0,0,1,0 --rounds 2 --converged true "${@:2}" +verify_docs() { + local ctx="$1" + python3 "$S/verify-anchors.py" --ctx "$ctx" \ + --doc "$FIXTURE_REPO/review-docs/pr-123-review.en.md" \ + --doc "$FIXTURE_REPO/review-docs/pr-123-review.zh.md" } cat > "$TMP_ROOT/bin/gh" <<'EOF' @@ -108,8 +155,11 @@ export MOCK_LIVE_HEAD="$HEAD_SHA" CTX="$TMP_ROOT/ctx" write_meta "$CTX" "$HEAD_SHA" + expect_failure "poster requires a runtime record" "review-runtime.env not found" \ - post_pass "$CTX" --dry-run + "$S/post-pass-comment.sh" --ctx "$CTX" --dry-run +expect_failure "poster rejects command-line runtime overrides" "unknown argument: --model" \ + "$S/post-pass-comment.sh" --ctx "$CTX" --model gpt-5.6-sol --effort xhigh --dry-run record_codex_runtime "$CTX" >"$TMP_ROOT/record.out" assert_file_contains "$CTX/review-runtime.env" "REVIEW_MODEL=gpt-5.6-sol" @@ -117,42 +167,119 @@ assert_file_contains "$CTX/review-runtime.env" "REVIEW_EFFORT=xhigh" assert_file_contains "$CTX/review-runtime.env" "REVIEW_HEAD_SHA=$HEAD_SHA" assert_file_contains "$CTX/review-runtime.env" "REVIEW_RUN_ID=$RUN_ID" assert_file_contains "$CTX/review-runtime.env" "REVIEW_RUNTIME_SOURCE=codex-explicit-spawn" -pass "qualified runtime is bound to the review run and commit" +pass "qualified Codex runtime is bound to the review run and commit" + +expect_failure "poster requires complete review documents" "did not pass final verification" \ + "$S/post-pass-comment.sh" --ctx "$CTX" --dry-run +write_docs APPROVE Minor 2 converged +verify_docs "$CTX" >"$TMP_ROOT/verify.out" +assert_file_contains "$CTX/review-result.env" "VERDICT=APPROVE" +assert_file_contains "$CTX/review-result.env" "FINDINGS=0,0,1,0" +assert_file_contains "$CTX/review-result.env" "ROUNDS=2" +assert_file_contains "$CTX/review-result.env" "CONVERGED=true" +pass "verifier binds document result and hashes to the review run" + +python3 - "$CTX/review-result.env" <<'PY' +from pathlib import Path +import sys -post_pass "$CTX" --dry-run >"$TMP_ROOT/dry-run.out" +path = Path(sys.argv[1]) +path.write_text(path.read_text().replace("FINDINGS=0,0,1,0", "FINDINGS=0,0,99,0")) +PY +"$S/post-pass-comment.sh" --ctx "$CTX" --dry-run >"$TMP_ROOT/dry-run.out" assert_file_contains "$CTX/pr-comment.md" "commit: $HEAD_SHA" assert_file_contains "$CTX/pr-comment.md" "model: gpt-5.6-sol" assert_file_contains "$CTX/pr-comment.md" "effort: xhigh" assert_file_contains "$CTX/pr-comment.md" "findings: {blocker: 0, major: 0, minor: 1, nit: 0}" +assert_file_contains "$TMP_ROOT/dry-run.out" "dry run - nothing was posted." +ACTUAL_FIELDS="$(awk ' + /^```yaml$/ { inside=1; next } + inside && /^```$/ { exit } + inside { sub(/:.*/, ""); print } +' "$CTX/pr-comment.md")" +EXPECTED_FIELDS="$(printf '%s\n' schema status pr commit base reviewed_at reviewer model effort findings rounds converged)" +[ "$ACTUAL_FIELDS" = "$EXPECTED_FIELDS" ] || fail "receipt fields or ordering drifted from v1" [ -z "${RECEIPT_OUTPUT:-}" ] || cp "$CTX/pr-comment.md" "$RECEIPT_OUTPUT" -pass "qualified dry run renders a pipeline-compatible receipt" +pass "verified qualified dry run renders a pipeline-compatible receipt" : > "$MOCK_GH_LOG" -MOCK_SAME_COMMENT=0 post_pass "$CTX" >"$TMP_ROOT/create.out" +MOCK_SAME_COMMENT=0 "$S/post-pass-comment.sh" --ctx "$CTX" >"$TMP_ROOT/create.out" assert_file_contains "$MOCK_GH_LOG" "--method POST repos/apache/doris/issues/123/comments --input -" -pass "qualified PASS automatically creates a comment" +assert_file_contains "$CTX/pr-comment.url" "#issuecomment-test" +pass "converged qualified PASS automatically creates the comment" : > "$MOCK_GH_LOG" -MOCK_SAME_COMMENT=1 post_pass "$CTX" >"$TMP_ROOT/update.out" +MOCK_SAME_COMMENT=1 "$S/post-pass-comment.sh" --ctx "$CTX" >"$TMP_ROOT/update.out" assert_file_contains "$MOCK_GH_LOG" "--method PATCH repos/apache/doris/issues/comments/987 --input -" -pass "same-commit rerun updates the existing comment" +pass "same-commit rerun automatically updates the existing comment" -REPLACE_CTX="$TMP_ROOT/replace-ctx" -write_meta "$REPLACE_CTX" "$HEAD_SHA" -record_codex_runtime "$REPLACE_CTX" >"$TMP_ROOT/replace-record.out" +FAILED_REPLACE_CTX="$TMP_ROOT/failed-replace-ctx" +write_meta "$FAILED_REPLACE_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 2 converged +record_codex_runtime "$FAILED_REPLACE_CTX" >"$TMP_ROOT/replace-record.out" +verify_docs "$FAILED_REPLACE_CTX" >"$TMP_ROOT/replace-verify.out" expect_failure "high effort is below the required floor" "use xhigh or higher" \ - "$S/record-review-runtime.sh" --ctx "$REPLACE_CTX" \ + "$S/record-review-runtime.sh" --ctx "$FAILED_REPLACE_CTX" \ --model gpt-5.6-sol --effort high --source codex-explicit-spawn -assert_file_absent "$REPLACE_CTX/review-runtime.env" -pass "failed replacement invalidates the old runtime record" +assert_file_absent "$FAILED_REPLACE_CTX/review-runtime.env" +assert_file_absent "$FAILED_REPLACE_CTX/review-result.env" +pass "failed runtime replacement invalidates old credentials" expect_failure "unapproved model is rejected" "is not eligible" \ - "$S/record-review-runtime.sh" --ctx "$REPLACE_CTX" \ + "$S/record-review-runtime.sh" --ctx "$FAILED_REPLACE_CTX" \ --model gpt-5.6-terra --effort xhigh --source codex-explicit-spawn expect_failure "model/source mismatch is rejected" "is not valid" \ - "$S/record-review-runtime.sh" --ctx "$REPLACE_CTX" \ + "$S/record-review-runtime.sh" --ctx "$FAILED_REPLACE_CTX" \ --model gpt-5.6-sol --effort xhigh --source claude-explicit-session +TAMPERED_CTX="$TMP_ROOT/tampered-ctx" +write_meta "$TAMPERED_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 2 converged +record_codex_runtime "$TAMPERED_CTX" >"$TMP_ROOT/tampered-record.out" +verify_docs "$TAMPERED_CTX" >"$TMP_ROOT/tampered-verify.out" +cat > "$TAMPERED_CTX/review-runtime.env" <<EOF +REVIEW_MODEL=gpt-5.6-sol +REVIEW_EFFORT=high +REVIEW_HEAD_SHA=$HEAD_SHA +REVIEW_RUN_ID=$RUN_ID +REVIEW_RUNTIME_SOURCE=codex-explicit-spawn +EOF +expect_failure "poster independently rejects a tampered low effort" "use xhigh or higher" \ + "$S/post-pass-comment.sh" --ctx "$TAMPERED_CTX" --dry-run + +REUSED_CTX="$TMP_ROOT/reused-ctx" +write_meta "$REUSED_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 2 converged +record_codex_runtime "$REUSED_CTX" >"$TMP_ROOT/reused-record.out" +verify_docs "$REUSED_CTX" >"$TMP_ROOT/reused-verify.out" +write_meta "$REUSED_CTX" "$HEAD_SHA" "$OTHER_RUN_ID" +expect_failure "runtime record cannot be reused by a later review" "different review run" \ + "$S/post-pass-comment.sh" --ctx "$REUSED_CTX" --dry-run + +HASH_CTX="$TMP_ROOT/hash-ctx" +write_meta "$HASH_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 2 converged +record_codex_runtime "$HASH_CTX" >"$TMP_ROOT/hash-record.out" +verify_docs "$HASH_CTX" >"$TMP_ROOT/hash-verify.out" +cat >> "$FIXTURE_REPO/review-docs/pr-123-review.en.md" <<'EOF' + +### F-02 · added after verification + +- **Severity**: Minor +- **Where**: `src/Foo.java:1` +EOF +expect_failure "invalid document changes are caught by final re-verification" "did not pass final verification" \ + "$S/post-pass-comment.sh" --ctx "$HASH_CTX" --dry-run + +CLAUDE_CTX="$TMP_ROOT/claude-ctx" +write_meta "$CLAUDE_CTX" "$HEAD_SHA" +"$S/record-review-runtime.sh" --ctx "$CLAUDE_CTX" \ + --model 'claude-opus-5[1m]' --effort max --source claude-explicit-session \ + >"$TMP_ROOT/claude-record.out" +assert_file_contains "$CLAUDE_CTX/review-runtime.env" "REVIEW_MODEL=claude-opus-5[1m]" +pass "eligible Claude runtime is accepted" + +# Keep this list explicit: a family-prefix match would silently admit future or lower-tier models. # shellcheck source=/dev/null . "$S/review-runtime-policy.sh" for model in claude-opus-5 'claude-opus-5[1m]' claude-fable-5 'claude-fable-5[1m]' gpt-5.6-sol; do @@ -173,33 +300,51 @@ for effort in minimal low medium high unknown; do done pass "model and effort allowlists are exact" -REUSED_CTX="$TMP_ROOT/reused-ctx" -write_meta "$REUSED_CTX" "$HEAD_SHA" -record_codex_runtime "$REUSED_CTX" >"$TMP_ROOT/reused-record.out" -write_meta "$REUSED_CTX" "$HEAD_SHA" "$OTHER_RUN_ID" -expect_failure "runtime record cannot be reused by a later review" "different review run" \ - post_pass "$REUSED_CTX" --dry-run +NONCONVERGED_CTX="$TMP_ROOT/nonconverged-ctx" +write_meta "$NONCONVERGED_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 3 "not converged" +record_codex_runtime "$NONCONVERGED_CTX" >"$TMP_ROOT/nonconverged-record.out" +verify_docs "$NONCONVERGED_CTX" >"$TMP_ROOT/nonconverged-verify.out" +expect_failure "non-converged review cannot post PASS" "verified review did not converge" \ + "$S/post-pass-comment.sh" --ctx "$NONCONVERGED_CTX" --dry-run + +INVALID_ROUNDS_CTX="$TMP_ROOT/invalid-rounds-ctx" +write_meta "$INVALID_ROUNDS_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 0 converged +expect_failure "round zero is rejected by the document verifier" "between 1 and 3" \ + verify_docs "$INVALID_ROUNDS_CTX" +assert_file_absent "$INVALID_ROUNDS_CTX/review-result.env" +write_docs APPROVE Minor 4 converged +expect_failure "round four is rejected by the document verifier" "between 1 and 3" \ + verify_docs "$INVALID_ROUNDS_CTX" + +CHANGES_CTX="$TMP_ROOT/changes-ctx" +write_meta "$CHANGES_CTX" "$HEAD_SHA" +write_docs REQUEST_CHANGES Major 2 converged +record_codex_runtime "$CHANGES_CTX" >"$TMP_ROOT/changes-record.out" +verify_docs "$CHANGES_CTX" >"$TMP_ROOT/changes-verify.out" +expect_failure "REQUEST_CHANGES result cannot post PASS" "not APPROVE" \ + "$S/post-pass-comment.sh" --ctx "$CHANGES_CTX" --dry-run MISMATCH_CTX="$TMP_ROOT/mismatch-ctx" write_meta "$MISMATCH_CTX" "$HEAD_SHA" -record_codex_runtime "$MISMATCH_CTX" >"$TMP_ROOT/mismatch-record.out" +write_docs APPROVE Minor 2 converged +"$S/record-review-runtime.sh" --ctx "$MISMATCH_CTX" \ + --model gpt-5.6-sol --effort xhigh --source codex-explicit-task \ + >"$TMP_ROOT/mismatch-record.out" +verify_docs "$MISMATCH_CTX" >"$TMP_ROOT/mismatch-verify.out" write_meta "$MISMATCH_CTX" "$OTHER_SHA" MOCK_LIVE_HEAD="$OTHER_SHA" expect_failure \ "runtime record is bound to one commit" "recorded for a different commit" \ - post_pass "$MISMATCH_CTX" --dry-run + "$S/post-pass-comment.sh" --ctx "$MISMATCH_CTX" --dry-run MOVED_CTX="$TMP_ROOT/moved-ctx" write_meta "$MOVED_CTX" "$HEAD_SHA" +write_docs APPROVE Minor 2 converged record_codex_runtime "$MOVED_CTX" >"$TMP_ROOT/moved-record.out" +verify_docs "$MOVED_CTX" >"$TMP_ROOT/moved-verify.out" MOCK_LIVE_HEAD="$OTHER_SHA" expect_failure \ "moved PR head invalidates the review" "PR head moved since the review" \ - post_pass "$MOVED_CTX" --dry-run - -expect_failure "non-converged review cannot post PASS" "requires --converged true" \ - "$S/post-pass-comment.sh" --ctx "$CTX" \ - --findings 0,0,0,0 --rounds 3 --converged false --dry-run -expect_failure "Blocker result cannot post PASS" "mean REQUEST_CHANGES" \ - "$S/post-pass-comment.sh" --ctx "$CTX" \ - --findings 1,0,0,0 --rounds 2 --converged true --dry-run + "$S/post-pass-comment.sh" --ctx "$MOVED_CTX" --dry-run -echo "$PASS_COUNT review-runtime tests passed" +echo "$PASS_COUNT review-receipt tests passed" --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
