gitlink-cli/skills/gitlink-shared/scripts/validate_pr_cards.py

108 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())