feat: review semantic diff submission

This commit is contained in:
Marcos Silva
2026-09-04 14:06:50 -03:00
parent 73c3062062
commit ac4683d8df
8 changed files with 1430 additions and 2 deletions
@@ -0,0 +1,95 @@
---
name: semantic-diff-review
description: Inspect staged, unstaged, and untracked Git changes or the diff introduced by the latest or a specified commit; assign deterministic IDs to individual diff hunks; semantically group hunks by purpose; and generate a self-contained dark HTML review dashboard. Use when asked to review, organize, explain, or split local changes or a commit into semantic units without staging, reverting, committing, checking out revisions, or otherwise changing Git state.
---
# Semantic Diff Review
Create `.semantic-review/review.html` from real Git output. Review either current Git changes or one commit against its first parent. Keep Codex responsible only for semantic classification; delegate collection, validation, and HTML generation to the bundled deterministic Python scripts.
## Safety boundary
- Never run commands that change Git state, including `git add`, `git restore`, `git checkout`, `git reset`, `git commit`, `git stash`, `git clean`, `git update-index`, or temporary worktree/branch manipulation.
- Never hand-author, reconstruct, shorten, or correct patch text.
- Never generate HTML, CSS, or JavaScript during a review. Use `scripts/render_review.py` unchanged.
- Write only `.semantic-review/classification.json`; the collector writes `changes.json` and the renderer writes `review.html`.
- Treat `.semantic-review/changes.json` as immutable Git-derived evidence. Re-run the collector instead of editing it.
## Workflow
Set `SKILL_DIR` to this skill's directory and run every command from anywhere inside the target repository.
1. Choose exactly one review target and collect it:
Current staged, unstaged, and untracked changes:
```bash
python3 "$SKILL_DIR/scripts/collect_changes.py" --repo .
```
Latest commit (`HEAD`):
```bash
python3 "$SKILL_DIR/scripts/collect_changes.py" --repo . --commit
```
Specific commit hash or revision:
```bash
python3 "$SKILL_DIR/scripts/collect_changes.py" --repo . --commit <revision>
```
Use commit mode whenever the user asks for the latest commit, a commit hash, or a named revision. The collector resolves the revision to a commit and diffs it against its first parent; for a root commit it uses Git's empty tree. Commit mode ignores working-tree changes. Never check out, reset, stage, or otherwise expose a commit through working-tree mutation.
The collector finds the repository root, excludes `.semantic-review/`, assigns stable content-derived hunk IDs, and writes `.semantic-review/changes.json`. It uses only read-only Git commands and preserves patches directly from Git output.
2. Read `.semantic-review/changes.json`. Semantically classify every entry in `hunks` exactly once. Base grouping on intent and purpose, not merely file proximity. Keep separable concerns in separate groups; keep tests, docs, migrations, and configuration with the implementation they directly support when they form one coherent change.
3. Write `.semantic-review/classification.json` with exactly this shape:
```json
{
"schema_version": 1,
"groups": [
{
"title": "Concise semantic group title",
"purpose": "What this change accomplishes and why",
"risk": {
"level": "low",
"rationale": "Concrete failure modes or reasons risk is limited"
},
"review_points": [
"A specific behavior, edge case, or integration to verify"
],
"suggested_commit_message": "type(scope): concise imperative subject",
"hunk_ids": ["H-0123456789ABCDEF"]
}
]
}
```
Use only `low`, `medium`, or `high` for `risk.level`. Use `groups: []` when `hunks` is empty. Do not add patch, diff, source, code, HTML, CSS, or JavaScript fields. Do not copy source lines into semantic prose.
4. Render and validate the review:
```bash
python3 "$SKILL_DIR/scripts/render_review.py" \
--changes .semantic-review/changes.json \
--classification .semantic-review/classification.json \
--output .semantic-review/review.html
```
If validation reports missing, duplicate, or unknown hunk IDs, fix only `classification.json` and render again. If it reports changed or invalid collected evidence, re-run collection and classification.
5. Report the reviewed target, absolute path to `.semantic-review/review.html`, the number of semantic groups and hunks, and that Git state was left untouched. Do not open a browser unless the user asks.
## Classification guidance
- Describe purpose at the behavioral or architectural level.
- Assess risk from observable failure modes, compatibility, data handling, security boundaries, concurrency, migrations, and test coverage.
- Make review points actionable questions or checks rather than generic advice.
- Suggest one commit message per semantic group. Do not claim a commit was created.
- Prefer a small number of coherent groups, but never force unrelated hunks together.
- Preserve the collector's hunk IDs verbatim. They are the only link between semantic judgments and source patches.
The renderer rejects incomplete classifications and obtains every displayed patch exclusively from `changes.json`; model-authored text is inserted only as escaped semantic metadata.
@@ -0,0 +1,4 @@
interface:
display_name: "Semantic Diff Review"
short_description: "Review working changes or commits by intent"
default_prompt: "Use $semantic-diff-review to classify my current Git changes or a selected commit and generate the semantic review dashboard."
@@ -0,0 +1,540 @@
#!/usr/bin/env python3
"""Collect Git changes or one commit into deterministic, hunk-addressable JSON.
Only read-only Git commands are used. All patch strings in the output are byte-for-byte
decodings of Git diff stdout; the script never reconstructs source patches.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Sequence
SCHEMA_VERSION = 1
REVIEW_DIR = ".semantic-review"
EXCLUDE_PATHSPEC = ":(exclude).semantic-review/**"
DIFF_OPTIONS = (
"--no-ext-diff",
"--no-textconv",
"--no-color",
"--binary",
"--full-index",
"--find-renames=50%",
"--diff-algorithm=histogram",
"--unified=3",
"--src-prefix=a/",
"--dst-prefix=b/",
"--submodule=short",
)
HUNK_HEADER = re.compile(r"^(@{2,}) .*? \1(?:.*)(?:\r?\n)?$")
NORMALIZE_HEADER = re.compile(r"^(@{2,}) .*? \1(.*?)(\r?\n)?$")
class CollectionError(RuntimeError):
"""Raised when Git output cannot be collected safely."""
@dataclass(frozen=True)
class ChangedPath:
status: str
old_path: str
new_path: str
@dataclass
class PendingHunk:
scope: str
status: str
old_path: str
new_path: str
kind: str
header: str
patch: str
additions: int
deletions: int
sequence: int
identity_material: str = ""
hunk_id: str = ""
def git_env() -> dict[str, str]:
env = os.environ.copy()
env.update(
{
"LC_ALL": "C",
"LANG": "C",
"GIT_OPTIONAL_LOCKS": "0",
"GIT_PAGER": "cat",
"GIT_EXTERNAL_DIFF": "",
}
)
return env
def git_executable() -> str:
"""Return Git executable, with a narrowly named override for hermetic tests."""
return os.environ.get("SEMANTIC_REVIEW_GIT", "git")
def run_git(
repo: Path,
args: Sequence[str],
*,
allow_diff_exit: bool = False,
) -> bytes:
command = [git_executable(), "-C", os.fspath(repo), *args]
completed = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=git_env(),
check=False,
)
accepted = {0, 1} if allow_diff_exit else {0}
if completed.returncode not in accepted:
detail = completed.stderr.decode("utf-8", "replace").strip()
raise CollectionError(
f"Git command failed ({completed.returncode}): {' '.join(command)}"
+ (f"\n{detail}" if detail else "")
)
return completed.stdout
def repository_root(repo_arg: str) -> Path:
candidate = Path(repo_arg).expanduser().resolve()
output = run_git(candidate, ("rev-parse", "--show-toplevel"))
return Path(output.decode("utf-8", "surrogateescape").rstrip("\n")).resolve()
def head_oid(root: Path) -> str | None:
completed = subprocess.run(
[git_executable(), "-C", os.fspath(root), "rev-parse", "--verify", "HEAD"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=git_env(),
check=False,
)
if completed.returncode != 0:
return None
return completed.stdout.decode("ascii", "strict").strip()
def decode_path(raw: bytes) -> str:
return raw.decode("utf-8", "surrogateescape")
def parse_name_status(raw: bytes) -> list[ChangedPath]:
fields = raw.split(b"\0")
if fields and fields[-1] == b"":
fields.pop()
changes: list[ChangedPath] = []
index = 0
while index < len(fields):
status = fields[index].decode("ascii", "replace")
index += 1
if not status:
raise CollectionError("Git emitted an empty name-status record")
if status[0] in {"R", "C"}:
if index + 1 >= len(fields):
raise CollectionError("Git emitted a truncated rename/copy record")
old_path = decode_path(fields[index])
new_path = decode_path(fields[index + 1])
index += 2
else:
if index >= len(fields):
raise CollectionError("Git emitted a truncated name-status record")
path = decode_path(fields[index])
index += 1
old_path = path
new_path = path
changes.append(ChangedPath(status, old_path, new_path))
return changes
def literal_pathspec(path: str) -> str:
return f":(literal){path}"
def tracked_changes(root: Path, scope: str) -> list[ChangedPath]:
return compared_changes(root, scope, ())
def compared_changes(
root: Path,
scope: str,
comparison: Sequence[str],
) -> list[ChangedPath]:
cached = ("--cached",) if scope == "staged" else ()
output = run_git(
root,
(
"diff",
*cached,
*DIFF_OPTIONS,
"--name-status",
"-z",
*comparison,
"--",
".",
EXCLUDE_PATHSPEC,
),
)
return parse_name_status(output)
def tracked_patch(
root: Path,
scope: str,
change: ChangedPath,
comparison: Sequence[str] = (),
) -> str:
cached = ("--cached",) if scope == "staged" else ()
paths = [literal_pathspec(change.old_path)]
if change.new_path != change.old_path:
paths.append(literal_pathspec(change.new_path))
output = run_git(
root,
("diff", *cached, *DIFF_OPTIONS, *comparison, "--", *paths),
)
return output.decode("utf-8", "surrogateescape")
def untracked_paths(root: Path) -> list[str]:
output = run_git(
root,
(
"ls-files",
"--others",
"--exclude-standard",
"-z",
"--",
".",
EXCLUDE_PATHSPEC,
),
)
paths = [decode_path(item) for item in output.split(b"\0") if item]
return sorted(paths, key=lambda item: item.encode("utf-8", "surrogateescape"))
def untracked_patch(root: Path, path: str) -> str:
output = run_git(
root,
("diff", "--no-index", *DIFF_OPTIONS, "--", "/dev/null", path),
allow_diff_exit=True,
)
return output.decode("utf-8", "surrogateescape")
def is_hunk_header(line: str) -> bool:
return bool(HUNK_HEADER.match(line))
def normalize_hunk_header(header: str) -> str:
match = NORMALIZE_HEADER.match(header)
if not match:
return header.rstrip("\r\n")
marker, context, _newline = match.groups()
return f"{marker} {marker}{context}"
def line_stats(lines: Iterable[str]) -> tuple[int, int]:
additions = 0
deletions = 0
for line in lines:
if line.startswith("+") and not line.startswith("+++"):
additions += 1
elif line.startswith("-") and not line.startswith("---"):
deletions += 1
return additions, deletions
def split_patch(
scope: str,
change: ChangedPath,
patch: str,
) -> list[PendingHunk]:
lines = patch.splitlines(keepends=True)
starts = [index for index, line in enumerate(lines) if is_hunk_header(line)]
if not starts:
kind = "empty" if not patch else "binary-or-metadata"
additions, deletions = line_stats(lines)
return [
PendingHunk(
scope=scope,
status=change.status,
old_path=change.old_path,
new_path=change.new_path,
kind=kind,
header="",
patch=patch,
additions=additions,
deletions=deletions,
sequence=1,
)
]
prelude = "".join(lines[: starts[0]])
hunks: list[PendingHunk] = []
for sequence, start in enumerate(starts, start=1):
end = starts[sequence] if sequence < len(starts) else len(lines)
hunk_lines = lines[start:end]
additions, deletions = line_stats(hunk_lines[1:])
hunks.append(
PendingHunk(
scope=scope,
status=change.status,
old_path=change.old_path,
new_path=change.new_path,
kind="text",
header=hunk_lines[0].rstrip("\r\n"),
patch=prelude + "".join(hunk_lines),
additions=additions,
deletions=deletions,
sequence=sequence,
)
)
return hunks
def identity_material(hunk: PendingHunk) -> str:
lines = hunk.patch.splitlines(keepends=True)
if hunk.kind == "text":
first_hunk = next(
(index for index, line in enumerate(lines) if is_hunk_header(line)),
len(lines),
)
body = "".join(lines[first_hunk + 1 :])
content = normalize_hunk_header(hunk.header) + "\n" + body
else:
content = hunk.patch
return "\0".join(
(
hunk.scope,
hunk.status,
hunk.old_path,
hunk.new_path,
hunk.kind,
content,
)
)
def assign_ids(hunks: list[PendingHunk]) -> None:
buckets: dict[str, list[PendingHunk]] = {}
for hunk in hunks:
hunk.identity_material = identity_material(hunk)
digest = hashlib.sha256(
hunk.identity_material.encode("utf-8", "surrogateescape")
).hexdigest().upper()
buckets.setdefault(digest, []).append(hunk)
used: set[str] = set()
for digest in sorted(buckets):
bucket = buckets[digest]
if len(bucket) == 1:
candidates = [(bucket[0], f"H-{digest[:16]}")]
else:
candidates = []
for hunk in bucket:
discriminator = hashlib.sha256(
(hunk.header + "\0" + hunk.patch).encode(
"utf-8", "surrogateescape"
)
).hexdigest().upper()
candidates.append((hunk, f"H-{digest[:12]}-{discriminator[:8]}"))
candidates.sort(key=lambda pair: (pair[1], pair[0].sequence))
for duplicate_index, (hunk, candidate) in enumerate(candidates, start=1):
hunk_id = candidate
if hunk_id in used:
hunk_id = f"{candidate}-{duplicate_index}"
if hunk_id in used:
raise CollectionError("Unable to assign unique stable hunk IDs")
hunk.hunk_id = hunk_id
used.add(hunk_id)
def collect_worktree(root: Path) -> list[PendingHunk]:
hunks: list[PendingHunk] = []
for scope in ("staged", "unstaged"):
for change in tracked_changes(root, scope):
hunks.extend(split_patch(scope, change, tracked_patch(root, scope, change)))
for path in untracked_paths(root):
change = ChangedPath("A", "/dev/null", path)
hunks.extend(split_patch("untracked", change, untracked_patch(root, path)))
assign_ids(hunks)
return hunks
def resolve_commit(root: Path, revision: str) -> str:
if not revision.strip():
raise CollectionError("Commit revision must not be empty")
output = run_git(
root,
("rev-parse", "--verify", "--end-of-options", f"{revision}^{{commit}}"),
)
return output.decode("ascii", "strict").strip()
def commit_base(root: Path, commit_oid: str) -> str:
output = run_git(root, ("rev-list", "--parents", "-n", "1", commit_oid))
parts = output.decode("ascii", "strict").strip().split()
if not parts or parts[0] != commit_oid:
raise CollectionError(f"Unable to resolve parents for commit {commit_oid}")
if len(parts) > 1:
return parts[1]
empty_tree = run_git(root, ("hash-object", "-t", "tree", "/dev/null"))
return empty_tree.decode("ascii", "strict").strip()
def collect_commit(
root: Path,
revision: str,
) -> tuple[list[PendingHunk], str, str]:
commit_oid = resolve_commit(root, revision)
base_oid = commit_base(root, commit_oid)
comparison = (base_oid, commit_oid)
hunks: list[PendingHunk] = []
for change in compared_changes(root, "commit", comparison):
hunks.extend(
split_patch(
"commit",
change,
tracked_patch(root, "commit", change, comparison),
)
)
assign_ids(hunks)
return hunks, commit_oid, base_oid
def patch_sha256(patch: str) -> str:
return hashlib.sha256(patch.encode("utf-8", "surrogateescape")).hexdigest()
def build_document(
root: Path,
hunks: list[PendingHunk],
target: dict[str, str],
) -> dict[str, object]:
records = [
{
"id": hunk.hunk_id,
"scope": hunk.scope,
"status": hunk.status,
"old_path": hunk.old_path,
"new_path": hunk.new_path,
"kind": hunk.kind,
"header": hunk.header,
"additions": hunk.additions,
"deletions": hunk.deletions,
"patch_sha256": patch_sha256(hunk.patch),
"patch": hunk.patch,
}
for hunk in hunks
]
evidence = json.dumps(records, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return {
"schema_version": SCHEMA_VERSION,
"generator": "semantic-diff-review/collect_changes.py",
"repository": {
"root": os.fspath(root),
"head": head_oid(root),
"target": target,
},
"evidence_sha256": hashlib.sha256(evidence.encode("ascii")).hexdigest(),
"hunks": records,
}
def atomic_write_json(path: Path, document: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
rendered = json.dumps(document, ensure_ascii=True, indent=2, sort_keys=False) + "\n"
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(rendered)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, path)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", default=".", help="Path inside the Git repository")
parser.add_argument(
"--output",
help="Output path (default: <repo>/.semantic-review/changes.json)",
)
parser.add_argument(
"--commit",
nargs="?",
const="HEAD",
metavar="REV",
help=(
"Collect one commit against its first parent instead of working-tree "
"changes; omit REV to review HEAD"
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
root = repository_root(args.repo)
output = (
Path(args.output).expanduser().resolve()
if args.output
else root / REVIEW_DIR / "changes.json"
)
if args.commit is None:
hunks = collect_worktree(root)
target = {"kind": "working-tree"}
else:
hunks, commit_oid, base_oid = collect_commit(root, args.commit)
target = {
"kind": "commit",
"revision": args.commit,
"commit": commit_oid,
"base": base_oid,
}
atomic_write_json(output, build_document(root, hunks, target))
except (CollectionError, OSError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if args.commit is None:
counts = {
scope: sum(1 for hunk in hunks if hunk.scope == scope)
for scope in ("staged", "unstaged", "untracked")
}
detail = (
f"{counts['staged']} staged, {counts['unstaged']} unstaged, "
f"{counts['untracked']} untracked"
)
else:
detail = f"commit {commit_oid} against {base_oid}"
print(f"Collected {len(hunks)} hunks ({detail}) -> {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -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())