feat: migrate skills review desk to astro
This commit is contained in:
@@ -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.
|
||||
+4
@@ -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."
|
||||
+540
@@ -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())
|
||||
+757
@@ -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())
|
||||
Reference in New Issue
Block a user