diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 5fbd3bb..bdbb023 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -487,6 +487,11 @@ jobs: $(sed -n '1,900p' "$OPENCODE_EVIDENCE_FILE") + Use CodeGraph for blast-radius, call graph, and test-coverage questions before broad local reads. + Prefer deletion, stdlib/native platform features, and already-installed dependencies before proposing new code or packages, but do not simplify away trust-boundary validation, data-loss handling, security, accessibility, or required tests. + For Korean prose, preserve facts, identifiers, numbers, and quotes while removing only formulaic filler or translationese. + When Strix evidence supports it, name the concrete CWE/KISA-style class such as injection, auth/authz, secrets, crypto, path traversal/file upload, XSS/CSRF/SSRF, error disclosure, or debug/deployment config; do not invent a category without evidence. + Before APPROVE, the summary must include at least one exact changed file path inspected as changed-file evidence. Never approve with a reason or summary that says no changes, no files, or no actionable changes were found when bounded evidence lists changed files; that control block is invalid. First line exactly: Then exactly one control block: @@ -576,6 +581,11 @@ jobs: $(sed -n '1,900p' "$OPENCODE_EVIDENCE_FILE") + Use CodeGraph for blast-radius, call graph, and test-coverage questions before broad local reads. + Prefer deletion, stdlib/native platform features, and already-installed dependencies before proposing new code or packages, but do not simplify away trust-boundary validation, data-loss handling, security, accessibility, or required tests. + For Korean prose, preserve facts, identifiers, numbers, and quotes while removing only formulaic filler or translationese. + When Strix evidence supports it, name the concrete CWE/KISA-style class such as injection, auth/authz, secrets, crypto, path traversal/file upload, XSS/CSRF/SSRF, error disclosure, or debug/deployment config; do not invent a category without evidence. + Before APPROVE, the summary must include at least one exact changed file path inspected as changed-file evidence. Never approve with a reason or summary that says no changes, no files, or no actionable changes were found when bounded evidence lists changed files; that control block is invalid. First line exactly: Then exactly one control block: @@ -665,6 +675,11 @@ jobs: $(sed -n '1,900p' "$OPENCODE_EVIDENCE_FILE") + Use CodeGraph for blast-radius, call graph, and test-coverage questions before broad local reads. + Prefer deletion, stdlib/native platform features, and already-installed dependencies before proposing new code or packages, but do not simplify away trust-boundary validation, data-loss handling, security, accessibility, or required tests. + For Korean prose, preserve facts, identifiers, numbers, and quotes while removing only formulaic filler or translationese. + When Strix evidence supports it, name the concrete CWE/KISA-style class such as injection, auth/authz, secrets, crypto, path traversal/file upload, XSS/CSRF/SSRF, error disclosure, or debug/deployment config; do not invent a category without evidence. + Before APPROVE, the summary must include at least one exact changed file path inspected as changed-file evidence. Never approve with a reason or summary that says no changes, no files, or no actionable changes were found when bounded evidence lists changed files; that control block is invalid. First line exactly: Then exactly one control block: diff --git a/scripts/ci/opencode_review_normalize_output.py b/scripts/ci/opencode_review_normalize_output.py index 2a850c6..32145f8 100755 --- a/scripts/ci/opencode_review_normalize_output.py +++ b/scripts/ci/opencode_review_normalize_output.py @@ -4,11 +4,122 @@ from __future__ import annotations import json +import re import sys from pathlib import Path from typing import Any +STRUCTURAL_FAILURE_PHRASES = ( + "structural exploration was not possible", + "structural exploration not possible", + "structural exploration is not required", + "structural exploration not required", + "structural analysis is not required", + "structural analysis not required", + "structural review is not required", + "structural review not required", + "no structural exploration required", + "no structural analysis required", + "no structural review required", + "structural exploration is unnecessary", + "structural analysis is unnecessary", + "structural review is unnecessary", + "changed files could not be inspected", + "source files could not be inspected", + "required files could not be inspected", + "could not access changed files", + "could not access the changed files", + "could not access source files", + "could not access the source files", + "could not access required files", + "could not access required evidence", + "evidence was truncated", + "truncated evidence", + "no changes detected", + "no changes were detected", + "no changes found", + "no changes were found", + "no files or changes were found", + "no files or changes found", + "no actionable changes to review", + "no changes to review", + "no changed files", +) + +STRUCTURAL_FAILURE_PATTERNS = ( + re.compile( + r"\b(?:could not|cannot|can't|unable to)\s+" + r"(?:inspect|access|review)\s+(?:the\s+)?" + r"(?:changed|source|required)\s+files?\b" + ), + re.compile( + r"\b(?:changed|source|required)\s+files?\s+" + r"(?:could not|cannot|can't|were not|was not)\s+" + r"(?:be\s+)?(?:inspected|accessed|reviewed)\b" + ), + re.compile( + r"\b(?:structural\s+(?:exploration|analysis|review))\s+" + r"(?:was\s+)?(?:unavailable|incomplete|blocked|not possible)\b" + ), + re.compile( + r"\bno\s+(?:files?\s+or\s+)?changes?\s+" + r"(?:were\s+)?(?:detected|found|present)\b" + ), + re.compile(r"\bno\s+(?:actionable\s+)?changes?\s+to\s+review\b"), + re.compile(r"\b(?:no|zero)\s+changed\s+files?\b"), +) + +CHANGED_FILE_EVIDENCE_PATTERN = re.compile( + r"(? bool: + """Return whether an approval admits it did not inspect required structure.""" + combined = f"{reason}\n{summary}".casefold() + return any(phrase in combined for phrase in STRUCTURAL_FAILURE_PHRASES) or any( + pattern.search(combined) for pattern in STRUCTURAL_FAILURE_PATTERNS + ) + + +def mentions_changed_file_evidence(reason: str, summary: str) -> bool: + """Return whether an approval names at least one concrete changed file/path.""" + return bool(CHANGED_FILE_EVIDENCE_PATTERN.search(f"{reason}\n{summary}")) + + +def check_structural_approval(control_file: Path) -> int: + """Validate an already-normalized control block before publishing approval.""" + try: + value = json.loads(control_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + print(f"cannot read OpenCode control JSON: {exc}", file=sys.stderr) + return 65 + + if not isinstance(value, dict): + print("NO_CONCLUSION", file=sys.stderr) + return 4 + + if value.get("result") == "APPROVE" and admits_missing_structural_review( + str(value.get("reason", "")), + str(value.get("summary", "")), + ): + print("NO_CONCLUSION", file=sys.stderr) + return 4 + if value.get("result") == "APPROVE" and not mentions_changed_file_evidence( + str(value.get("reason", "")), + str(value.get("summary", "")), + ): + print("NO_CONCLUSION", file=sys.stderr) + return 4 + + return 0 + + def valid_control( value: Any, *, @@ -16,6 +127,7 @@ def valid_control( expected_run_id: str, expected_run_attempt: str, ) -> dict[str, Any] | None: + """Return a normalized control block when it matches the current run.""" if not isinstance(value, dict): return None @@ -34,14 +146,22 @@ def valid_control( return None if not isinstance(value.get("summary"), str) or not value["summary"].strip(): return None + reason = value["reason"].strip() + summary = value["summary"].strip() findings = value.get("findings") + if findings is None and result == "APPROVE": + findings = [] if not isinstance(findings, list): return None if result == "APPROVE" and findings: return None if result == "REQUEST_CHANGES" and not findings: return None + if result == "APPROVE" and admits_missing_structural_review(reason, summary): + return None + if result == "APPROVE" and not mentions_changed_file_evidence(reason, summary): + return None required_finding_fields = ( "path", @@ -56,7 +176,8 @@ def valid_control( for finding in findings: if not isinstance(finding, dict): return None - if not isinstance(finding.get("line"), int) or finding["line"] <= 0: + line = finding.get("line") + if isinstance(line, bool) or not isinstance(line, int) or line <= 0: return None for field in required_finding_fields: if not isinstance(finding.get(field), str) or not finding[field].strip(): @@ -67,13 +188,14 @@ def valid_control( "run_id": value["run_id"], "run_attempt": value["run_attempt"], "result": result, - "reason": value["reason"], - "summary": value["summary"], + "reason": reason, + "summary": summary, "findings": findings, } def iter_json_objects(text: str) -> list[Any]: + """Extract JSON objects from raw OpenCode output that may include prose.""" decoder = json.JSONDecoder() values: list[Any] = [] @@ -96,10 +218,15 @@ def iter_json_objects(text: str) -> list[Any]: def main(argv: list[str]) -> int: + """Run the normalizer CLI and write the publishable control block.""" + if len(argv) == 3 and argv[1] == "--check-structural-approval": + return check_structural_approval(Path(argv[2])) + if len(argv) != 5: print( "usage: opencode_review_normalize_output.py " - " ", + " \n" + " or: opencode_review_normalize_output.py --check-structural-approval ", file=sys.stderr, ) return 64