diff --git a/scripts/review-loop-run b/scripts/review-loop-run index 7dbcd19..205d8d1 100755 --- a/scripts/review-loop-run +++ b/scripts/review-loop-run @@ -57,26 +57,75 @@ _counts_scope() { fi } -# _counts_extract → last matching value for SEV in scope, -# accepting: "SEV: n", "SEV = n", "- SEV: n", "**SEV**: n", "| SEV | n |", -# "SEV (n)", "n SEV". Empty if none found. +# _is_declaration_line → 0 (true) iff the line is a declaration-only +# line. After stripping `*` emphasis markers, the line must consist solely of +# declaration tokens (CRITICAL/HIGH/MEDIUM/LOW, any severity) separated by +# [.,;\s]+, with nothing else except label-style parentheticals matching +# [A-Z][0-9]+ (e.g. "(C1)" labelling the count) which are stripped before the +# check. Non-label parentheticals (finding IDs like "(H-R3-1)", annotations +# like "(see §3.2)", trailing prose after the count) disqualify the line. +_is_declaration_line() { + local line="$1" + # Strip `*` emphasis markers + local clean="${line//\*/}" + # Reduce declaration tokens to placeholders (any severity) + local reduced + reduced=$(printf '%s\n' "$clean" | sed -E 's/(CRITICAL|HIGH|MEDIUM|LOW)[[:space:]]*[:=][[:space:]]*[0-9]+/__TOK__/g') + reduced=$(printf '%s\n' "$reduced" | sed -E 's/(CRITICAL|HIGH|MEDIUM|LOW)[[:space:]]*\([0-9]+\)/__TOK__/g') + reduced=$(printf '%s\n' "$reduced" | sed -E 's/[0-9]+[[:space:]]+(CRITICAL|HIGH|MEDIUM|LOW)/__TOK__/g') + # Strip label-style parentheticals (single uppercase letter + digits) + reduced=$(printf '%s\n' "$reduced" | sed -E 's/[[:space:]]*\([A-Z][0-9]+\)//g') + # Remaining must be only separators + __TOK__s. + # Use a variable for the pattern to keep bash from mis-parsing the + # embedded `;` and `[[:space:]]` inside `[[ =~ ]]`. + local re='^[[:space:].;,]*__TOK__([.,;[:space:]]+__TOK__)*[[:space:].;,]*$' + [[ "$reduced" =~ $re ]] +} + +# _counts_extract → last matching value for SEV in scope. +# CR-7: Table rows accept annotated value cells ("| SEV | n (...) |") — the +# leading integer in the value cell is the count. +# CR-8: Declaration forms ("SEV: n", "SEV = n", "SEV (n)", "n SEV", and their +# bulleted/bold variants) match only on declaration-only lines — a line +# that, after stripping `*` and any leading bullet/heading prefix, +# consists solely of declaration tokens separated by [.,;\s]+ (label- +# style parentheticals like "(C1)" are accepted; other parentheticals +# disqualify the line). _counts_extract() { - local scope="$1" sev="$2" line n - # Strip markdown emphasis markers (bold/italic) — they can appear before - # or after the colon (e.g. "**SEV:**" as well as "**SEV**:"). - local clean; clean=$(sed 's/\*//g' <<<"$scope") - line=$(grep -inE "\\| *${sev} *\\| *[0-9]+ *\\||${sev}[[:space:]]*[:=][[:space:]]*[0-9]+|${sev}[[:space:]]*\\([0-9]+\\)|[0-9]+[[:space:]]+${sev}\\b" <<<"$clean" | tail -1) || true - [[ -z "$line" ]] && { echo ""; return; } - if [[ "$line" =~ \|[[:space:]]*${sev}[[:space:]]*\|[[:space:]]*([0-9]+)[[:space:]]*\| ]]; then - n="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ${sev}[[:space:]]*[:=][[:space:]]*([0-9]+) ]]; then - n="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ${sev}[[:space:]]*\(([0-9]+)\) ]]; then - n="${BASH_REMATCH[1]}" - elif [[ "$line" =~ ([0-9]+)[[:space:]]+${sev} ]]; then - n="${BASH_REMATCH[1]}" + local scope="$1" sev="$2" + # CR-7: Table row branch (exempt from declaration-only rule). + # Accept "| SEV | n |" and "| SEV | n () |". + local table_match + table_match=$(grep -inE "\\|[[:space:]]*${sev}[[:space:]]*\\|[[:space:]]*[0-9]+[^|]*\\|" <<<"$scope" | tail -1) || true + if [[ -n "$table_match" ]]; then + if [[ "$table_match" =~ \|[[:space:]]*${sev}[[:space:]]*\|[[:space:]]*([0-9]+)[^|]*\| ]]; then + echo "${BASH_REMATCH[1]}" + return + fi fi - echo "${n:-}" + # CR-8: Declaration line branch — only lines that pass _is_declaration_line + # contribute; across all such lines the LAST match wins. + local last_n="" + while IFS= read -r line; do + [[ -z "$line" ]] && continue + # Strip leading bullet (-, *, +) or heading (#+) prefix + local stripped + stripped=$(printf '%s\n' "$line" | sed -E 's/^[[:space:]]*([-*+]+|#{1,6})[[:space:]]*//') + # Reject the line if it's not declaration-only + _is_declaration_line "$stripped" || continue + # Strip `*` markers for value extraction + local clean_line="${stripped//\*/}" + local n="" + if [[ "$clean_line" =~ (^|[^[:alnum:]])${sev}[[:space:]]*[:=][[:space:]]*([0-9]+) ]]; then + n="${BASH_REMATCH[2]}" + elif [[ "$clean_line" =~ (^|[^[:alnum:]])${sev}[[:space:]]*\(([0-9]+)\) ]]; then + n="${BASH_REMATCH[2]}" + elif [[ "$clean_line" =~ ([0-9]+)[[:space:]]+${sev}([^[:alnum:]]|$) ]]; then + n="${BASH_REMATCH[1]}" + fi + if [[ -n "$n" ]]; then last_n="$n"; fi + done <<<"$scope" + echo "$last_n" } # counts → prints CRITICAL=n HIGH=n MEDIUM=n LOW=n ; exit 4 if C+H>0 @@ -103,6 +152,16 @@ counts() { n=$(grep -cE "^[[:space:]]*(#+[[:space:]]*)?(\*\*)?${sev}(\*\*)?[[:space:]]*(:|—|-)" "$f") || n=0 vals[$sev]="$n" done + # CR-9: Suspect derived zero. If the derived fallback ran and yielded + # CRITICAL=0 and HIGH=0, but the file contains a CRITICAL/HIGH section + # header anywhere (case-insensitive), the derived count is untrustworthy + # — mark "(suspect)" instead of "(derived)" and force exit 4. Suspect + # and genuine C+H>0 share exit 4; distinguish via the marker. + if [[ "${vals[CRITICAL]:-0}" -eq 0 && "${vals[HIGH]:-0}" -eq 0 ]]; then + if grep -qiE "^#{1,6}[[:space:]]*(\*\*)?(CRITICAL|HIGH)\b" "$f"; then + derived=" (suspect)" + fi + fi fi shopt -u nocasematch @@ -113,7 +172,7 @@ counts() { if [[ "$sev" == "CRITICAL" || "$sev" == "HIGH" ]]; then total=$((total + n)); fi done echo "${out% }${derived}" - if [[ "$total" -gt 0 ]]; then return 4; fi + if [[ "$total" -gt 0 || "$derived" == " (suspect)" ]]; then return 4; fi return 0 }