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