Improve the logic for reporting warnings and errors to
avoid flagging good patches as suspicious.

Signed-off-by: Stephen Hemminger <[email protected]>
---
 devtools/ai/review-patch.py | 232 +++++++++++++++++++++++++++++-------
 1 file changed, 186 insertions(+), 46 deletions(-)

diff --git a/devtools/ai/review-patch.py b/devtools/ai/review-patch.py
index 5f8d9ed772..9b5b36f40d 100755
--- a/devtools/ai/review-patch.py
+++ b/devtools/ai/review-patch.py
@@ -19,7 +19,6 @@
 from email.message import EmailMessage
 from pathlib import Path
 from typing import Any, Iterator
-from enum import Flag, auto
 
 from _common import (
     PROVIDERS,
@@ -69,19 +68,31 @@
 - Be conservative: reject changes that aren't clearly bug fixes"""
 
 FORMAT_INSTRUCTIONS = {
-    "text": """Provide your review in plain text format.""",
+    "text": """Provide your review in plain text format.
+
+End the review with a single final line, exactly:
+Review-Result: CLEAN|WARNING|ERROR
+CLEAN means no Errors and no Warnings. This line is parsed by CI.""",
     "markdown": """Provide your review in Markdown format with:
 - Headers (##) for each severity level (Errors, Warnings, Info)
 - Bullet points for individual issues
 - Code blocks (```) for code references
-- Bold (**) for emphasis on key points""",
+- Bold (**) for emphasis on key points
+
+End the review with a single final line, exactly:
+Review-Result: CLEAN|WARNING|ERROR
+CLEAN means no Errors and no Warnings. This line is parsed by CI.""",
     "html": """Provide your review in HTML format with:
 - <h2> tags for each severity level (Errors, Warnings, Info)
 - <ul>/<li> for individual issues
 - <pre><code> for code references
 - <strong> for emphasis on key points
 - Use appropriate semantic HTML tags
-- Do NOT include <html>, <head>, or <body> tags - just the content""",
+- Do NOT include <html>, <head>, or <body> tags - just the content
+
+End the review with a single final line, exactly:
+Review-Result: CLEAN|WARNING|ERROR
+CLEAN means no Errors and no Warnings. This line is parsed by CI.""",
     "json": """Provide your review in JSON format with this structure:
 {
   "summary": "Brief one-line summary of the review",
@@ -122,10 +133,134 @@
 EXIT_ERRORS = 3
 
 
-class ReviewParseState(Flag):
-    NORMAL = auto()
-    IN_ERROR = auto()
-    IN_WARNING = auto()
+# Explicit machine-readable verdict; authoritative when present.
+# Quoted lines ('>') are excluded: a patch that adds a Review-Result line to a
+# document or test fixture must not be able to dictate its own verdict.
+_RESULT_RE = re.compile(
+    r"^[\s*#-]*review-result:\s*[*`\s]*(clean|warning|error)s?\b", re.MULTILINE
+)
+_RESULT_EXIT = {"clean": EXIT_CLEAN, "warning": EXIT_WARNINGS, "error": 
EXIT_ERRORS}
+
+# A severity section header: the whole line is the word plus decoration.
+#   "## Errors"  "**Warnings**"  "<h2>Errors</h2>"  "ERRORS:"  "Error (must 
fix):"
+_HEADER_RE = re.compile(
+    r"^(?:#{1,6}\s*)?(?:<h[1-6]>\s*)?[*_`]{0,2}"
+    r"(error|warning)s?"
+    r"[*_`]{0,2}\s*(?:\((?:must|should) fix\))?\s*:?\s*(?:</h[1-6]>)?$"
+)
+
+# An inline finding marker: "Error: foo", "- **Warning** - bar".
+# The remainder is captured so that "Errors: none" is recognised as filler
+# rather than counted as a finding.
+_INLINE_RE = re.compile(
+    r"^[-*\s]*[*_`]{0,2}(error|warning)s?[*_`]{0,2}\s*[:\u2013-]\s*(\S.*)$"
+)
+
+# Text meaning "this section is empty". Deliberately a closed vocabulary:
+# anything else under a severity header counts as a finding.
+_EMPTY_RE = re.compile(
+    r"^[-*\s]*[*_`(\[]*\s*"
+    r"(?:none|nil|n/?a"
+    r"|no\s+(?:errors?|warnings?|issues?|problems?|findings?|concerns?"
+    r"|correctness\s+bugs?|changes?\s+(?:required|needed)))"
+    r"[\w\s]{0,24}[.!]?\s*[*_`)\]]*$"
+)
+
+_RULE_CHARS = set("-=_*# \t")
+
+# Code block delimiters. The markdown instructions ask for code references, so 
a
+# quoted compiler diagnostic or RTE_LOG(WARNING, ...) must not read as a 
finding.
+_FENCE_RE = re.compile(r"^(?:```|~~~)")
+
+
+def _asserted_lines(review_text: str) -> Iterator[str]:
+    """Yield the lines a review asserts, dropping the ones it quotes.
+
+    Code fences and '>' context carry text the review is talking *about*
+    rather than claiming, so neither may open a section or supply a verdict.
+    Indentation is left to the caller: an indented line may be a code sample,
+    but it may equally be a finding nested under a severity header.
+    """
+    in_fence = False
+
+    for line in review_text.splitlines():
+        if _FENCE_RE.match(line.strip()):
+            in_fence = not in_fence
+            continue
+        if in_fence:
+            continue
+        if line.lstrip().startswith(">"):
+            continue
+        yield line
+
+
+def strip_quoted_blocks(review_text: str) -> str:
+    """Return the text that may supply a verdict line.
+
+    Indented blocks are dropped here: a review that shows an example
+    Review-Result line must not be able to dictate its own verdict.
+    """
+    return "\n".join(
+        line
+        for line in _asserted_lines(review_text)
+        if not line.startswith("    ") and not line.startswith("\t")
+    )
+
+
+def scan_review_prose(review_text: str) -> tuple[bool, bool]:
+    """Scan free-form review text for findings.
+
+    A severity header only counts if the section under it holds something
+    other than "none". Returns (has_errors, has_warnings).
+    """
+    has_errors = False
+    has_warnings = False
+    section: str | None = None
+
+    for line in _asserted_lines(review_text):
+        stripped = line.strip().lower()
+
+        # Blank lines, horizontal rules and patch context are inert:
+        # they neither open nor close a section.
+        if (
+            not stripped
+            or set(stripped) <= _RULE_CHARS
+            or stripped.startswith("diff --git")
+        ):
+            continue
+
+        # An indented line may be an unfenced code sample, so it cannot open
+        # a section or stand alone as a finding.  Under an open header it is
+        # still content: a bullet nested under "## Errors" is a finding.
+        if not line.startswith("    ") and not line.startswith("\t"):
+            match = _INLINE_RE.match(stripped)
+            if match:
+                if not _EMPTY_RE.match(match.group(2)):
+                    if match.group(1) == "error":
+                        has_errors = True
+                    else:
+                        has_warnings = True
+                section = None
+                continue
+
+            match = _HEADER_RE.match(stripped)
+            if match:
+                section = match.group(1)
+                continue
+
+        if section is None:
+            continue
+
+        # Strip HTML tags so "<p>None identified.</p>" reads as filler.
+        text = re.sub(r"<[^>]+>", " ", stripped).strip()
+        if not _EMPTY_RE.match(text):
+            if section == "error":
+                has_errors = True
+            else:
+                has_warnings = True
+        section = None
+
+    return has_errors, has_warnings
 
 
 def classify_review(review_text: str, output_format: str) -> int:
@@ -136,12 +271,37 @@ def classify_review(review_text: str, output_format: str) 
-> int:
         2 - warnings found (no errors)
         3 - errors found
     """
+    # 1. Explicit verdict line wins. Combined reviews carry one per section;
+    #    the worst result decides. Quoted material cannot supply one.
+    verdicts = _RESULT_RE.findall(strip_quoted_blocks(review_text).lower())
+    if verdicts:
+        return max(_RESULT_EXIT[v] for v in verdicts)
+
     has_errors = False
     has_warnings = False
 
+    # 2. Structured JSON.
     if output_format == "json":
         try:
             data = json.loads(review_text)
+            # Combined reviews nest one sub-review per patch or chunk, each
+            # still in its own format. Classify each and keep the worst.
+            sections = data.get("sections")
+            if isinstance(sections, list):
+                worst = EXIT_CLEAN
+                classified = False
+                for entry in sections:
+                    if not isinstance(entry, dict):
+                        continue
+                    review = entry.get("review")
+                    if isinstance(review, str):
+                        classified = True
+                        worst = max(worst, classify_review(review, 
output_format))
+                # An empty or unrecognised "sections" value is not a verdict;
+                # fall through to the top-level keys rather than report clean.
+                if classified:
+                    return worst
+
             if data.get("errors"):
                 has_errors = True
             if data.get("warnings"):
@@ -154,44 +314,9 @@ def classify_review(review_text: str, output_format: str) 
-> int:
         except (json.JSONDecodeError, AttributeError):
             pass  # Fall through to text scanning
 
+    # 3. Fallback: scan prose.
     if not has_errors and not has_warnings:
-        # Matches against error or warning section headers
-        rgx_header_match: str = r"(#+\s)?(\*+)?(<h[1-3]>)?{err_or_warn}"
-        # Matches against observed filler text
-        rgx_filler_match: str = r"(none(.)?$|\(must fix\)$|$)"
-        curr_state: ReviewParseState = ReviewParseState.NORMAL
-
-        curr_line: str
-        for curr_line in review_text.splitlines():
-            stripped: str = curr_line.strip().lower()
-
-            if (
-                stripped.startswith(">")
-                or stripped.startswith("diff --git")
-                or stripped == ""
-            ):
-                continue
-
-            elif re.match(rgx_header_match.format(err_or_warn="error"), 
stripped):
-                curr_state = ReviewParseState.IN_ERROR
-
-            elif re.match(rgx_header_match.format(err_or_warn="warning"), 
stripped):
-                curr_state = ReviewParseState.IN_WARNING
-
-            elif curr_state == ReviewParseState.IN_ERROR and not re.match(
-                rgx_filler_match, stripped
-            ):
-                curr_state = ReviewParseState.NORMAL
-                has_errors = True
-
-            elif curr_state == ReviewParseState.IN_WARNING and not re.match(
-                rgx_filler_match, stripped
-            ):
-                curr_state = ReviewParseState.NORMAL
-                has_warnings = True
-
-            else:
-                curr_state = ReviewParseState.NORMAL
+        has_errors, has_warnings = scan_review_prose(review_text)
 
     if has_errors:
         return EXIT_ERRORS
@@ -1164,7 +1289,10 @@ def main() -> None:
     if args.verbose:
         print("=== Request ===", file=sys.stderr)
         print(f"Provider: {args.provider}", file=sys.stderr)
-        print(f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}", 
file=sys.stderr)
+        print(
+            f"Auth method: {'vertex' if auth == 'vertex' else 'direct'}",
+            file=sys.stderr,
+        )
         print(f"Model: {model}", file=sys.stderr)
         print(f"Review date: {review_date}", file=sys.stderr)
         if args.release:
@@ -1351,6 +1479,18 @@ def main() -> None:
             print("", file=sys.stderr)
             print(f"Review sent to: {', '.join(args.to_addrs)}", 
file=sys.stderr)
 
+    # The format instructions require a final Review-Result line.  Without it
+    # the severity below is inferred from prose, which is a guess -- and the
+    # usual cause is a response truncated before the review was finished.
+    if args.output_format != "json" and not _RESULT_RE.search(
+        strip_quoted_blocks(review_text).lower()
+    ):
+        print(
+            "warning: review has no Review-Result line, "
+            "severity inferred from prose (response may be truncated)",
+            file=sys.stderr,
+        )
+
     # Exit with code based on review severity
     sys.exit(classify_review(review_text, args.output_format))
 
-- 
2.53.0

Reply via email to