claude-skills/markdown-html/skills/md-review/scripts/review_html_renderer.py
Claude 8f6734a205
feat(markdown-html): add md-review v2.10.2 — code-review markdown→2-col HTML
Adds the fourth skill to the markdown-html/ domain. The Tier-2 use case
from Shihipar's essay ("Code Review and PR Writeups"): a markdown PR
writeup with ```diff blocks and severity callouts becomes a single-file
2-column HTML review with top jump-nav, diff on the left, severity-tagged
annotation cards on the right, and a mandatory named reviewer footer.

Three stdlib tools pipeline together:

1. diff_parser.py — scans markdown for ```diff fenced blocks, parses each
   as a unified diff (--- a/file, +++ b/file, @@ -10,7 +10,8 @@,
   space/+/- body lines), assigns per-line numbers on both old (lo) and
   new (ln) sides, preserves the per-hunk @@ header context. Supports
   --infer-diff for unfenced blocks. Stdlib regex + state machine.

2. annotation_extractor.py — extracts severity callouts (GFM
   > [!BLOCKER] style) and inline markers (nit:, blocker:, etc.).
   Default convention BLOCKER/MAJOR/MINOR/NIT per Google's Code Review
   Developer Guide; overridable via --severity-convention. Attaches each
   annotation to the nearest preceding diff block by source-line index;
   unanchored annotations go to a "general comments" section. Also
   captures LGTM/approve markers separately as approvals.

3. review_html_renderer.py — emits single-file 2-col HTML. Top jump-nav
   lists every annotation with severity badge + 80-char preview + jump
   link + per-tier counts in heading. Each hunk-row is a CSS grid with
   diff on the left (per-line numbers, +/- marks, addition/deletion bg
   tints from --md-success/--md-warn via color-mix) and annotation cards
   on the right. WCAG-1.4.1-compliant severity badges (color + icon +
   aria-label + text — color is NEVER the sole signal); BLOCKER danger
   color computed by hue-rotating the design-system accent 120° toward
   red so it stays brand-coherent. Approval bar when LGTM markers
   present and no findings. Collapses to stacked on viewports < 900px.
   Mandatory --reviewer (refuses exit 3 otherwise — research-ops
   named-owner discipline). Refuses exit 4 if no hunks present (wrong
   skill → route to md-document). No Prism CDN (diff coloring conflicts
   with syntax highlighting).

Plus 3 references each citing 5-7 sources:
  - diff_rendering_canon.md — POSIX diff format + GitHub/GitLab UI +
    difftastic + SWE at Google ch. 9
  - severity_coding.md — WCAG 1.4.1 + Google review taxonomy + Don
    Norman Design of Everyday Things + NN/g color UX
  - pr_annotation_ux.md — convergent 2-col UX from GitHub / GitLab /
    Reviewable / CodeStream + SWE at Google + NN/g F-shape
1 template asset documenting the canonical 2-col review HTML shape.
/cs:md-review slash command with 4 pre-flight gates
(under-100-lines, no-onboarding, missing-reviewer, no-hunks) + pipeline
+ output digest.

Repo-level updates:
- markdown-html/.claude-plugin/plugin.json: skills array adds
  ./skills/md-review; version 2.10.1 → 2.10.2; description updated.
- .claude-plugin/marketplace.json: markdown-html-skills entry version
  and description; top-level counters 341 → 342 skills, 542 → 545 Python
  tools, 685 → 688 references, 88 → 89 slash commands; metadata.version
  2.10.1 → 2.10.2.
- Root CLAUDE.md: v2.10.2 release-notes block above v2.10.1.

Validation:
- check_plugin_json.py → OK
- sync-codex-skills.py --dry-run → 1 new symlink, documentation: 4 skills,
  total 343
- skill_description_validator.py → PASS (all 5 checks: present, length,
  third-person, trigger, action verb)
- skill_review_checklist_runner.py → 5/6 PASS (97 lines passes
  under-100-lines check; minor WARN on "user" vs "developer" terminology
  which are contextually distinct — converter operator vs review subject)
- All 3 tools pass --help and --sample
- Hard rules verified end-to-end:
    no --reviewer → exit 3 with refusal message
    no hunks → exit 4 with refusal message + md-document routing hint
    custom severity convention "critical,important,suggestion,nit" works
- Full pipeline on sample PR (2 diff blocks, 2 callouts) produces 11.3 KB
  single-file HTML with all 14 expected components (reviewer footer, PR
  title, aria-labels per WCAG 1.4.1, --md-danger computed color, modern
  color-mix tints, 2-col grid, 900px responsive collapse, both file
  paths, annotation cards, jump-nav, addition+deletion classes, Findings
  heading).

Coming in v2.10.3: md-slides — slide splitter + presenter-notes parser
+ arrow-key/space-bar nav + @media print for PDF export. Reuses
md-document's renderer scaffolding + design-system/scripts/config_loader.py.

https://claude.ai/code/session_01BK2KoQot1U7J5oSosrCQdc
2026-06-03 05:43:45 +00:00

605 lines
22 KiB
Python
Raw Permalink 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
"""review_html_renderer.py - Render parsed diffs + annotations into a 2-column HTML review.
Stdlib-only. Combines:
- diff_parser.py output (hunks per file)
- annotation_extractor.py output (severity-tagged margin notes)
- design-system config (12-token palette + typography + design_style)
into a single-file HTML page with:
* Top jump-nav: every annotation listed with severity badge + file:line + 1-line preview
(Click jumps to the annotation in the right margin and highlights the hunk)
* 2-column layout: diff on left, annotation cards on right
(Falls back to stacked layout on viewports < 900px)
* Per-line diff coloring: additions in success-tinted bg, deletions in warn-tinted bg
* Severity badges: icon + color + aria-label (WCAG 1.4.1 — color is NOT the sole signal)
* Mandatory "Reviewer:" footer (refuses to render without --reviewer)
NO LLM CALLS. Pure templating + config-driven CSS.
Single-file output: all CSS + (optional) JS inline. Only external is Google
Fonts CSS for typography (same discipline as md-document; no Prism here —
we render diff coloring ourselves with stable conventions).
Usage:
python review_html_renderer.py \\
--diff-blocks hunks.json \\
--annotations annotations.json \\
--reviewer "Jane Doe" \\
--output review.html
python review_html_renderer.py --sample --reviewer "Sample Reviewer" --output /tmp/review.html
"""
from __future__ import annotations
import argparse
import html
import json
import os
import sys
from pathlib import Path
from typing import Any
_DESIGN_SYSTEM_SCRIPTS = (
Path(__file__).resolve().parent.parent.parent / "design-system" / "scripts"
)
sys.path.insert(0, str(_DESIGN_SYSTEM_SCRIPTS))
try:
import config_loader as _cfg
import brand_palette_validator as _bpv
except ImportError:
_cfg = None
_bpv = None
# ----- Severity → visual mapping (color + icon + aria-label) -------------------
# Color is NOT the sole signal (WCAG 1.4.1). Every badge has an icon and an
# aria-label that's announced by screen readers.
SEVERITY_DEFAULTS = {
# severity_name: {token_key_in_palette, icon_text, aria_phrase}
"BLOCKER": {"token": "_danger", "icon": "■", "aria": "Blocker — must fix before merge"},
"MAJOR": {"token": "--md-warn", "icon": "▲", "aria": "Major — strongly recommended fix"},
"MINOR": {"token": "--md-link", "icon": "●", "aria": "Minor — worth fixing"},
"NIT": {"token": "--md-text-muted", "icon": "◦", "aria": "Nit — cosmetic preference"},
}
def _derive_danger_color(palette: dict[str, str]) -> str:
"""Compute a 'danger' color by rotating the accent hue toward red.
Falls back to a generic red if the palette is unavailable.
"""
if not palette or _bpv is None:
return "#D04646"
accent_hex = palette.get("--md-accent", "#D04646")
try:
rgb = _bpv.parse_hex(accent_hex)
# Rotate hue toward red (0°) — pick the shorter rotation that lands near red
target = _bpv.shift_hue(rgb, -120) # rotate 120° toward red
return _bpv.rgb_to_hex(target)
except Exception:
return "#D04646"
def _resolve_severity_color(severity: str, palette: dict[str, str], danger: str) -> str:
sev = severity.upper()
spec = SEVERITY_DEFAULTS.get(sev, {"token": "--md-text-muted"})
token = spec["token"]
if token == "_danger":
return danger
return palette.get(token, "#888888")
def _palette_to_css(palette: dict[str, str]) -> str:
if not palette:
palette = {
"--md-bg": "#0E1E38", "--md-surface": "#142B50", "--md-border": "#1A3868",
"--md-text": "#F7F7F2", "--md-text-muted": "rgba(247, 247, 242, 0.68)",
"--md-accent": "#00D4AA", "--md-accent-soft": "rgba(0, 212, 170, 0.14)",
"--md-code-bg": "#122648",
"--md-link": "#00D4AA", "--md-link-hover": "#08FECE",
"--md-success": "#10A85C", "--md-warn": "#C87C10",
}
return "\n".join(f" {k}: {v};" for k, v in palette.items())
def _font_url(heading: str, body: str) -> str:
families = sorted({heading, body})
parts = "&".join(f"family={f.replace(' ', '+')}:wght@400;600" for f in families)
return f"https://fonts.googleapis.com/css2?{parts}&display=swap"
def _font_stack(name: str) -> str:
fallback = ("Georgia, serif" if name in
("Playfair Display", "Merriweather", "Lora", "Source Serif 4")
else "system-ui, -apple-system, sans-serif")
return f"'{name}', {fallback}"
BASE_CSS = """
:root {
__PALETTE__
--md-danger: __DANGER__;
--md-font-heading: __HEADING_FONT__;
--md-font-body: __BODY_FONT__;
--md-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
padding: 2rem 1.5rem;
background: var(--md-bg);
color: var(--md-text);
font-family: var(--md-font-body);
font-size: 16px;
line-height: 1.55;
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
h1, h2, h3 { font-family: var(--md-font-heading); color: var(--md-text); margin: 0 0 0.5em; line-height: 1.25; }
h1 { font-size: 1.75rem; }
h2 { font-size: 1.25rem; margin-top: 2rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
a { color: var(--md-link); }
/* Severity badges (color + icon + aria-label — WCAG 1.4.1) */
.sev-badge {
display: inline-flex;
align-items: center;
gap: 0.35em;
font-family: var(--md-font-heading);
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.15em 0.55em;
border-radius: 999px;
border: 1px solid currentColor;
background: var(--md-bg);
}
.sev-icon { font-family: var(--md-font-mono); font-size: 0.875em; }
/* Top jump-nav */
nav.jump-nav {
background: var(--md-surface);
border: 1px solid var(--md-border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin: 1.5rem 0 2rem;
}
nav.jump-nav h2 { margin-top: 0; font-size: 1rem; border: none; padding: 0; }
nav.jump-nav ul { list-style: none; padding: 0; margin: 0.75rem 0 0; display: grid; gap: 0.4rem; }
nav.jump-nav li { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
nav.jump-nav a {
color: var(--md-text);
text-decoration: none;
flex: 1;
border-radius: 4px;
padding: 0.2em 0.4em;
}
nav.jump-nav a:hover { background: var(--md-accent-soft); color: var(--md-accent); }
nav.jump-nav .nav-target {
color: var(--md-text-muted);
font-family: var(--md-font-mono);
font-size: 0.875rem;
}
nav.jump-nav .nav-preview { color: var(--md-text-muted); font-size: 0.875rem; }
/* 2-column hunk layout */
.hunk-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 1.25rem;
margin: 1.5rem 0 2rem;
scroll-margin-top: 1rem;
}
@media (max-width: 900px) {
.hunk-row { grid-template-columns: 1fr; }
.hunk-annotations { order: 2; }
}
.hunk {
background: var(--md-code-bg);
border: 1px solid var(--md-border);
border-radius: 8px;
overflow: hidden;
}
.hunk-head {
background: var(--md-surface);
border-bottom: 1px solid var(--md-border);
padding: 0.5rem 0.85rem;
font-family: var(--md-font-mono);
font-size: 0.8125rem;
color: var(--md-text-muted);
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.hunk-head strong { color: var(--md-text); font-weight: 600; }
.hunk pre {
margin: 0;
padding: 0;
background: transparent;
font-family: var(--md-font-mono);
font-size: 0.8125rem;
line-height: 1.55;
overflow-x: auto;
}
.hunk .line {
display: grid;
grid-template-columns: 3.5em 3.5em 1.25em 1fr;
padding-right: 0.75rem;
}
.hunk .ln, .hunk .lo {
color: var(--md-text-muted);
padding: 0 0.5em;
text-align: right;
user-select: none;
border-right: 1px solid var(--md-border);
font-size: 0.75rem;
}
.hunk .mark { text-align: center; user-select: none; color: var(--md-text-muted); }
.hunk .src { padding-left: 0.5em; white-space: pre; overflow-wrap: normal; }
.hunk .line.addition { background: color-mix(in srgb, var(--md-success) 15%, transparent); }
.hunk .line.addition .mark { color: var(--md-success); }
.hunk .line.deletion { background: color-mix(in srgb, var(--md-warn) 12%, transparent); }
.hunk .line.deletion .mark { color: var(--md-warn); }
.hunk .line.meta { color: var(--md-text-muted); font-style: italic; padding-left: 1em; }
/* Annotation cards on the right */
.hunk-annotations { display: grid; gap: 0.75rem; align-content: start; }
.annotation {
background: var(--md-surface);
border: 1px solid var(--md-border);
border-left-width: 4px;
border-radius: 8px;
padding: 0.75rem 0.85rem;
}
.annotation .annotation-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.annotation .annotation-body { font-size: 0.9375rem; line-height: 1.5; color: var(--md-text); }
.annotation .annotation-body code {
font-family: var(--md-font-mono);
background: var(--md-code-bg);
padding: 0.1em 0.3em;
border-radius: 4px;
font-size: 0.875em;
}
.annotation.unanchored { border-left-color: var(--md-text-muted); }
/* Reviewer footer */
footer.review-footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--md-border);
color: var(--md-text-muted);
font-size: 0.9375rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
footer.review-footer strong { color: var(--md-text); }
/* Approval bar (LGTM markers) */
.approval-bar {
background: color-mix(in srgb, var(--md-success) 12%, transparent);
border: 1px solid var(--md-success);
color: var(--md-success);
padding: 0.5rem 0.85rem;
border-radius: 8px;
margin: 1rem 0;
font-weight: 600;
font-family: var(--md-font-heading);
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
html { scroll-behavior: auto; }
}
"""
def _hunk_anchor(block_idx: int, file_idx: int, hunk_idx: int) -> str:
return f"hunk-b{block_idx}-f{file_idx}-h{hunk_idx}"
def _annotation_anchor(idx: int) -> str:
return f"ann-{idx}"
def _render_severity_badge(severity: str, palette: dict[str, str], danger: str) -> str:
spec = SEVERITY_DEFAULTS.get(severity.upper(), {"icon": "?", "aria": severity})
color = _resolve_severity_color(severity, palette, danger)
icon = html.escape(spec["icon"])
aria = html.escape(spec["aria"])
return (
f'<span class="sev-badge" style="color: {color}" '
f'role="status" aria-label="{aria}">'
f'<span class="sev-icon" aria-hidden="true">{icon}</span>'
f'{html.escape(severity.upper())}'
f'</span>'
)
def _render_hunk(file_idx: int, file_entry: dict[str, Any],
block_idx: int, hunk_idx: int) -> str:
h = file_entry["hunks"][hunk_idx]
path = file_entry.get("path_new") or file_entry.get("path_old") or "(unknown)"
anchor = _hunk_anchor(block_idx, file_idx, hunk_idx)
head = (
f'<div class="hunk-head">'
f'<strong>{html.escape(path)}</strong>'
f'<span>@{h["old_start"]}{html.escape(" → ")}@{h["new_start"]}</span>'
f'<span>{html.escape(h.get("header_context") or "")}</span>'
f'</div>'
)
body_lines = []
for ln in h["lines"]:
kind = ln["kind"]
mark = {"addition": "+", "deletion": "−", "context": " ", "meta": "\\"}.get(kind, " ")
old = "" if ln.get("old") is None else str(ln["old"])
new = "" if ln.get("new") is None else str(ln["new"])
body_lines.append(
f'<div class="line {kind}">'
f'<span class="lo">{old}</span>'
f'<span class="ln">{new}</span>'
f'<span class="mark" aria-hidden="true">{mark}</span>'
f'<span class="src">{html.escape(ln["text"])}</span>'
f'</div>'
)
return (
f'<div class="hunk" id="{anchor}">'
f'{head}<pre>{"".join(body_lines)}</pre></div>'
)
def _render_annotation(idx: int, ann: dict[str, Any],
palette: dict[str, str], danger: str,
anchor_for_block: dict[int, str]) -> str:
color = _resolve_severity_color(ann["severity"], palette, danger)
anchor_target = anchor_for_block.get(ann.get("attached_block"))
target_link = (
f'<a href="#{anchor_target}" '
f'style="color: var(--md-text-muted); font-size: 0.75rem; '
f'text-decoration: none">jump to diff →</a>'
if anchor_target else ""
)
body = html.escape(ann["body"])
# Re-inflate inline `code` for readability
body = body.replace("`", "<code>", 1)
while "<code>" in body and body.count("<code>") > body.count("</code>"):
body = body.replace("`", "</code>", 1)
return (
f'<div class="annotation{" unanchored" if anchor_target is None else ""}" '
f'id="{_annotation_anchor(idx)}" style="border-left-color: {color}">'
f'<div class="annotation-head">'
f'{_render_severity_badge(ann["severity"], palette, danger)}'
f'{target_link}'
f'</div>'
f'<div class="annotation-body">{body}</div>'
f'</div>'
)
def render(
diff_blocks: dict[str, Any],
annotations: dict[str, Any],
config: dict[str, Any],
reviewer: str,
pr_title: str = "Code Review",
) -> str:
palette = config.get("derived_palette") or {}
typo = config.get("typography") or {}
heading_font = typo.get("heading_font", "Inter")
body_font = typo.get("body_font", "Inter")
style = config.get("design_style", "technical")
company_name = config.get("company_name", "")
danger = _derive_danger_color(palette)
css = (BASE_CSS
.replace("__PALETTE__", _palette_to_css(palette))
.replace("__DANGER__", danger)
.replace("__HEADING_FONT__", _font_stack(heading_font))
.replace("__BODY_FONT__", _font_stack(body_font)))
# Build anchor map for jump-nav targets
anchor_for_block: dict[int, str] = {}
for block in diff_blocks.get("blocks", []):
block_idx = block["block_index"]
for file_idx, file_entry in enumerate(block["files"]):
if file_entry["hunks"]:
# Anchor the first hunk of the first file in each block
anchor_for_block.setdefault(
block_idx, _hunk_anchor(block_idx, file_idx, 0)
)
# Top jump-nav (ordered by source position, which is annotation order)
annlist = annotations.get("annotations", [])
nav_items_html: list[str] = []
for i, ann in enumerate(annlist):
target_anchor = _annotation_anchor(i)
target_diff = anchor_for_block.get(ann.get("attached_block")) or target_anchor
preview = html.escape(ann["body"][:80] + ("…" if len(ann["body"]) > 80 else ""))
target_label = (
f'#{ann.get("attached_block") + 1}' if ann.get("attached_block") is not None
else "(unanchored)"
)
nav_items_html.append(
f'<li>'
f'{_render_severity_badge(ann["severity"], palette, danger)}'
f'<a href="#{target_anchor}">{preview}</a>'
f'<span class="nav-target">{html.escape(target_label)}</span>'
f'</li>'
)
jump_nav_html = ""
if annlist:
counts = annotations.get("summary", {}).get("counts_by_severity", {})
count_summary = " · ".join(
f"{n} {sev}" for sev, n in sorted(counts.items())
)
jump_nav_html = (
'<nav class="jump-nav" aria-label="Review annotations">'
f'<h2>Findings ({count_summary})</h2>'
f'<ul>{"".join(nav_items_html)}</ul>'
'</nav>'
)
# Approval bar (LGTM markers)
approvals = annotations.get("approvals", [])
approval_html = ""
if approvals and not annlist:
approval_html = (
'<div class="approval-bar" role="status">'
'LGTM — no findings flagged'
'</div>'
)
# Render hunks + their attached annotations
block_to_annotations: dict[int, list[tuple[int, dict[str, Any]]]] = {}
unanchored: list[tuple[int, dict[str, Any]]] = []
for i, ann in enumerate(annlist):
if ann.get("attached_block") is not None:
block_to_annotations.setdefault(ann["attached_block"], []).append((i, ann))
else:
unanchored.append((i, ann))
sections_html: list[str] = []
for block in diff_blocks.get("blocks", []):
block_idx = block["block_index"]
anns_for_block = block_to_annotations.get(block_idx, [])
for file_idx, file_entry in enumerate(block["files"]):
for hunk_idx in range(len(file_entry["hunks"])):
hunk_html = _render_hunk(file_idx, file_entry, block_idx, hunk_idx)
# All annotations for this block render alongside the first hunk
ann_html = ""
if file_idx == 0 and hunk_idx == 0 and anns_for_block:
ann_html = "".join(
_render_annotation(i, ann, palette, danger, anchor_for_block)
for i, ann in anns_for_block
)
sections_html.append(
'<div class="hunk-row">'
f'{hunk_html}'
f'<div class="hunk-annotations">{ann_html}</div>'
'</div>'
)
if unanchored:
sections_html.append('<h2>General comments</h2>')
sections_html.append('<div class="hunk-annotations">')
for i, ann in unanchored:
sections_html.append(
_render_annotation(i, ann, palette, danger, anchor_for_block)
)
sections_html.append('</div>')
# Footer with mandatory reviewer name
footer_html = (
'<footer class="review-footer">'
f'<span>Reviewer: <strong>{html.escape(reviewer)}</strong></span>'
+ (f'<span>· {html.escape(company_name)}</span>' if company_name else "")
+ '<span style="margin-left:auto">Generated by markdown-html / md-review</span>'
'</footer>'
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(pr_title)}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="stylesheet" href="{_font_url(heading_font, body_font)}">
<style>{css}</style>
</head>
<body class="style-{style}">
<header><h1>{html.escape(pr_title)}</h1></header>
{jump_nav_html}
{approval_html}
{"".join(sections_html)}
{footer_html}
</body>
</html>"""
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
p.add_argument("--diff-blocks", help="Path to diff_parser JSON output")
p.add_argument("--annotations", help="Path to annotation_extractor JSON output")
p.add_argument("--reviewer", help="Reviewer name (required; refuses to render without)")
p.add_argument("--title", default="Code Review", help="PR / review title")
p.add_argument("--output", help="Path to write HTML (else stdout)")
p.add_argument("--no-config", action="store_true",
help="Bypass design-system config (use DEFAULTS)")
p.add_argument("--sample", action="store_true",
help="Render the built-in sample PR review")
p.add_argument("--severity-convention",
default="BLOCKER,MAJOR,MINOR,NIT",
help="Comma-separated severity tier list (most → least)")
args = p.parse_args(argv)
if args.sample:
sys.path.insert(0, str(Path(__file__).resolve().parent))
import annotation_extractor
import diff_parser
text = diff_parser.SAMPLE_MARKDOWN
diff_blocks = diff_parser.parse_markdown_for_diffs(text)
sev_conv = [s.strip().upper() for s in args.severity_convention.split(",")]
annotations = annotation_extractor.extract_annotations(text, diff_blocks, sev_conv)
reviewer = args.reviewer or "Sample Reviewer"
pr_title = args.title
else:
if not (args.diff_blocks and args.annotations):
print("error: --diff-blocks and --annotations are required "
"(use --sample for a built-in demo)", file=sys.stderr)
return 2
diff_blocks = json.loads(Path(args.diff_blocks).read_text(encoding="utf-8"))
annotations = json.loads(Path(args.annotations).read_text(encoding="utf-8"))
reviewer = args.reviewer
pr_title = args.title
# Hard rule 1: reviewer name is mandatory (named owner per research-ops discipline)
if not reviewer or not reviewer.strip():
print("refusing: --reviewer is required. A code review must name a human reviewer.",
file=sys.stderr)
return 3
# Hard rule 2: refuse if there are no hunks (wrong skill — route to md-document)
if diff_blocks.get("summary", {}).get("total_hunks", 0) == 0:
print("refusing: no diff hunks present in input. This is not a code review — "
"route to md-document instead.", file=sys.stderr)
return 4
if args.no_config or os.environ.get("MARKDOWN_HTML_NO_CONFIG") == "1":
config = _cfg.DEFAULTS if _cfg else {}
else:
config = _cfg.load_config() if _cfg else {}
output = render(diff_blocks, annotations, config, reviewer, pr_title)
if args.output and args.output != "-":
Path(args.output).write_text(output, encoding="utf-8")
print(f"wrote {args.output}: {len(output):,} bytes "
f"({diff_blocks['summary']['total_hunks']} hunks, "
f"{annotations['summary']['total_annotations']} annotations, "
f"reviewer={reviewer})")
else:
print(output)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))