Files
Atay-Makhzan/.agents/skills/bmad-agent-builder/scripts/render_report.py
T

388 lines
13 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Render the analysis report deterministically from findings JSON.
Injects a validated findings JSON object into the report shell's
report-data island and writes the self-contained HTML atomically.
With --md, also writes a markdown rendering of the same data as the
archival artifact.
Refuses (non-zero exit, message on stderr) when the JSON does not
parse, fails shape validation, or still carries the shell's
placeholder subject — a refused render means fix the findings file
and re-run, never hand-edit the HTML.
Usage:
uv run render_report.py <findings.json> --shell <report-shell.html> \
-o <out.html> [--md <out.md>]
On success prints one JSON line: output paths, grade, and severity
counts derived from the findings array.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import tempfile
from pathlib import Path
SEVERITIES = ("critical", "high", "medium", "low")
GRADES = ("excellent", "good", "fair", "poor")
PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
ISLAND_RE = re.compile(
r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
)
def fail(message: str) -> None:
print(f"render_report: {message}", file=sys.stderr)
sys.exit(1)
def validate(data: object) -> list[str]:
"""Return a list of shape errors; empty list means valid."""
if not isinstance(data, dict):
return ["top level must be a JSON object"]
errors: list[str] = []
subject = data.get("subject")
if not isinstance(subject, str) or not subject.strip():
errors.append('"subject" must be a non-empty string')
elif PLACEHOLDER_SUBJECT in subject:
errors.append(
f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
"this is the unfilled shell sample, not real findings"
)
findings = data.get("findings")
if not isinstance(findings, list):
errors.append('"findings" must be an array (use [] for a clean pass)')
else:
for i, finding in enumerate(findings):
if not isinstance(finding, dict):
errors.append(f"findings[{i}] must be an object")
grade = data.get("grade")
if grade is not None and grade not in GRADES:
errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
for key in ("themes", "recommendations"):
value = data.get(key)
if value is not None and (
not isinstance(value, list)
or any(not isinstance(item, dict) for item in value)
):
errors.append(f'"{key}" must be an array of objects')
strengths = data.get("strengths")
if strengths is not None and (
not isinstance(strengths, list)
or any(not isinstance(item, str) for item in strengths)
):
errors.append('"strengths" must be an array of strings')
return errors
def severity_counts(findings: list[dict]) -> dict[str, int]:
counts = {sev: 0 for sev in SEVERITIES}
for finding in findings:
sev = finding.get("severity")
counts[sev if sev in counts else "low"] += 1
return counts
def inject(shell_html: str, data: dict) -> str:
payload = json.dumps(data, ensure_ascii=False, indent=2)
# A "</" sequence inside a JSON string would close the script tag
# early in the browser; "<\/" is the same string to JSON.parse.
payload = payload.replace("</", "<\\/")
def replace(match: re.Match) -> str:
return match.group(1) + "\n" + payload + "\n" + match.group(3)
new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
if count != 1:
fail('shell has no <script id="report-data"> island to fill')
return new_html
def atomic_write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _finding_lines(finding: dict, heading_level: str) -> list[str]:
fid = str(finding.get("id", ""))
title = str(finding.get("title", "(untitled finding)"))
lines = [f"{heading_level} {fid}{title}" if fid else f"{heading_level} {title}", ""]
for key, label in (
("lens", "Lens"),
("location", "Location"),
("evidence", "Evidence"),
("recommendation", "Recommendation"),
("proposed_smallest", "Proposed smallest"),
("predicted_delta", "Predicted delta"),
):
value = finding.get(key)
if value:
value = f"`{value}`" if key == "location" else str(value)
lines.append(f"- {label}: {value}")
lines.append("")
return lines
def render_md(data: dict) -> str:
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
counts = severity_counts(findings)
lines: list[str] = []
lines.append(f"# Analysis Report: {data.get('subject', '')}")
lines.append("")
meta = []
if data.get("generated"):
meta.append(f"Generated: {data['generated']}")
if data.get("schema_version") is not None:
meta.append(f"Schema: {data['schema_version']}")
if meta:
lines.append(" · ".join(meta))
lines.append("")
if data.get("grade"):
lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
lines.append("")
if data.get("verdict"):
lines.append(f"> {data['verdict']}")
lines.append("")
summary = data.get("summary")
if isinstance(summary, str) and summary:
lines.append(summary)
lines.append("")
lines.append("| Severity | Count |")
lines.append("| --- | --- |")
for sev in SEVERITIES:
lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
lines.append("")
themes = data.get("themes") or []
if themes:
lines.append("## Themes")
lines.append("")
for i, theme in enumerate(themes, 1):
lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
lines.append("")
if theme.get("root_cause"):
lines.append(f"- Root cause: {theme['root_cause']}")
if theme.get("action"):
lines.append(f"- Fix: {theme['action']}")
ids = theme.get("finding_ids") or []
if ids:
lines.append("- Findings:")
for fid in ids:
finding = by_id.get(str(fid))
if finding:
loc = finding.get("location")
suffix = f" — `{loc}`" if loc else ""
lines.append(
f" - `{fid}` {finding.get('title', '')}{suffix}"
)
else:
lines.append(f" - `{fid}`")
lines.append("")
strengths = data.get("strengths") or []
if strengths:
lines.append("## Strengths")
lines.append("")
for strength in strengths:
lines.append(f"- {strength}")
lines.append("")
recommendations = data.get("recommendations") or []
if recommendations:
lines.append("## Recommendations")
lines.append("")
for i, rec in enumerate(recommendations, 1):
rank = rec.get("rank", i)
resolves = rec.get("resolves")
if isinstance(resolves, list) and resolves:
suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
elif isinstance(resolves, (int, float)):
suffix = f" (resolves {int(resolves)} findings)"
else:
suffix = ""
lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
lines.append("")
# Optional agent blocks: rendered only when present so the same
# renderer serves both the workflow and agent schemas.
profile = data.get("agent_profile")
if isinstance(profile, dict) and any(profile.values()):
lines.append("## Agent Profile")
lines.append("")
for key, label in (
("name", "Name"),
("title", "Title"),
("agent_type", "Type"),
("mission", "Mission"),
):
if profile.get(key):
lines.append(f"- {label}: {profile[key]}")
lines.append("")
capabilities = data.get("capabilities")
if isinstance(capabilities, list) and capabilities:
lines.append("## Capabilities")
lines.append("")
for cap in capabilities:
if not isinstance(cap, dict) or not cap.get("name"):
continue
kind = f" ({cap['kind']})" if cap.get("kind") else ""
note = f"{cap['note']}" if cap.get("note") else ""
lines.append(f"- **{cap['name']}**{kind}{note}")
lines.append("")
detailed = data.get("detailed_analysis")
if isinstance(detailed, dict) and detailed:
lines.append("## Per-Lens Verdicts")
lines.append("")
for lens, verdict in detailed.items():
if verdict:
lines.append(f"- **{lens}**: {verdict}")
lines.append("")
sanctum = data.get("sanctum")
if isinstance(sanctum, dict) and sanctum.get("present") is not False:
rows = []
if sanctum.get("location"):
rows.append(f"- Location: `{sanctum['location']}`")
files = sanctum.get("files") or []
if files:
rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
if sanctum.get("note"):
rows.append(f"- Note: {sanctum['note']}")
if rows:
lines.append("## Sanctum (runtime memory)")
lines.append("")
lines.extend(rows)
lines.append("")
experience = data.get("experience")
if isinstance(experience, dict):
journeys = [
j for j in experience.get("journeys") or [] if isinstance(j, dict)
]
headless = experience.get("headless")
if journeys or headless:
lines.append("## Experience")
lines.append("")
for journey in journeys:
steps = f"{journey['steps']}" if journey.get("steps") else ""
lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
if headless:
lines.append(f"- Headless: {headless}")
lines.append("")
lines.append("## Findings")
lines.append("")
if not findings:
lines.append("No findings: the scanners returned a clean pass.")
lines.append("")
else:
for sev in SEVERITIES:
group = [
f
for f in findings
if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
== sev
]
if not group:
continue
lines.append(f"### {sev.capitalize()} ({len(group)})")
lines.append("")
for finding in group:
lines.extend(_finding_lines(finding, "####"))
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
)
parser.add_argument("findings", type=Path, help="path to findings.json")
parser.add_argument(
"--shell", type=Path, required=True, help="path to report-shell.html"
)
parser.add_argument(
"-o", "--output", type=Path, required=True, help="output HTML path"
)
parser.add_argument(
"--md", type=Path, help="also write a markdown rendering to this path"
)
args = parser.parse_args()
try:
raw = args.findings.read_text(encoding="utf-8")
except OSError as err:
fail(f"cannot read {args.findings}: {err}")
try:
data = json.loads(raw)
except json.JSONDecodeError as err:
fail(f"{args.findings} is not valid JSON: {err}")
errors = validate(data)
if errors:
fail(
f"{args.findings} failed shape validation:\n - "
+ "\n - ".join(errors)
)
try:
shell_html = args.shell.read_text(encoding="utf-8")
except OSError as err:
fail(f"cannot read shell {args.shell}: {err}")
atomic_write(args.output, inject(shell_html, data))
if args.md:
atomic_write(args.md, render_md(data))
findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
print(
json.dumps(
{
"html_report": str(args.output),
"md_report": str(args.md) if args.md else None,
"grade": data.get("grade"),
"counts": severity_counts(findings),
"findings": len(findings),
}
)
)
return 0
if __name__ == "__main__":
sys.exit(main())