108 lines
4.0 KiB
Python
108 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
||
"""Validate UTF-8 Markdown reports that summarize one or more PRs."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
PR_HEADING = re.compile(r"^## PR #(\d+)(?:\s.*)?$")
|
||
CARD_PATTERN = re.compile(
|
||
r"^\*\*([^*\n]+):\*\*\s*"
|
||
r"<span\b[^>]*><strong>([^<]+)</strong></span>\s*"
|
||
r"\*\*\[([^\]]+)\]\*\*:\s*(\S.*)$"
|
||
)
|
||
MOJIBAKE_PATTERNS = ("\ufffd", "\x00", "\x1b")
|
||
|
||
|
||
def parse_pr_sections(lines: list[str]) -> dict[int, list[str]]:
|
||
sections: dict[int, list[str]] = {}
|
||
current: int | None = None
|
||
for line in lines:
|
||
match = PR_HEADING.match(line.strip())
|
||
if match:
|
||
current = int(match.group(1))
|
||
sections.setdefault(current, [])
|
||
continue
|
||
if current is not None and line.startswith("## "):
|
||
current = None
|
||
elif current is not None:
|
||
sections[current].append(line)
|
||
return sections
|
||
|
||
|
||
def validate_report(
|
||
text: str,
|
||
required_prs: list[int] | None = None,
|
||
min_cards: int = 2,
|
||
required_aspects: list[str] | None = None,
|
||
) -> list[str]:
|
||
errors: list[str] = []
|
||
for marker in MOJIBAKE_PATTERNS:
|
||
if marker in text:
|
||
errors.append(f"report contains invalid encoding marker: {marker!r}")
|
||
if re.search(r"\?{4,}", text):
|
||
errors.append("report contains repeated question marks indicating encoding loss")
|
||
if len(re.findall(r"[\u3400-\u9fff]", text)) < 40:
|
||
errors.append("report does not contain enough readable Chinese narrative")
|
||
|
||
sections = parse_pr_sections(text.splitlines())
|
||
targets = required_prs or sorted(sections)
|
||
if not targets:
|
||
return errors + ["report contains no '## PR #<number>' section"]
|
||
for number in targets:
|
||
if number not in sections:
|
||
errors.append(f"missing PR section: #{number}")
|
||
continue
|
||
cards = []
|
||
for line in sections[number]:
|
||
match = CARD_PATTERN.match(line.strip())
|
||
if not match:
|
||
continue
|
||
aspect, conclusion, status, rationale = match.groups()
|
||
cards.append((aspect, conclusion, status, rationale))
|
||
if len(rationale) < 20:
|
||
errors.append(f"PR #{number} aspect '{aspect}' explanation is too short")
|
||
if "依据:" not in rationale:
|
||
errors.append(f"PR #{number} aspect '{aspect}' is missing explicit evidence")
|
||
if any(token.startswith("<") and token.endswith(">") for token in (conclusion, status)):
|
||
errors.append(f"PR #{number} aspect '{aspect}' contains a placeholder")
|
||
if len(cards) < min_cards:
|
||
errors.append(f"PR #{number} has {len(cards)} valid aspect card(s), need at least {min_cards}")
|
||
aspects = [card[0] for card in cards]
|
||
if len(set(aspects)) != len(aspects):
|
||
errors.append(f"PR #{number} contains duplicate aspect cards")
|
||
for aspect in required_aspects or []:
|
||
if aspect not in aspects:
|
||
errors.append(f"PR #{number} is missing required aspect card: {aspect}")
|
||
return errors
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Validate per-PR aspect cards.")
|
||
parser.add_argument("--report", required=True, type=Path)
|
||
parser.add_argument("--require-pr", action="append", type=int, default=[])
|
||
parser.add_argument("--min-cards", type=int, default=2)
|
||
parser.add_argument("--required-aspect", action="append", default=[])
|
||
args = parser.parse_args()
|
||
try:
|
||
text = args.report.read_text(encoding="utf-8", errors="strict")
|
||
except (OSError, UnicodeError) as exc:
|
||
print(f"PR card validation failed: {exc}", file=sys.stderr)
|
||
return 2
|
||
errors = validate_report(text, args.require_pr, args.min_cards, args.required_aspect)
|
||
if errors:
|
||
print("PR card validation failed:", file=sys.stderr)
|
||
for error in errors:
|
||
print(f"- {error}", file=sys.stderr)
|
||
return 1
|
||
print("PR card validation passed")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|