feat: review semantic diff submission

This commit is contained in:
Marcos Silva
2026-09-04 14:06:50 -03:00
parent 9557957698
commit d12d301a1a
8 changed files with 1430 additions and 2 deletions
@@ -0,0 +1,757 @@
#!/usr/bin/env python3
"""Validate semantic classifications and render a self-contained HTML review."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
COLLECTOR_NAME = "semantic-diff-review/collect_changes.py"
HUNK_ID = re.compile(r"^H-[0-9A-F]{12,64}(?:-[0-9A-F]{8})?(?:-[0-9]+)?$")
RISK_LEVELS = {"low", "medium", "high"}
CLASSIFICATION_KEYS = {"schema_version", "groups"}
GROUP_KEYS = {
"title",
"purpose",
"risk",
"review_points",
"suggested_commit_message",
"hunk_ids",
}
RISK_KEYS = {"level", "rationale"}
class RenderError(RuntimeError):
"""Raised when evidence or semantic classification is invalid."""
def load_json(path: Path) -> Any:
try:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
except FileNotFoundError as exc:
raise RenderError(f"File not found: {path}") from exc
except json.JSONDecodeError as exc:
raise RenderError(f"Invalid JSON in {path}: {exc}") from exc
def require_dict(value: Any, label: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise RenderError(f"{label} must be an object")
return value
def require_exact_keys(value: dict[str, Any], expected: set[str], label: str) -> None:
actual = set(value)
missing = sorted(expected - actual)
unknown = sorted(actual - expected)
if missing or unknown:
details = []
if missing:
details.append(f"missing {', '.join(missing)}")
if unknown:
details.append(f"unknown {', '.join(unknown)}")
raise RenderError(f"{label} has invalid fields: {'; '.join(details)}")
def require_string(value: Any, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
raise RenderError(f"{label} must be a string")
if not allow_empty and not value.strip():
raise RenderError(f"{label} must not be empty")
return value
def canonical_evidence(records: list[dict[str, Any]]) -> str:
return json.dumps(records, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
def validate_changes(document: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
root = require_dict(document, "changes")
if root.get("schema_version") != SCHEMA_VERSION:
raise RenderError("Unsupported changes schema_version")
if root.get("generator") != COLLECTOR_NAME:
raise RenderError("changes.json was not produced by the bundled collector")
repository = require_dict(root.get("repository"), "changes.repository")
require_string(repository.get("root"), "changes.repository.root")
head = repository.get("head")
if head is not None:
require_string(head, "changes.repository.head")
target_value = repository.get("target")
if target_value is None:
target = {"kind": "working-tree"}
repository = {**repository, "target": target}
else:
target = require_dict(target_value, "changes.repository.target")
kind = require_string(target.get("kind"), "changes.repository.target.kind")
if kind == "working-tree":
require_exact_keys(target, {"kind"}, "changes.repository.target")
elif kind == "commit":
require_exact_keys(
target,
{"kind", "revision", "commit", "base"},
"changes.repository.target",
)
require_string(target["revision"], "changes.repository.target.revision")
require_string(target["commit"], "changes.repository.target.commit")
require_string(target["base"], "changes.repository.target.base")
else:
raise RenderError(
"changes.repository.target.kind must be working-tree or commit"
)
records = root.get("hunks")
if not isinstance(records, list):
raise RenderError("changes.hunks must be an array")
seen: set[str] = set()
validated: list[dict[str, Any]] = []
required_fields = {
"id",
"scope",
"status",
"old_path",
"new_path",
"kind",
"header",
"additions",
"deletions",
"patch_sha256",
"patch",
}
for index, raw_record in enumerate(records):
label = f"changes.hunks[{index}]"
record = require_dict(raw_record, label)
require_exact_keys(record, required_fields, label)
hunk_id = require_string(record["id"], f"{label}.id")
if not HUNK_ID.fullmatch(hunk_id):
raise RenderError(f"{label}.id is not a valid collector hunk ID")
if hunk_id in seen:
raise RenderError(f"Duplicate collected hunk ID: {hunk_id}")
seen.add(hunk_id)
scope = require_string(record["scope"], f"{label}.scope")
if scope not in {"staged", "unstaged", "untracked", "commit"}:
raise RenderError(
f"{label}.scope must be staged, unstaged, untracked, or commit"
)
require_string(record["status"], f"{label}.status")
require_string(record["old_path"], f"{label}.old_path")
require_string(record["new_path"], f"{label}.new_path")
kind = require_string(record["kind"], f"{label}.kind")
if kind not in {"text", "binary-or-metadata", "empty"}:
raise RenderError(f"{label}.kind is invalid")
require_string(record["header"], f"{label}.header", allow_empty=True)
for stat in ("additions", "deletions"):
if not isinstance(record[stat], int) or record[stat] < 0:
raise RenderError(f"{label}.{stat} must be a non-negative integer")
patch = require_string(record["patch"], f"{label}.patch", allow_empty=True)
expected_hash = require_string(
record["patch_sha256"], f"{label}.patch_sha256"
)
actual_hash = hashlib.sha256(
patch.encode("utf-8", "surrogateescape")
).hexdigest()
if actual_hash != expected_hash:
raise RenderError(
f"Collected patch integrity check failed for {hunk_id}; re-run collection"
)
validated.append(record)
digest = require_string(root.get("evidence_sha256"), "changes.evidence_sha256")
actual_digest = hashlib.sha256(canonical_evidence(validated).encode("ascii")).hexdigest()
if digest != actual_digest:
raise RenderError("Collected evidence integrity check failed; re-run collection")
return repository, validated
def validate_classification(
document: Any, hunks: list[dict[str, Any]]
) -> list[dict[str, Any]]:
root = require_dict(document, "classification")
require_exact_keys(root, CLASSIFICATION_KEYS, "classification")
if root["schema_version"] != SCHEMA_VERSION:
raise RenderError("Unsupported classification schema_version")
groups = root["groups"]
if not isinstance(groups, list):
raise RenderError("classification.groups must be an array")
known_ids = {hunk["id"] for hunk in hunks}
assigned: list[str] = []
validated: list[dict[str, Any]] = []
for index, raw_group in enumerate(groups):
label = f"classification.groups[{index}]"
group = require_dict(raw_group, label)
require_exact_keys(group, GROUP_KEYS, label)
title = require_string(group["title"], f"{label}.title")
purpose = require_string(group["purpose"], f"{label}.purpose")
risk = require_dict(group["risk"], f"{label}.risk")
require_exact_keys(risk, RISK_KEYS, f"{label}.risk")
level = require_string(risk["level"], f"{label}.risk.level").lower()
if level not in RISK_LEVELS:
raise RenderError(f"{label}.risk.level must be low, medium, or high")
rationale = require_string(risk["rationale"], f"{label}.risk.rationale")
points = group["review_points"]
if not isinstance(points, list) or not points:
raise RenderError(f"{label}.review_points must be a non-empty array")
review_points = [
require_string(point, f"{label}.review_points[{point_index}]")
for point_index, point in enumerate(points)
]
message = require_string(
group["suggested_commit_message"], f"{label}.suggested_commit_message"
)
hunk_ids = group["hunk_ids"]
if not isinstance(hunk_ids, list) or not hunk_ids:
raise RenderError(f"{label}.hunk_ids must be a non-empty array")
normalized_ids = [
require_string(hunk_id, f"{label}.hunk_ids[{hunk_index}]")
for hunk_index, hunk_id in enumerate(hunk_ids)
]
unknown = sorted(set(normalized_ids) - known_ids)
if unknown:
raise RenderError(f"{label} references unknown hunk IDs: {', '.join(unknown)}")
assigned.extend(normalized_ids)
validated.append(
{
"id": f"group-{index + 1}",
"title": title,
"purpose": purpose,
"risk": {"level": level, "rationale": rationale},
"review_points": review_points,
"suggested_commit_message": message,
"hunk_ids": normalized_ids,
}
)
if not known_ids and groups:
raise RenderError("classification.groups must be empty when there are no hunks")
duplicates = sorted({item for item in assigned if assigned.count(item) > 1})
if duplicates:
raise RenderError(f"Hunk IDs assigned more than once: {', '.join(duplicates)}")
missing = sorted(known_ids - set(assigned))
if missing:
raise RenderError(f"Unclassified hunk IDs: {', '.join(missing)}")
return validated
def build_payload(
repository: dict[str, Any],
hunks: list[dict[str, Any]],
groups: list[dict[str, Any]],
) -> dict[str, Any]:
by_id = {hunk["id"]: hunk for hunk in hunks}
rendered_groups = []
for group in groups:
group_hunks = [by_id[hunk_id] for hunk_id in group["hunk_ids"]]
paths = sorted(
{
hunk["new_path"]
if hunk["new_path"] != "/dev/null"
else hunk["old_path"]
for hunk in group_hunks
}
)
rendered_groups.append(
{
**group,
"hunks": group_hunks,
"stats": {
"additions": sum(hunk["additions"] for hunk in group_hunks),
"deletions": sum(hunk["deletions"] for hunk in group_hunks),
"files": len(paths),
"hunks": len(group_hunks),
},
}
)
root = repository["root"]
return {
"repository": {
"name": Path(root).name or root,
"root": root,
"head": repository.get("head"),
"target": repository["target"],
},
"totals": {
"groups": len(rendered_groups),
"hunks": len(hunks),
"additions": sum(hunk["additions"] for hunk in hunks),
"deletions": sum(hunk["deletions"] for hunk in hunks),
},
"groups": rendered_groups,
}
def safe_json_for_html(payload: dict[str, Any]) -> str:
encoded = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
return encoded.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
HTML_TEMPLATE = r'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Semantic Diff Review</title>
<style>
:root {
color-scheme: dark;
--bg: #090c10;
--surface: #0f141b;
--surface-2: #151b24;
--surface-3: #1b2330;
--border: #273140;
--border-soft: #1d2632;
--text: #e6edf3;
--muted: #8b98a8;
--faint: #5f6b79;
--accent: #7c9cff;
--accent-soft: rgba(124, 156, 255, .12);
--green: #57d18c;
--green-soft: rgba(46, 160, 88, .13);
--red: #ff7b72;
--red-soft: rgba(248, 81, 73, .13);
--amber: #e3b341;
--amber-soft: rgba(227, 179, 65, .13);
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
}
button { font: inherit; }
.shell { display: grid; grid-template-rows: 58px minmax(0, 1fr); height: 100vh; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: rgba(15, 20, 27, .95);
box-shadow: 0 8px 28px rgba(0, 0, 0, .22);
z-index: 5;
}
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
.brand-mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid rgba(124, 156, 255, .45);
border-radius: 8px;
background: linear-gradient(145deg, rgba(124,156,255,.22), rgba(87,209,140,.08));
color: #a9bcff;
font: 700 15px var(--mono);
}
.brand-copy { min-width: 0; }
.brand-title { font-weight: 650; letter-spacing: -.01em; }
.repo-line { color: var(--muted); font: 11px var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-stats { display: flex; align-items: center; gap: 13px; color: var(--muted); font-size: 12px; white-space: nowrap; }
.top-stats b { color: var(--text); font-weight: 600; }
.add { color: var(--green) !important; }
.del { color: var(--red) !important; }
.integrity {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border: 1px solid rgba(87, 209, 140, .25);
border-radius: 999px;
background: rgba(87, 209, 140, .08);
color: #8ae2ad;
font-size: 11px;
}
.integrity::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); }
.workspace { display: grid; grid-template-columns: 282px minmax(390px, 1fr) 350px; min-height: 0; }
.sidebar, .inspector { background: var(--surface); min-height: 0; overflow: auto; }
.sidebar { border-right: 1px solid var(--border); padding: 18px 12px; }
.inspector { border-left: 1px solid var(--border); padding: 22px 20px 32px; }
.diff-pane { min-width: 0; min-height: 0; overflow: auto; background: #0b0f14; }
.eyebrow {
margin: 0 8px 10px;
color: var(--faint);
font-size: 10px;
font-weight: 700;
letter-spacing: .13em;
text-transform: uppercase;
}
.group-list { display: grid; gap: 7px; }
.group-button {
width: 100%;
padding: 12px;
border: 1px solid transparent;
border-radius: 9px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, transform .15s ease;
}
.group-button:hover { background: var(--surface-2); border-color: var(--border-soft); }
.group-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.group-button.active { background: var(--accent-soft); border-color: rgba(124, 156, 255, .35); }
.group-index { color: var(--faint); font: 10px var(--mono); }
.group-name { margin-top: 5px; font-size: 13px; font-weight: 620; line-height: 1.35; }
.group-meta { display: flex; gap: 8px; margin-top: 9px; color: var(--muted); font: 10px var(--mono); }
.empty-state { display: grid; place-items: center; min-height: 100%; padding: 40px; text-align: center; }
.empty-card { max-width: 440px; }
.empty-icon { color: var(--green); font: 38px var(--mono); }
.empty-card h1 { margin: 15px 0 8px; font-size: 22px; }
.empty-card p { margin: 0; color: var(--muted); line-height: 1.6; }
.pane-header {
position: sticky;
top: 0;
z-index: 3;
padding: 20px 22px 15px;
border-bottom: 1px solid var(--border);
background: rgba(11, 15, 20, .94);
backdrop-filter: blur(12px);
}
.pane-header h1 { margin: 0; font-size: 18px; letter-spacing: -.015em; }
.pane-meta { display: flex; flex-wrap: wrap; gap: 13px; margin-top: 9px; color: var(--muted); font: 11px var(--mono); }
.diff-stack { display: grid; gap: 14px; padding: 16px 18px 34px; }
.hunk-card { overflow: hidden; border: 1px solid var(--border); border-radius: 9px; background: #0d1218; box-shadow: 0 8px 28px rgba(0, 0, 0, .16); }
.hunk-bar { display: flex; align-items: center; gap: 9px; padding: 9px 12px; border-bottom: 1px solid var(--border); background: var(--surface-2); }
.scope {
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 5px;
color: #b8c2ce;
background: var(--surface-3);
font: 9px var(--mono);
letter-spacing: .06em;
text-transform: uppercase;
}
.scope.staged { color: #8ae2ad; border-color: rgba(87,209,140,.28); background: rgba(87,209,140,.08); }
.scope.untracked { color: #f2cb6c; border-color: rgba(227,179,65,.28); background: rgba(227,179,65,.08); }
.scope.commit { color: #a9bcff; border-color: rgba(124,156,255,.35); background: rgba(124,156,255,.10); }
.path { min-width: 0; overflow: hidden; color: #c9d3df; font: 11px var(--mono); text-overflow: ellipsis; white-space: nowrap; }
.hunk-id { margin-left: auto; color: var(--faint); font: 9px var(--mono); white-space: nowrap; }
.diff { margin: 0; padding: 10px 0; overflow-x: auto; color: #b9c3cf; font: 11px/1.55 var(--mono); tab-size: 4; }
.diff-line { display: block; min-width: max-content; padding: 0 14px; white-space: pre; }
.diff-line.addition { color: #a8e6bd; background: var(--green-soft); }
.diff-line.deletion { color: #ffaaa4; background: var(--red-soft); }
.diff-line.hunk { color: #a9bcff; background: rgba(124,156,255,.08); }
.diff-line.file { color: #d6a8ff; }
.diff-line.meta { color: #6f7d8c; }
.no-patch { padding: 24px 16px; color: var(--muted); font-size: 12px; text-align: center; }
.inspector h2 { margin: 0 0 18px; font-size: 17px; line-height: 1.35; letter-spacing: -.01em; }
.section { padding: 17px 0; border-top: 1px solid var(--border-soft); }
.section:first-of-type { border-top: 0; padding-top: 0; }
.section-label { margin-bottom: 9px; color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.section p { margin: 0; color: #b9c3cf; line-height: 1.6; }
.risk-row { display: flex; align-items: center; gap: 9px; margin-bottom: 9px; }
.risk-badge { padding: 4px 8px; border-radius: 999px; font: 700 10px var(--mono); text-transform: uppercase; }
.risk-badge.low { color: #8ae2ad; background: var(--green-soft); border: 1px solid rgba(87,209,140,.25); }
.risk-badge.medium { color: #f0c762; background: var(--amber-soft); border: 1px solid rgba(227,179,65,.25); }
.risk-badge.high { color: #ff9a93; background: var(--red-soft); border: 1px solid rgba(248,81,73,.25); }
.review-points { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.review-points li { position: relative; padding-left: 17px; color: #b9c3cf; line-height: 1.5; }
.review-points li::before { content: ""; position: absolute; left: 0; color: var(--accent); font: 700 15px var(--mono); }
.commit-box { position: relative; padding: 12px 40px 12px 12px; border: 1px solid var(--border); border-radius: 8px; background: #0b0f14; color: #d7e0ea; font: 11px/1.55 var(--mono); word-break: break-word; }
.copy-button { position: absolute; top: 7px; right: 7px; width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); cursor: pointer; }
.copy-button:hover { color: var(--text); border-color: #3a4758; }
.source-note { display: flex; gap: 9px; margin-top: 19px; padding: 11px; border: 1px solid var(--border-soft); border-radius: 8px; color: var(--muted); background: rgba(255,255,255,.015); font-size: 11px; line-height: 1.45; }
.source-note span:first-child { color: var(--green); }
@media (max-width: 1050px) {
body { overflow: auto; }
.shell { min-height: 100vh; height: auto; }
.workspace { grid-template-columns: 230px minmax(0, 1fr); grid-template-rows: minmax(620px, auto) auto; }
.inspector { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border); }
}
@media (max-width: 720px) {
.top-stats .desktop-stat, .integrity { display: none; }
.workspace { display: block; }
.sidebar { border-right: 0; border-bottom: 1px solid var(--border); overflow: visible; }
.group-list { grid-auto-flow: column; grid-auto-columns: minmax(210px, 75vw); overflow-x: auto; padding-bottom: 4px; }
.diff-pane { min-height: 600px; }
.inspector { border-left: 0; }
}
</style>
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark">Δ</div>
<div class="brand-copy">
<div class="brand-title">Semantic Diff Review</div>
<div class="repo-line" id="repo-line"></div>
</div>
</div>
<div class="top-stats">
<span><b id="total-groups">0</b> groups</span>
<span class="desktop-stat"><b id="total-hunks">0</b> hunks</span>
<span class="desktop-stat"><b class="add" id="total-additions">+0</b></span>
<span class="desktop-stat"><b class="del" id="total-deletions">0</b></span>
<span class="integrity">Git-derived patches</span>
</div>
</header>
<main class="workspace">
<nav class="sidebar" aria-label="Semantic groups">
<div class="eyebrow">Change groups</div>
<div class="group-list" id="group-list"></div>
</nav>
<section class="diff-pane" id="diff-pane" aria-label="Selected group diff"></section>
<aside class="inspector" id="inspector" aria-label="Semantic analysis"></aside>
</main>
</div>
<script id="review-data" type="application/json">__REVIEW_DATA__</script>
<script>
(() => {
"use strict";
const data = JSON.parse(document.getElementById("review-data").textContent);
const groupList = document.getElementById("group-list");
const diffPane = document.getElementById("diff-pane");
const inspector = document.getElementById("inspector");
let selected = 0;
const node = (tag, className, text) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
};
const pathFor = (hunk) => hunk.new_path === "/dev/null" ? hunk.old_path : hunk.new_path;
const lineClass = (line) => {
if (line.startsWith("@@")) return "hunk";
if (line.startsWith("diff --git") || line.startsWith("--- ") || line.startsWith("+++ ")) return "file";
if (line.startsWith("+") && !line.startsWith("+++")) return "addition";
if (line.startsWith("-") && !line.startsWith("---")) return "deletion";
if (/^(index |new file mode |deleted file mode |similarity index |rename |copy |Binary files |GIT binary patch)/.test(line)) return "meta";
return "context";
};
const renderSidebar = () => {
groupList.replaceChildren();
data.groups.forEach((group, index) => {
const button = node("button", `group-button${index === selected ? " active" : ""}`);
button.type = "button";
button.setAttribute("aria-pressed", String(index === selected));
button.append(node("div", "group-index", `GROUP ${String(index + 1).padStart(2, "0")}`));
button.append(node("div", "group-name", group.title));
const meta = node("div", "group-meta");
meta.append(node("span", "", `${group.stats.files} file${group.stats.files === 1 ? "" : "s"}`));
meta.append(node("span", "", `${group.stats.hunks} hunk${group.stats.hunks === 1 ? "" : "s"}`));
button.append(meta);
button.addEventListener("click", () => { selected = index; render(); });
groupList.append(button);
});
};
const renderDiff = (group) => {
diffPane.replaceChildren();
const header = node("header", "pane-header");
header.append(node("h1", "", group.title));
const meta = node("div", "pane-meta");
meta.append(node("span", "", `${group.stats.files} files`));
meta.append(node("span", "", `${group.stats.hunks} hunks`));
meta.append(node("span", "add", `+${group.stats.additions}`));
meta.append(node("span", "del", `${group.stats.deletions}`));
header.append(meta);
diffPane.append(header);
const stack = node("div", "diff-stack");
group.hunks.forEach((hunk) => {
const card = node("article", "hunk-card");
const bar = node("div", "hunk-bar");
bar.append(node("span", `scope ${hunk.scope}`, hunk.scope));
bar.append(node("span", "path", pathFor(hunk)));
bar.append(node("span", "hunk-id", hunk.id));
card.append(bar);
if (!hunk.patch) {
card.append(node("div", "no-patch", "Git emitted no textual patch for this empty-file change."));
} else {
const pre = node("pre", "diff");
const lines = hunk.patch.split("\n");
if (lines.at(-1) === "") lines.pop();
lines.forEach((line) => pre.append(node("span", `diff-line ${lineClass(line)}`, line)));
card.append(pre);
}
stack.append(card);
});
diffPane.append(stack);
diffPane.scrollTop = 0;
};
const renderInspector = (group) => {
inspector.replaceChildren();
inspector.append(node("h2", "", group.title));
const purpose = node("section", "section");
purpose.append(node("div", "section-label", "Purpose"));
purpose.append(node("p", "", group.purpose));
inspector.append(purpose);
const risk = node("section", "section");
risk.append(node("div", "section-label", "Risk"));
const riskRow = node("div", "risk-row");
riskRow.append(node("span", `risk-badge ${group.risk.level}`, group.risk.level));
risk.append(riskRow);
risk.append(node("p", "", group.risk.rationale));
inspector.append(risk);
const review = node("section", "section");
review.append(node("div", "section-label", "Review points"));
const list = node("ul", "review-points");
group.review_points.forEach((point) => list.append(node("li", "", point)));
review.append(list);
inspector.append(review);
const commit = node("section", "section");
commit.append(node("div", "section-label", "Suggested commit"));
const box = node("div", "commit-box", group.suggested_commit_message);
const copy = node("button", "copy-button", "");
copy.type = "button";
copy.title = "Copy commit message";
copy.setAttribute("aria-label", "Copy suggested commit message");
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(group.suggested_commit_message);
copy.textContent = "";
setTimeout(() => { copy.textContent = ""; }, 1200);
} catch (_error) {
copy.textContent = "!";
}
});
box.append(copy);
commit.append(box);
inspector.append(commit);
const note = node("div", "source-note");
note.append(node("span", "", ""));
note.append(node("span", "", "Every patch shown in the center pane is preserved from Git diff output. Semantic text is escaped classification metadata."));
inspector.append(note);
inspector.scrollTop = 0;
};
const renderEmpty = () => {
groupList.replaceChildren();
diffPane.replaceChildren();
inspector.replaceChildren();
const state = node("div", "empty-state");
const card = node("div", "empty-card");
card.append(node("div", "empty-icon", ""));
const commitTarget = data.repository.target.kind === "commit";
card.append(node("h1", "", commitTarget ? "Commit has no changes" : "Working tree is clean"));
card.append(node("p", "", commitTarget
? "No changes were found between the selected commit and its first parent. Git state was not modified."
: "No staged, unstaged, or untracked changes were collected. Git state was not modified."));
state.append(card);
diffPane.append(state);
};
const render = () => {
if (!data.groups.length) { renderEmpty(); return; }
renderSidebar();
renderDiff(data.groups[selected]);
renderInspector(data.groups[selected]);
};
const target = data.repository.target;
const targetLabel = target.kind === "commit"
? `commit ${target.commit.slice(0, 10)}`
: (data.repository.head ? `working tree @ ${data.repository.head.slice(0, 10)}` : "working tree @ unborn HEAD");
document.getElementById("repo-line").textContent = `${data.repository.name} · ${targetLabel}`;
document.getElementById("repo-line").title = data.repository.root;
document.getElementById("total-groups").textContent = data.totals.groups;
document.getElementById("total-hunks").textContent = data.totals.hunks;
document.getElementById("total-additions").textContent = `+${data.totals.additions}`;
document.getElementById("total-deletions").textContent = `${data.totals.deletions}`;
render();
})();
</script>
</body>
</html>
'''
def render_html(payload: dict[str, Any]) -> str:
return HTML_TEMPLATE.replace("__REVIEW_DATA__", safe_json_for_html(payload))
def atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
temp_path = Path(handle.name)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, path)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--changes",
default=".semantic-review/changes.json",
help="Collector JSON input",
)
parser.add_argument(
"--classification",
default=".semantic-review/classification.json",
help="Semantic classification JSON input",
)
parser.add_argument(
"--output",
default=".semantic-review/review.html",
help="Self-contained HTML output",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
changes_path = Path(args.changes).expanduser().resolve()
classification_path = Path(args.classification).expanduser().resolve()
output_path = Path(args.output).expanduser().resolve()
try:
repository, hunks = validate_changes(load_json(changes_path))
groups = validate_classification(load_json(classification_path), hunks)
atomic_write(output_path, render_html(build_payload(repository, hunks, groups)))
except (OSError, RenderError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"Rendered {len(groups)} groups and {len(hunks)} hunks -> {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())