| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- #!/usr/bin/env python3
- # /// script
- # requires-python = ">=3.10"
- # ///
- """Render a PRD validation findings JSON into HTML + markdown reports.
- Reads structured findings produced by the validator subagent, groups them by
- category (explicit `category` field, else derived from ID prefix), computes a
- pass/warn/fail summary and grade, substitutes into the configured HTML
- template, writes a markdown companion at the same path with `.md` extension,
- and optionally opens the HTML in the default browser.
- """
- import argparse
- import html
- import json
- import string
- import sys
- import webbrowser
- from datetime import datetime
- from pathlib import Path
- CATEGORY_FROM_PREFIX = {
- "Q": "Quality",
- "D": "Discipline",
- "S": "Structural integrity",
- "STK": "Stakes-gated",
- "M": "Mechanical",
- }
- CATEGORY_ORDER = ["Quality", "Discipline", "Structural integrity", "Stakes-gated", "Mechanical"]
- def category_for(finding: dict) -> str:
- explicit = finding.get("category")
- if explicit:
- return explicit
- fid = finding.get("id", "")
- prefix = fid.split("-", 1)[0] if "-" in fid else fid
- return CATEGORY_FROM_PREFIX.get(prefix, prefix or "Other")
- def compute_stats(findings: list[dict]) -> dict:
- total = len(findings)
- by_status = {"pass": 0, "warn": 0, "fail": 0, "n/a": 0}
- failed_critical = 0
- failed_high = 0
- for f in findings:
- status = (f.get("status") or "n/a").lower()
- if status in by_status:
- by_status[status] += 1
- if status == "fail":
- sev = (f.get("severity") or "low").lower()
- if sev == "critical":
- failed_critical += 1
- elif sev == "high":
- failed_high += 1
- return {
- "total": total,
- "passed": by_status["pass"],
- "warned": by_status["warn"],
- "failed": by_status["fail"],
- "na": by_status["n/a"],
- "failed_critical": failed_critical,
- "failed_high": failed_high,
- }
- def grade_from(stats: dict) -> tuple[str, str]:
- if stats["failed_critical"] > 0:
- return "Poor", "grade-poor"
- if stats["failed_high"] >= 1 or stats["failed"] >= 4:
- return "Fair", "grade-fair"
- if stats["failed"] > 0 or stats["warned"] > 2:
- return "Good", "grade-good"
- return "Excellent", "grade-excellent"
- def render_score_bar(stats: dict, width: int = 480, height: int = 22) -> str:
- total = max(stats["total"], 1)
- p = stats["passed"] / total * width
- w = stats["warned"] / total * width
- f = stats["failed"] / total * width
- n = stats["na"] / total * width
- return (
- f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" '
- f'aria-label="Pass / warn / fail / n-a breakdown">'
- f'<rect x="0" y="0" width="{p:.1f}" height="{height}" fill="#22c55e"/>'
- f'<rect x="{p:.1f}" y="0" width="{w:.1f}" height="{height}" fill="#eab308"/>'
- f'<rect x="{p + w:.1f}" y="0" width="{f:.1f}" height="{height}" fill="#ef4444"/>'
- f'<rect x="{p + w + f:.1f}" y="0" width="{n:.1f}" height="{height}" fill="#94a3b8"/>'
- f"</svg>"
- )
- def render_finding(f: dict) -> str:
- status = (f.get("status") or "n/a").lower()
- severity = (f.get("severity") or "low").lower()
- fid = html.escape(f.get("id") or "")
- title = html.escape(f.get("title") or fid)
- location = html.escape(f.get("location") or "")
- note = html.escape(f.get("note") or "")
- fix = html.escape(f.get("suggested_fix") or "")
- status_class = "na" if status == "n/a" else status
- parts = [
- f'<article class="finding finding-{status_class}">',
- '<header>',
- f'<span class="badge badge-status badge-{status_class}">{status.upper()}</span>',
- f'<span class="badge badge-severity badge-sev-{severity}">{severity}</span>',
- f'<span class="finding-id">{fid}</span>',
- f'<h3 class="finding-title">{title}</h3>',
- '</header>',
- ]
- if location:
- parts.append(f'<div class="finding-location"><strong>Location:</strong> {location}</div>')
- if note:
- parts.append(f'<div class="finding-note">{note}</div>')
- if fix:
- parts.append(f'<div class="finding-fix"><strong>Suggested fix:</strong> {fix}</div>')
- parts.append("</article>")
- return "\n".join(parts)
- def render_category(name: str, findings: list[dict]) -> str:
- items = "\n".join(render_finding(f) for f in findings)
- name_e = html.escape(name)
- return (
- f'<section class="category">'
- f"<details open>"
- f'<summary><h2>{name_e} <span class="count">({len(findings)})</span></h2></summary>'
- f"{items}"
- f"</details>"
- f"</section>"
- )
- SEVERITY_ORDER = ["critical", "high", "medium", "low"]
- def render_finding_md(f: dict) -> str:
- status = (f.get("status") or "n/a").upper()
- severity = (f.get("severity") or "low").lower()
- fid = f.get("id") or ""
- title = f.get("title") or fid
- location = f.get("location") or ""
- note = f.get("note") or ""
- fix = f.get("suggested_fix") or ""
- lines = [f"### [{status}] {fid} — {title} _(severity: {severity})_"]
- if location:
- lines.append(f"- **Location:** {location}")
- if note:
- lines.append(f"- **Finding:** {note}")
- if fix:
- lines.append(f"- **Suggested fix:** {fix}")
- return "\n".join(lines)
- def render_markdown_report(data: dict, findings: list[dict], stats: dict, grade: str) -> str:
- prd_name = data.get("prd_name") or "PRD"
- prd_path = data.get("prd_path") or ""
- checklist_path = data.get("checklist_path") or ""
- timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
- synthesis = data.get("overall_synthesis") or ""
- out = [
- f"# Validation Report — {prd_name}",
- "",
- f"- **PRD:** `{prd_path}`",
- f"- **Checklist:** `{checklist_path}`",
- f"- **Run at:** {timestamp}",
- f"- **Grade:** {grade}",
- "",
- f"**Summary:** {stats['passed']} pass · {stats['warned']} warn · {stats['failed']} fail · {stats['na']} n/a "
- f"(total {stats['total']}; critical fails: {stats['failed_critical']}, high fails: {stats['failed_high']})",
- ]
- if synthesis:
- out += ["", "## Overall synthesis", "", synthesis]
- # Group by severity then status: failed criticals first, then highs, etc.
- by_sev: dict[str, list[dict]] = {s: [] for s in SEVERITY_ORDER}
- other: list[dict] = []
- for f in findings:
- sev = (f.get("severity") or "low").lower()
- if sev in by_sev:
- by_sev[sev].append(f)
- else:
- other.append(f)
- out += ["", "## Findings by severity"]
- any_findings = False
- for sev in SEVERITY_ORDER:
- items = by_sev[sev]
- if not items:
- continue
- any_findings = True
- out += ["", f"### {sev.capitalize()} ({len(items)})", ""]
- out += [render_finding_md(f) for f in items]
- if other:
- any_findings = True
- out += ["", f"### Other ({len(other)})", ""]
- out += [render_finding_md(f) for f in other]
- if not any_findings:
- out += ["", "_No findings._"]
- return "\n".join(out) + "\n"
- def main(argv: list[str]) -> int:
- parser = argparse.ArgumentParser(description="Render PRD validation findings to HTML.")
- parser.add_argument("--findings", required=True, help="Path to validation-findings.json")
- parser.add_argument("--template", required=True, help="Path to HTML template")
- parser.add_argument("--output", required=True, help="Path to write the rendered HTML")
- parser.add_argument("--open", action="store_true", help="Open the rendered HTML in the default browser")
- args = parser.parse_args(argv)
- findings_path = Path(args.findings)
- template_path = Path(args.template)
- output_path = Path(args.output)
- try:
- data = json.loads(findings_path.read_text(encoding="utf-8"))
- except FileNotFoundError:
- print(f"error: findings file not found: {findings_path}", file=sys.stderr)
- return 1
- except json.JSONDecodeError as e:
- print(f"error: findings file is not valid JSON ({findings_path}): {e}", file=sys.stderr)
- return 1
- try:
- template = template_path.read_text(encoding="utf-8")
- except FileNotFoundError:
- print(f"error: template file not found: {template_path}", file=sys.stderr)
- return 1
- findings = data.get("findings", []) or []
- by_cat: dict[str, list[dict]] = {}
- for f in findings:
- by_cat.setdefault(category_for(f), []).append(f)
- sorted_cats = sorted(
- by_cat.keys(),
- key=lambda c: (CATEGORY_ORDER.index(c) if c in CATEGORY_ORDER else 99, c),
- )
- categories_html = "\n".join(render_category(c, by_cat[c]) for c in sorted_cats)
- stats = compute_stats(findings)
- grade, grade_class = grade_from(stats)
- score_svg = render_score_bar(stats)
- timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
- substitutions = {
- "prd_name": html.escape(str(data.get("prd_name") or "PRD")),
- "prd_path": html.escape(str(data.get("prd_path") or "")),
- "checklist_path": html.escape(str(data.get("checklist_path") or "")),
- "timestamp": html.escape(timestamp),
- "overall_synthesis": html.escape(str(data.get("overall_synthesis") or "")),
- "grade": grade,
- "grade_class": grade_class,
- "total": str(stats["total"]),
- "passed": str(stats["passed"]),
- "failed": str(stats["failed"]),
- "warned": str(stats["warned"]),
- "na": str(stats["na"]),
- "score_svg": score_svg,
- "categories_html": categories_html,
- }
- rendered = string.Template(template).safe_substitute(substitutions)
- output_path.write_text(rendered, encoding="utf-8")
- md_path = output_path.with_suffix(".md")
- md_path.write_text(render_markdown_report(data, findings, stats, grade), encoding="utf-8")
- print(json.dumps({
- "output": str(output_path),
- "markdown": str(md_path),
- "grade": grade,
- "stats": stats,
- }))
- if args.open:
- webbrowser.open(output_path.resolve().as_uri())
- return 0
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|