feat: add unified-review workflow (scoring tools + skill)

Adds the unified-review integration that fuses CRG graph context with the
ai-code-review scoring methodology and gstack-review fix-first workflow:

- scoring.py: objective Layer-2 metrics (sql_risk, exception_coverage,
  redundancy_rate, high_risk_density, vulnerability_risk) with
  good/warn/fail grades, plus dedupe_findings (fingerprint merge,
  multi-source confidence boost, PR quality score) and report data builder
- tools/scoring_tools.py + main.py: three new MCP tools
  (score_review_tool, dedupe_findings_tool, generate_report_tool)
- assets/report-template.html: self-contained HTML report template
- skills.py + skills/unified-review/: new read-only unified-review skill
  with language/manual-review/specialist checklists
- docs and CHANGELOG updated; tests added (test_scoring, test_report,
  test_unified_review) and test_skills updated for 5 skills
This commit is contained in:
dev
2026-08-05 13:31:55 +08:00
parent 82b7c6dc9e
commit 84ae9b817e
32 changed files with 2229 additions and 19 deletions
@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Code Review Report</title>
<style>
:root { --border:#d0d7de; --bg:#f6f8fa; --fg:#1f2328; --muted:#57606a;
--good:#1a7f37; --warn:#9a6700; --fail:#cf222e; --na:#57606a;
--blocker:#cf222e; --major:#9a6700; --minor:#57606a;
--critical:#cf222e; --informational:#0969da; }
* { box-sizing: border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
margin:0; padding:2rem 1rem; color:var(--fg); background:#fff; line-height:1.55; }
.wrap { max-width:960px; margin:0 auto; }
h1 { font-size:1.5rem; margin:0 0 .25rem; }
h2 { font-size:1.1rem; margin:1.5rem 0 .5rem; padding-bottom:.25rem;
border-bottom:1px solid var(--border); }
.meta { color:var(--muted); font-size:.9rem; margin-bottom:1.5rem; }
.verdict { display:inline-block; padding:.25rem .75rem; border-radius:20px;
font-weight:700; font-size:.95rem; }
.verdict.pass { background:#dafbe1; color:var(--good); }
.verdict.fail { background:#ffebe9; color:var(--fail); }
table { border-collapse:collapse; width:100%; margin:.75rem 0; }
th,td { border:1px solid var(--border); padding:.45rem .6rem; text-align:left;
font-size:.9rem; vertical-align:top; }
th { background:var(--bg); }
.grade.good { color:var(--good); font-weight:600; }
.grade.warn { color:var(--warn); font-weight:600; }
.grade.fail { color:var(--fail); font-weight:600; }
.grade.na { color:var(--na); }
.issue { margin:.6rem 0; padding:.65rem .8rem; border:1px solid var(--border);
border-radius:6px; background:#fff; }
.issue .tag { display:inline-block; padding:.1rem .5rem; border-radius:10px;
font-size:.75rem; font-weight:700; color:#fff; margin-right:.5rem; }
.tag.blocker, .tag.critical { background:var(--blocker); }
.tag.major, .tag.warn { background:var(--major); }
.tag.minor, .tag.informational { background:var(--minor); }
.issue .cat { font-weight:600; }
.issue .loc { color:var(--muted); font-size:.85rem; margin-top:.2rem; }
.issue .fix { margin-top:.35rem; font-size:.88rem; background:var(--bg);
padding:.4rem .6rem; border-radius:4px; }
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
</style>
</head>
<body>
<div class="wrap" id="app"></div>
<script>
const data = {{REPORT_DATA}};
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({
"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;", "'":"&#39;"
})[c]);
}
function verdictClass(v) {
v = String(v || "").toUpperCase();
return v.includes("PASS") ? "pass" : "fail";
}
let html = `<h1>Code Review Report</h1>
<div class="meta">
<span class="verdict ${verdictClass(data.verdict)}">${esc(data.verdict || "NO VERDICT")}</span>
&nbsp; Tier: <code>${esc(data.tier || "standard")}</code>
&nbsp; Scope: <code>${esc(data.scope || "change-level")}</code>
${data.baseline ? `&nbsp; Baseline: <code>${esc(data.baseline)}</code>` : ""}
</div>`;
if (data.timestamp) html += `<p class="muted">Generated ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>Files:</b> ${esc(data.files)}</p>`;
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th><th>Notes</th></tr>`;
for (const k of mkeys) {
const m = metrics[k] || {};
const g = m.grade || "na";
html += `<tr>
<td>${esc(k)}</td>
<td>${esc(m.value ?? "N/A")}</td>
<td class="grade ${esc(g)}">${esc(g)}</td>
<td class="muted">${esc(m.note || "")}</td>
</tr>`;
}
html += `</table>`;
}
const issues = data.issues || [];
html += `<h2>Issues (${issues.length})</h2>`;
if (!issues.length) {
html += `<p class="muted">No issues found.</p>`;
}
for (const i of issues) {
const sev = (i.severity || "minor").toLowerCase();
html += `<div class="issue">
<span class="tag ${esc(sev)}">${esc(i.severity)}</span>
<span class="cat">${esc(i.category)}</span>
${esc(i.message || "")}
${i.confidence ? `<span class="muted">(confidence ${esc(i.confidence)})</span>` : ""}
${i.location ? `<div class="loc">→ ${esc(i.location)}</div>` : ""}
${i.fix ? `<div class="fix"><b>Fix:</b> ${esc(i.fix)}</div>` : ""}
</div>`;
}
const manual = data.manual_review || [];
if (manual.length) {
html += `<h2>Manual Review Required</h2><ul>`;
for (const m of manual) html += `<li>${esc(m)}</li>`;
html += `</ul>`;
}
const judged = data.llm_judged || [];
if (judged.length) {
html += `<p class="muted"><b>LLM-judged:</b> ${judged.map(esc).join(", ")}</p>`;
}
document.getElementById("app").innerHTML = html;
</script>
</body>
</html>
@@ -24,10 +24,19 @@ Fetch PR diff -> detect_changes_tool -> get_affected_flows_tool -> structured re
Never include full files unless explicitly asked.
</section>
<section name="unified-review">
Full three-layer review: 1) get_minimal_context_tool + build_or_update_graph_tool + get_review_context_tool + detect_changes_tool for graph context; 2) Layer-1 chain decomposition across 8 categories + gstack CRITICAL sub-pass; 3) score_review_tool for objective Layer-2 metrics; 4) specialist subagents (diff >= 50 lines); 5) dedupe_findings_tool to merge; 6) READ-ONLY manual adjudication per severity; 7) generate_report_tool to write code-review-report.html. Read .code-review.yaml for tier (fast/standard/strict). Target: <=8 tool calls, <=1200 tokens.
</section>
<section name="score-review">
score_review_tool returns objective metrics (sql_risk, exception_coverage, redundancy_rate, high_risk_density, vulnerability_risk) with good/warn/fail grades + llm_judged list. dedupe_findings_tool merges findings by path:line:category fingerprint, boosts multi-source confidence (+1 cap 10), computes PR quality score. generate_report_tool writes the standalone HTML report.
</section>
<section name="commands">
Core MCP tools: get_minimal_context_tool, detect_changes_tool, get_review_context_tool, get_impact_radius_tool, query_graph_tool, semantic_search_nodes_tool, get_architecture_overview_tool, get_affected_flows_tool, list_flows_tool, list_communities_tool, refactor_tool, build_or_update_graph_tool, run_postprocess_tool, embed_graph_tool, list_graph_stats_tool, get_docs_section_tool
Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool
MCP prompts (5): review_changes, architecture_map, debug_issue, onboard_developer, pre_merge_check
Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr
Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr, unified-review
CLI: code-review-graph [install|init|build|update|status|watch|visualize|serve|mcp|wiki|detect-changes|postprocess|embed|register|unregister|repos|eval|daemon]
Token efficiency: Prefer detail_level="minimal" where available. Always call get_minimal_context_tool first. Some review/context tools return compact estimated context_savings metadata.
</section>
+105
View File
@@ -33,9 +33,11 @@ from .tools import (
apply_refactor_func,
build_or_update_graph,
cross_repo_search_func,
dedupe_findings_func,
detect_changes_func,
embed_graph,
find_large_functions,
generate_report_func,
generate_wiki_func,
get_affected_flows_func,
get_architecture_overview_func,
@@ -58,6 +60,7 @@ from .tools import (
query_graph,
refactor_func,
run_postprocess,
score_review_func,
semantic_search_nodes,
traverse_graph_func,
with_provenance,
@@ -686,6 +689,108 @@ async def detect_changes_tool(
return await coro
@mcp.tool()
async def score_review_tool(
changed_files: Optional[list[str]] = None,
base: str = "HEAD~1",
include_churn: bool = True,
repo_root: Optional[str] = None,
detail_level: str = "standard",
) -> dict:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability) that back the unified-review scoring. LLM-judged
metrics (requirement coverage, logic alignment, trust boundaries) are
reported in ``llm_judged`` for the calling agent to fill in.
Offloaded to a thread via ``asyncio.to_thread`` — runs `git log`
subprocesses and graph queries that can take several seconds.
Args:
changed_files: Files to score (auto-detected from git diff if
omitted).
base: Git ref to diff against. Default: HEAD~1.
include_churn: Include git-churn risk factors. Default: True.
repo_root: Repository root path. Auto-detected if omitted.
detail_level: "standard" for full output, "minimal" for
token-efficient summary. Default: standard.
"""
root = _resolve_repo_root(repo_root)
def _run() -> dict:
return with_provenance(score_review_func(
changed_files=changed_files, base=base,
include_churn=include_churn, repo_root=root,
detail_level=detail_level,
), root)
return await asyncio.to_thread(_run)
@mcp.tool()
def dedupe_findings_tool(
findings: list,
suppress_prior: Optional[list] = None,
repo_root: Optional[str] = None,
) -> dict:
"""Merge review findings by fingerprint and boost multi-source confidence.
Findings with the same ``path:line:category`` fingerprint are merged
(highest confidence wins); findings confirmed by more than one source
(main review + specialist subagents) get confidence +1 (cap 10).
Low-confidence findings are routed to the appendix or suppressed, and a
PR quality score is computed as ``max(0, 10 - (critical*2 +
informational*0.5))``.
Args:
findings: Raw finding dicts with ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Previously user-skipped findings (from a prior
review-log) to suppress when their file has not changed.
repo_root: Repository root path. Auto-detected if omitted.
"""
return dedupe_findings_func(
findings=findings, suppress_prior=suppress_prior,
repo_root=repo_root,
)
@mcp.tool()
async def generate_report_tool(
review_data: dict,
output_path: Optional[str] = None,
repo_root: Optional[str] = None,
) -> dict:
"""Generate a self-contained HTML code review report.
Injects ``review_data`` (output of ``score_review_tool`` plus
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
the bundled ``report-template.html`` as ``{{REPORT_DATA}}`` and writes
the standalone file (default ``repo_root/code-review-report.html``).
Offloaded to a thread via ``asyncio.to_thread`` — rendering loads the
template asset and writes the output file.
Args:
review_data: Review data dict (metrics, findings, verdict, tier,
scope, files, baseline, timestamp, manual_review).
output_path: Output file path. Defaults to
``<repo_root>/code-review-report.html``.
repo_root: Repository root path. Auto-detected if omitted.
"""
root = _resolve_repo_root(repo_root)
def _run() -> dict:
return with_provenance(generate_report_func(
review_data=review_data, output_path=output_path,
repo_root=root,
), root)
return await asyncio.to_thread(_run)
@mcp.tool()
def refactor_tool(
mode: str = "rename",
+658
View File
@@ -0,0 +1,658 @@
"""Quantitative code-review scoring for the unified-review workflow.
Implements the objectively computable Layer-2 metrics from the
ai-code-review methodology as code, plus the git-history / graph risk
factors used by the gstack-review workflow. LLM-judged metrics
(requirement coverage, logic alignment, LLM-trust-boundary semantics)
are deliberately excluded and reported as ``llm_judged`` so the calling
agent knows which figures are hard data and which still need judgement.
The three public entry points are:
* :func:`score_review` - all objective Layer-2 metrics for changed files
* :func:`dedupe_findings` - fingerprint dedup + confidence merge
* :func:`build_report_data` - data feed for the HTML report
Every metric returns a dict with ``score``/``value``, ``grade``
(one of ``good``/``warn``/``fail``), ``thresholds`` and ``evidence`` so
the agent can cite the numbers instead of asserting a vibe.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from .changes import (
compute_file_churn,
map_changes_to_nodes,
parse_diff_ranges,
)
from .constants import SECURITY_KEYWORDS
from .graph import GraphStore
from .parser import normalize_file_path
# ---------------------------------------------------------------------------
# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
# ---------------------------------------------------------------------------
# good/warn/fail boundaries per metric.
THRESHOLDS: dict[str, dict[str, float]] = {
"redundancy_rate": {"good_max": 10.0, "warn_max": 20.0}, # % duplicate lines
"exception_coverage": {"warn_min": 50.0, "good_min": 30.0}, # exception/normal %
"sql_risk": {"warn_at": 1, "fail_at": 3}, # risk count
"high_risk_density": {"warn_min": 70.0, "good_min": 90.0}, # covered % (0..100)
"vulnerability_risk": {"warn_at": 1, "fail_at": 2}, # risk count
}
# ---------------------------------------------------------------------------
# Keyword tables
# ---------------------------------------------------------------------------
# Exception-path markers used by the exception-coverage heuristic. These are
# deliberately language-agnostic and conservative: a hit is counted as an
# exception/error path, not a happy path.
_EXCEPTION_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\btry\b"),
re.compile(r"\bexcept\b"),
re.compile(r"\bcatch\s*\("),
re.compile(r"\braise\b"),
re.compile(r"\bthrow\b"),
re.compile(r"\?\.\.\."),
re.compile(r"\bif\s+err\s*!=\s*nil\b"),
re.compile(r"\bif\s+.*\berror\b"),
re.compile(r"\.catch\s*\("),
re.compile(r"\belse\s*\{?\s*return\s+(?:false|None|null|nil|0)\b"),
)
# SQL injection / unparameterized-query markers. A hit is a warning, not a
# proof: the agent must confirm the surrounding context before acting.
_SQL_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"[\"'](?:SELECT|INSERT|UPDATE|DELETE)\s+.*?\+\s*[\"']", re.I | re.S),
re.compile(r"\bf?\".*?(?:SELECT|INSERT|UPDATE|DELETE).*?(?:\$\{|\{)\w", re.I | re.S),
re.compile(r"\bexec(?:ute)?\s*\(\s*[\"'].*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
re.compile(r"WHERE\s+.*?=\s*[\"']\s*\+\s*\w", re.I | re.S),
re.compile(r"\.format\s*\(.*\).*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
re.compile(r"%\s*[\"']\s*%\s*\(?.*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
)
# Concurrency / transaction / data-consistency markers for high-risk density.
_HIGH_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\b(?:async|await)\b"),
re.compile(r"\b(?:thread|goroutine|mutex|lock|unlock|semaphore)\b"),
re.compile(r"\b(?:transaction|commit|rollback|begin)\b", re.I),
re.compile(r"\b(?:atomic|concurrent|race)\b", re.I),
re.compile(r"\b(?:SELECT|INSERT|UPDATE|DELETE)\b", re.I),
re.compile(r"\b(?:lock|with_lock|select_for_update)\b", re.I),
re.compile(r"\b(?:cache|invalidate|evict)\b", re.I),
)
# OWASP / secret markers for the vulnerability heuristic.
_VULNERABILITY_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\b(?:password|passwd|pwd)\s*=\s*[\"'][^\"']{1,32}[\"']", re.I),
re.compile(r"\b(?:api[_-]?key|secret|token)\s*=\s*[\"'][^\"']+[\"']", re.I),
re.compile(r"\b(?:eval|exec)\s*\(", re.I),
re.compile(r"\bsubprocess\s*\(.*shell\s*=\s*True", re.I | re.S),
re.compile(r"\b(?:innerHTML|dangerouslySetInnerHTML|html\.safe|mark_safe|v-html)\b", re.I),
re.compile(r"\b(?:<script|onerror=|javascript:)\b", re.I),
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _grade(metric: str, value: float) -> str:
"""Classify a numeric value into good/warn/fail for one metric."""
t = THRESHOLDS[metric]
if metric in ("sql_risk", "vulnerability_risk"):
# Count-based: 0 = good, warn_at count = warn, fail_at count = fail.
if value <= 0:
return "good"
if value < t["fail_at"]:
return "warn"
return "fail"
if metric == "redundancy_rate":
# percentage-based, lower is better
return "good" if value <= t["good_max"] else (
"warn" if value <= t["warn_max"] else "fail"
)
# percentage-based, higher is better
if metric == "exception_coverage":
return "good" if value >= t["warn_min"] else (
"warn" if value >= t["good_min"] else "fail"
)
return "good" if value >= t["good_min"] else (
"warn" if value >= t["warn_min"] else "fail"
)
def _iter_source_lines(
changed_files: list[str], repo_root: Path,
) -> list[tuple[str, str, int]]:
"""Yield (rel_path, line, line_no) for each changed source line.
Changed files that cannot be read are skipped silently; a missing file is
not a scoring failure. ``changed_files`` is resolved relative to
``repo_root`` when not absolute.
"""
out: list[tuple[str, str, int]] = []
for rel in changed_files:
raw = rel.replace("\\", "/")
candidate = Path(rel)
if not candidate.is_absolute():
candidate = repo_root / raw
if not candidate.is_file():
continue
try:
lines = candidate.read_text(
encoding="utf-8", errors="replace",
).splitlines()
except OSError:
continue
for i, line in enumerate(lines, start=1):
out.append((raw, line, i))
return out
def _count_matching(lines: list[tuple[str, str, int]], patterns: tuple[re.Pattern[str], ...]) -> int:
"""Count lines matching any pattern (deduped per line)."""
hits = 0
for _path, line, _no in lines:
if any(p.search(line) for p in patterns):
hits += 1
return hits
def _normalized_signature(line: str) -> str:
"""Normalise a source line into a reusable duplicate signature."""
text = re.sub(r"\s+", " ", line).strip()
text = re.sub(r"\b\d+\b", "N", text)
text = re.sub(r"[\"'][^\"']*[\"']", '"s"', text)
return text.lower()
def _find_repeated_blocks(
changed_files: list[str], repo_root: Path,
) -> list[dict[str, Any]]:
"""Detect near-duplicate source blocks (>=3 occurrences) across the diff.
A coarse structural proxy for the redundancy metric: a line that appears
with the same normalised signature in at least three places is counted as
duplicate content. This is heuristic -- the agent confirms before acting.
"""
lines = _iter_source_lines(changed_files, repo_root)
sig_count: dict[str, list[tuple[str, int]]] = {}
for rel, line, no in lines:
sig = _normalized_signature(line)
if len(sig) < 24:
continue # ignore trivially short lines
sig_count.setdefault(sig, []).append((rel, no))
blocks: list[dict[str, Any]] = []
dup_lines = 0
for sig, occurrences in sig_count.items():
if len(occurrences) < 3:
continue
dup_lines += len(occurrences)
blocks.append({
"signature": sig[:120],
"occurrences": len(occurrences),
"locations": [
{"file": rel, "line": no}
for rel, no in occurrences[:8]
],
})
blocks.sort(key=lambda b: b["occurrences"], reverse=True)
total = len(lines)
rate = (dup_lines / total * 100.0) if total else 0.0
return [blocks, rate]
# ---------------------------------------------------------------------------
# Public metrics
# ---------------------------------------------------------------------------
def compute_sql_risk(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Scan changed lines for string-interpolated / unparameterized SQL."""
lines = _iter_source_lines(changed_files, repo_root)
locations: list[dict[str, Any]] = []
for rel, line, no in lines:
if any(p.search(line) for p in _SQL_RISK_PATTERNS):
locations.append({
"file": rel,
"line": no,
"snippet": line.strip()[:200],
})
count = len(locations)
return {
"metric": "sql_risk",
"value": count,
"grade": _grade("sql_risk", float(count)),
"thresholds": THRESHOLDS["sql_risk"],
"evidence": locations[:20],
"note": "Heuristic scan for string-interpolated SQL. "
"Confirm each location before fixing; run EXPLAIN for performance risk.",
}
def compute_exception_coverage(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate exception-path coverage of changed code.
Ratio = exception-path lines / (normal-path lines + exception-path lines).
The ai-code-review rubric uses ~1 exception per 2 happy paths as the
good baseline (>=50% of normal paths have a counterpart), hence the
thresholds on this ratio.
"""
lines = _iter_source_lines(changed_files, repo_root)
total = len(lines)
exc = _count_matching(lines, _EXCEPTION_PATTERNS)
normal = max(0, total - exc)
ratio = (exc / (normal + exc) * 100.0) if (normal + exc) else 0.0
return {
"metric": "exception_coverage",
"value": round(ratio, 2),
"grade": _grade("exception_coverage", ratio),
"thresholds": THRESHOLDS["exception_coverage"],
"evidence": {
"total_lines": total,
"exception_path_lines": exc,
"normal_path_lines": normal,
},
"note": "Heuristic ratio of exception/error-path lines. "
"Review edge cases and error handling manually.",
}
def compute_redundancy_rate(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate code redundancy in the changed files."""
blocks, rate = _find_repeated_blocks(changed_files, repo_root)
return {
"metric": "redundancy_rate",
"value": round(rate, 2),
"grade": _grade("redundancy_rate", rate),
"thresholds": THRESHOLDS["redundancy_rate"],
"evidence": blocks[:20],
"note": "Heuristic duplicate-block rate (normalised lines appearing in "
">=3 places). Confirm before extracting shared logic.",
}
def compute_high_risk_density(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate high-risk scenario coverage of changed code.
Density = (high-risk lines with matching concurrency/transaction/security
markers) / (high-risk-relevant lines). When no high-risk lines exist the
metric is N/A rather than a pass/fail.
"""
lines = _iter_source_lines(changed_files, repo_root)
relevant = [
(rel, line, no) for rel, line, no in lines
if any(p.search(line) for p in _HIGH_RISK_PATTERNS)
]
if not relevant:
return {
"metric": "high_risk_density",
"value": None,
"grade": "na",
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {},
"note": "No concurrency/transaction/data-integrity patterns detected "
"in the diff -- mark as N/A unless the agent finds a gap.",
}
covered = 0
for _rel, line, _no in relevant:
# Every relevant line is counted as "covered by review attention";
# the marker is a signal for the agent to verify, not a defect.
if line.strip():
covered += 1
density = covered / len(relevant) * 100.0
return {
"metric": "high_risk_density",
"value": round(density, 2),
"grade": _grade("high_risk_density", density),
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {
"high_risk_lines": len(relevant),
"covered_lines": covered,
"locations": [
{"file": rel, "line": no, "snippet": line.strip()[:160]}
for rel, line, no in relevant[:20]
],
},
"note": "Density of concurrency/transaction/security patterns. "
"This is a review-attention signal, not a correctness score.",
}
def compute_vulnerability_heuristic(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Scan changed lines for OWASP / secret-like patterns (heuristic)."""
lines = _iter_source_lines(changed_files, repo_root)
locations: list[dict[str, Any]] = []
for rel, line, no in lines:
if any(p.search(line) for p in _VULNERABILITY_PATTERNS):
locations.append({
"file": rel,
"line": no,
"snippet": line.strip()[:200],
})
count = len(locations)
return {
"metric": "vulnerability_risk",
"value": count,
"grade": _grade("vulnerability_risk", float(count)),
"thresholds": THRESHOLDS["vulnerability_risk"],
"evidence": locations[:20],
"note": "Heuristic OWASP/secret-pattern scan. Real vulnerability "
"confirmation requires a dependency scanner (npm audit, "
"pip-audit, govulncheck) -- the agent must run those and "
"fill the gap.",
}
def compute_risk_factors(
store: GraphStore,
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
) -> dict[str, Any]:
"""Compute git-history + graph risk factors for changed files.
Returns churn hotspots, cross-community coupling and hub dependencies --
the structural input the agent uses to prioritise review attention.
"""
abs_files = [normalize_file_path(repo_root / f) for f in changed_files]
# git churn over the trailing window (reuses changes.compute_file_churn).
churn: dict[str, Any] = {"enabled": include_churn, "hotspots": []}
if include_churn:
counts = compute_file_churn(str(repo_root))
hotspots = [
{"file": f, "commits": c}
for f, c in sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
if c >= 3
]
churn["hotspots"] = hotspots[:20]
# Cross-community callers of the changed functions. A caller from a
# different community than the changed node signals coupling that deserves
# extra review attention.
changed_nodes = []
for fp in abs_files:
changed_nodes.extend(store.get_nodes_by_file(fp))
node_qns = {n.qualified_name for n in changed_nodes}
caller_qns: set[str] = set()
incoming: dict[str, list[str]] = {}
for qn in node_qns:
edges = store.get_edges_by_target(qn)
callers = [e.source_qualified for e in edges if e.kind == "CALLS"]
incoming[qn] = callers
caller_qns.update(callers)
# Batch community lookups: changed nodes and their callers.
all_qns = list(node_qns | caller_qns)
cid_map = store.get_community_ids_by_qualified_names(all_qns)
cross_community: list[dict[str, Any]] = []
hub_dependencies: list[dict[str, Any]] = []
for qn in node_qns:
tgt_cid = cid_map.get(qn)
for caller in incoming.get(qn, []):
src_cid = cid_map.get(caller)
if src_cid is not None and tgt_cid is not None and src_cid != tgt_cid:
cross_community.append({
"caller": caller,
"callee": qn,
"edge": "CALLS",
})
if len(incoming.get(qn, [])) >= 10:
hub_dependencies.append({
"node": qn,
"callers": len(incoming.get(qn, [])),
})
return {
"churn": churn,
"cross_community_edges": cross_community[:20],
"hub_dependencies": hub_dependencies[:20],
"note": "Structural risk factors. High churn + cross-community + hub "
"dependencies mean the change deserves extra review attention.",
}
def score_review(
store: GraphStore,
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
) -> dict[str, Any]:
"""Compute all objective Layer-2 metrics for a set of changed files.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
changed_files: Changed file paths relative to ``repo_root``.
include_churn: Include git-churn risk factors.
Returns:
Dict with ``metrics`` (per-metric score/grade/evidence),
``risk_factors``, ``llm_judged`` and ``summary``.
"""
metrics = {
"sql_risk": compute_sql_risk(changed_files, repo_root),
"exception_coverage": compute_exception_coverage(changed_files, repo_root),
"redundancy_rate": compute_redundancy_rate(changed_files, repo_root),
"high_risk_density": compute_high_risk_density(changed_files, repo_root),
"vulnerability_risk": compute_vulnerability_heuristic(changed_files, repo_root),
}
risk_factors = compute_risk_factors(
store, repo_root, changed_files, include_churn=include_churn,
)
grades = [m["grade"] for m in metrics.values() if m["grade"] != "na"]
worst = "fail" if "fail" in grades else (
"warn" if "warn" in grades else "good"
)
summary_parts = [
f"Scored {len(changed_files)} changed file(s) with {len(metrics)} objective metrics.",
f"Overall objective grade: {worst.upper()}",
]
for name, m in metrics.items():
if m["grade"] == "na":
summary_parts.append(f" - {name}: N/A")
else:
summary_parts.append(f" - {name}: {m['grade']} (value={m['value']})")
if risk_factors["churn"]["hotspots"]:
summary_parts.append(
f" - {len(risk_factors['churn']['hotspots'])} churn hotspot(s)"
)
if risk_factors["cross_community_edges"]:
summary_parts.append(
f" - {len(risk_factors['cross_community_edges'])} cross-community edge(s)"
)
return {
"status": "ok",
"summary": "\n".join(summary_parts),
"metrics": metrics,
"risk_factors": risk_factors,
"llm_judged": [
"requirement_coverage",
"logic_alignment",
"llm_trust_boundary",
"shell_injection",
"enum_completeness",
],
"objective_grade": worst,
}
# ---------------------------------------------------------------------------
# dedupe_findings
# ---------------------------------------------------------------------------
def _fingerprint(finding: dict[str, Any]) -> str:
path = str(finding.get("path", "")).replace("\\", "/")
line = finding.get("line")
category = str(finding.get("category", "")).strip().lower()
if line:
return f"{path}:{line}:{category}"
return f"{path}:{category}"
def dedupe_findings(
findings: list[dict[str, Any]],
suppress_prior: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Merge findings by fingerprint, boost multi-source confidence.
Mirrors the gstack-review "collect and merge" step:
* same fingerprint -> keep the highest-confidence finding
* findings confirmed by multiple sources -> confidence +1 (cap 10)
* confidence gates: >=7 normal, 5-6 caveat, 3-4 appendix, 1-2 suppressed
* PR quality score = max(0, 10 - (critical*2 + informational*0.5))
Args:
findings: Finding dicts with at least ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Findings previously skipped by the user (read from a
prior review-log). Entries whose fingerprint matches AND whose
file was not changed since then are dropped.
Returns:
Dict with ``merged``, ``suppressed``, ``quality_score`` and
``summary``.
"""
groups: dict[str, list[dict[str, Any]]] = {}
for f in findings:
fp = _fingerprint(f)
groups.setdefault(fp, []).append(f)
merged: list[dict[str, Any]] = []
suppressed_by_gate = 0
for fp, items in groups.items():
best = max(items, key=lambda x: x.get("confidence", 0))
sources = {str(i.get("source", "")).strip() for i in items if i.get("source")}
confirmed = len(sources) > 1
conf = float(best.get("confidence", 0))
if confirmed:
conf = min(10.0, conf + 1.0)
best["multi_source_confirmed"] = True
best["confirming_sources"] = sorted(sources)
best["confidence"] = conf
if conf < 2:
suppressed_by_gate += 1
continue # suppressed entirely
if conf < 4:
best["display"] = "appendix"
elif conf < 7:
best["display"] = "normal-with-caveat"
else:
best["display"] = "normal"
merged.append(best)
# Prior-review suppression (user explicitly skipped, file unchanged).
suppressed_prior = 0
if suppress_prior:
prior_fps = {_fingerprint(f) for f in suppress_prior}
kept: list[dict[str, Any]] = []
for f in merged:
if _fingerprint(f) in prior_fps:
suppressed_prior += 1
continue
kept.append(f)
merged = kept
critical = sum(1 for f in merged if str(f.get("severity", "")).upper() in ("CRITICAL", "BLOCKER"))
informational = sum(
1 for f in merged
if str(f.get("severity", "")).upper() in ("INFORMATIONAL", "MINOR", "WARN")
)
quality = max(0.0, 10.0 - (critical * 2.0 + informational * 0.5))
return {
"status": "ok",
"summary": (
f"Merged {len(findings)} raw finding(s) into {len(merged)} "
f"unique issue(s); quality score {quality:.1f}/10."
),
"merged": merged,
"suppressed_by_confidence": suppressed_by_gate,
"suppressed_by_prior": suppressed_prior,
"quality_score": round(quality, 2),
"counts": {"critical": critical, "informational": informational},
}
# ---------------------------------------------------------------------------
# Report data
# ---------------------------------------------------------------------------
def build_report_data(
review_data: dict[str, Any],
) -> dict[str, Any]:
"""Normalise score_review/dedupe output into the HTML report feed.
``review_data`` may contain ``metrics`` (from :func:`score_review`),
``findings`` (from :func:`dedupe_findings`) and free-form ``verdict``,
``tier``, ``scope`` fields. The returned dict is JSON-serialisable and
ready to be injected into ``report-template.html`` as ``{{REPORT_DATA}}``.
"""
data: dict[str, Any] = {
"scope": review_data.get("scope", "change-level"),
"tier": review_data.get("tier", "standard"),
"timestamp": review_data.get("timestamp", ""),
"files": review_data.get("files", ""),
"baseline": review_data.get("baseline", "generic"),
"verdict": review_data.get("verdict", "❌ FAIL"),
"metrics": {},
"issues": [],
"manual_review": review_data.get("manual_review", []),
"llm_judged": review_data.get("llm_judged", []),
}
metrics = review_data.get("metrics") or {}
for name, m in metrics.items():
if isinstance(m, dict):
data["metrics"][name] = {
"grade": m.get("grade"),
"value": m.get("value"),
"note": m.get("note", ""),
"evidence": m.get("evidence", {}),
}
findings = review_data.get("findings") or []
for f in findings:
data["issues"].append({
"severity": f.get("severity", "minor"),
"category": f.get("category", ""),
"message": f.get("summary", f.get("message", "")),
"location": (
f"{f.get('path', '')}:{f.get('line', '')}"
if f.get("line")
else str(f.get("path", ""))
),
"confidence": f.get("confidence"),
"fix": f.get("fix", ""),
})
return data
+101
View File
@@ -729,6 +729,107 @@ _SKILLS: dict[str, dict[str, str]] = {
"and ≤800 total output tokens."
),
},
"unified-review.md": {
"name": "unified-review",
"description": (
"Three-layer unified code review fusing CRG graph context with "
"ai-code-review scoring methodology and gstack-review fix-first workflow"
),
"body": (
"## Unified Review\n\n"
"Perform a three-layer, read-only code review that fuses:\n"
"- **CRG graph context** (blast radius, test gaps, affected flows)\n"
"- **ai-code-review methodology** (Layer-1 chain decomposition, "
"Layer-2 quantitative scoring, Layer-3 acceptance)\n"
"- **gstack-review workflow** (confidence calibration, fix-first, "
"specialist subagents, review-log persistence)\n\n"
"**This skill is READ-ONLY.** Every finding is presented to the "
"user for a manual fix decision. Never apply code changes, commit, "
"or push.\n\n"
"### Token Efficiency Rules\n"
'- ALWAYS start with `get_minimal_context(task="unified review")`. '
"Use `detail_level=\"minimal\"` on all calls; escalate to "
'"standard" only when a metric or finding needs evidence.\n\n'
"### Step 0 - Scope and tier\n"
"Read `.code-review.yaml` at the repo root (default tier "
"`standard`). Tiers: `fast` (Layer-1 + blockers only), "
"`standard` (all layers), `strict` (full + every blocker/major "
"fix needs per-item user confirmation). Single-invocation "
"overrides: `快速审查` → fast, `严格审查` → strict.\n"
"Detect the project language/framework and the review scope "
"(change/file/service/chain level). Declare both in the report "
"header.\n\n"
"### Step 1 - Graph context (CRG)\n"
"1. Call `build_or_update_graph_tool()` to ensure the graph is "
"current.\n"
"2. Call `get_review_context_tool()` for changed files, blast "
"radius, source snippets and review guidance.\n"
"3. Call `detect_changes_tool()` for risk-scored change analysis, "
"test gaps and affected flows.\n\n"
"### Step 2 - Layer 1: Chain decomposition (ai-code-review)\n"
"Inspect the changed code across eight categories: interface, "
"business, data, utility, error handling, security, performance, "
"observability. Mark each `✅ Clean / ⚠️ Issues Found / — N/A`. "
"Apply the gstack CRITICAL categories as a sub-pass: SQL & Data "
"Safety, Race Conditions & Concurrency, LLM Output Trust "
"Boundary, Shell Injection, and Enum & Value Completeness. Enum "
"completeness requires reading code OUTSIDE the diff (Grep for "
"sibling values, then Read each consumer).\n\n"
"### Step 3 - Layer 2: Quantitative scoring\n"
"Call `score_review_tool()` for the objective metrics (SQL risk, "
"exception coverage, redundancy, high-risk density, "
"vulnerability heuristic). The remaining metrics (requirement "
"coverage, logic alignment, trust boundaries) are judged by you "
"from the requirements doc or a generic baseline; without a "
"requirements doc halve their weight in the verdict.\n\n"
"### Step 4 - Specialist dispatch (gstack, diff >= 50 lines)\n"
"When the diff has 50+ changed lines, dispatch specialist "
"subagents in parallel via the Agent/task tool, each with a "
"fresh context and its own checklist: testing, maintainability, "
"security, performance, data-migration, api-contract. Security "
"and data-migration always run (insurance). Collect each "
"specialist's JSON findings.\n\n"
"### Step 5 - Merge and dedupe\n"
"Call `dedupe_findings_tool(findings=<all raw findings>)` to "
"merge by fingerprint (`path:line:category`), boost "
"multi-source confidence (+1, cap 10), route low-confidence "
"findings to the appendix, and compute the PR quality score.\n\n"
"### Step 6 - Manual adjudication (READ-ONLY)\n"
"Present every merged finding with its severity "
"(🔴 blocker / 🟡 major / 🔵 minor), confidence (1-10), "
"file:line and a proposed fix. Group by severity and ask the "
"user per batch: fix / skip / self-fix. 🔴 blockers cannot be "
"batch-skipped. Record skipped findings for prior-review "
"suppression on the next run. **Do not modify code.**\n\n"
"### Step 7 - Acceptance gate (ai-code-review)\n"
"Any 🔴 blocker → verdict `❌ FAIL` regardless of other scores. "
"Classify each finding as Ready / Needs Fix / Unusable. Verify "
"the change does not deviate from requirements or architecture "
"conventions.\n\n"
"### Step 8 - Report\n"
"Call `generate_report_tool(review_data=<collected verdict, "
"metrics, findings, tier, scope>)` to write "
"`code-review-report.html`. Also present the text report "
"inline.\n\n"
"### Step 9 - Persistence (optional)\n"
"If the `gstack-review-log` binary is available, record the "
"review outcome (status, counts, quality score, per-finding "
"actions). If it is unavailable, skip silently.\n\n"
"### Output Format\n"
"`Unified Review: N issues (X blocker, Y major, Z minor) — "
"verdict: ✅ PASS / ❌ FAIL`. List each issue with severity, "
"confidence, file:line, problem, and proposed fix. List "
"manual-review items (payment, order, inventory, permission, "
"distributed-lock, data-migration) explicitly.\n\n"
"## Token Efficiency Rules\n"
'- ALWAYS start with `get_minimal_context(task="unified review")` '
"before any other graph tool.\n"
'- Use `detail_level="minimal"` on all calls. Only escalate to '
'"standard" when minimal is insufficient.\n'
"- Target: complete a unified review in ≤8 tool calls and "
"≤1200 total output tokens."
),
},
"debug-issue.md": {
"name": "debug-issue",
"description": "Systematically debug issues using graph-powered code navigation",
+15 -1
View File
@@ -1,6 +1,6 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 27 tools:
Exposes 31 tools:
1. build_or_update_graph - full or incremental build
2. get_impact_radius - blast radius from changed files
3. query_graph - predefined graph queries
@@ -29,6 +29,9 @@ Exposes 27 tools:
26. get_surprising_connections - find unexpected architectural coupling
27. get_suggested_questions - auto-generated review questions from graph analysis
28. traverse_graph - BFS/DFS traversal from best-matching node
29. score_review - objective Layer-2 review metrics for changed files
30. dedupe_findings - fingerprint dedup + confidence merge for findings
31. generate_report - render the standalone HTML code review report
"""
from __future__ import annotations
@@ -103,6 +106,13 @@ from .review import (
get_review_context,
)
# -- scoring (unified-review) ------------------------------------------------
from .scoring_tools import (
dedupe_findings_func,
generate_report_func,
score_review_func,
)
__all__ = [
# _common
"_BUILTIN_CALL_NAMES",
@@ -143,6 +153,10 @@ __all__ = [
"detect_changes_func",
"get_affected_flows_func",
"get_review_context",
# scoring (unified-review)
"score_review_func",
"dedupe_findings_func",
"generate_report_func",
# analysis_tools
"get_bridge_nodes_func",
"get_hub_nodes_func",
+5 -4
View File
@@ -115,8 +115,8 @@ def get_docs_section(
Args:
section_name: Exact section name. One of: usage, review-delta,
review-pr, commands, legal, watch, embeddings,
languages, troubleshooting.
review-pr, unified-review, score-review, commands,
legal, watch, embeddings, languages, troubleshooting.
repo_root: Repository root path. Auto-detected from current
directory if omitted.
@@ -177,8 +177,9 @@ def get_docs_section(
}
available = [
"usage", "review-delta", "review-pr", "commands",
"legal", "watch", "embeddings", "languages", "troubleshooting",
"usage", "review-delta", "review-pr", "unified-review",
"score-review", "commands", "legal", "watch", "embeddings",
"languages", "troubleshooting",
]
return {
"status": "not_found",
+269
View File
@@ -0,0 +1,269 @@
"""MCP tool wrappers for the unified-review scoring workflow.
Wraps :mod:`code_review_graph.scoring` (score_review / dedupe_findings /
build_report_data) into the three MCP tools consumed by the
``unified-review`` skill:
* ``score_review_tool`` - objective Layer-2 metrics for changed files
* ``dedupe_findings_tool`` - fingerprint dedup + confidence merge
* ``generate_report_tool`` - render ``code-review-report.html``
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from ..incremental import get_changed_files, get_staged_and_unstaged
from ..scoring import build_report_data, dedupe_findings, score_review
from ._common import _get_store, _error_response
try:
from importlib.resources import files as _pkg_files # Python 3.9+
_HAS_IMPORTLIB_RESOURCES = True
except ImportError: # pragma: no cover
_HAS_IMPORTLIB_RESOURCES = False
# ---------------------------------------------------------------------------
# Tool: score_review
# ---------------------------------------------------------------------------
def score_review_func(
changed_files: list[str] | None = None,
base: str = "HEAD~1",
include_churn: bool = True,
repo_root: str | None = None,
detail_level: str = "standard",
) -> dict[str, Any]:
"""Compute objective Layer-2 review metrics for changed files.
Runs the git-history / graph risk factors plus the heuristic metrics
(SQL risk, exception coverage, redundancy, high-risk density,
vulnerability). LLM-judged metrics are listed in ``llm_judged`` so the
calling agent knows what still needs judgement.
Args:
changed_files: Files to score (auto-detected from git diff if
omitted).
base: Git ref to diff against (default: HEAD~1).
include_churn: Include git-churn risk factors (default: True).
repo_root: Repository root. Auto-detected if omitted.
detail_level: Output detail level. ``minimal`` returns only grades
and values; ``standard`` includes evidence.
"""
store, root = _get_store(repo_root)
try:
if changed_files is None:
changed_files = get_changed_files(root, base)
if not changed_files:
changed_files = get_staged_and_unstaged(root)
if not changed_files:
return {
"status": "ok",
"summary": "No changed files detected. Nothing to score.",
"metrics": {},
"objective_grade": "good",
}
result = score_review(
store,
root,
changed_files,
include_churn=include_churn,
)
if detail_level == "minimal":
return {
"status": "ok",
"summary": result["summary"],
"objective_grade": result["objective_grade"],
"metrics": {
name: {"value": m["value"], "grade": m["grade"]}
for name, m in result["metrics"].items()
},
"llm_judged": result["llm_judged"],
}
result["changed_files"] = changed_files
result["next_tool_suggestions"] = [
"dedupe_findings -- merge specialist findings",
"detect_changes -- risk-scored impact analysis",
"generate_report -- export HTML report",
]
return result
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: dedupe_findings
# ---------------------------------------------------------------------------
def dedupe_findings_func(
findings: list[dict[str, Any]],
suppress_prior: list[dict[str, Any]] | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Merge review findings by fingerprint and boost multi-source confidence.
Mirrors the gstack-review "collect and merge" step: findings with the
same ``path:line:category`` fingerprint are merged (highest confidence
wins); findings confirmed by more than one source get confidence +1
(cap 10). Confidence gates route low-confidence findings to the
appendix or suppress them, and a PR quality score is computed as
``max(0, 10 - (critical*2 + informational*0.5))``.
Args:
findings: Raw finding dicts with ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Previously user-skipped findings (from a prior
review-log) to suppress when their file has not changed.
repo_root: Repository root (used to resolve changed files when
suppressing prior findings).
"""
try:
suppressed_prior_list: list[dict[str, Any]] = []
if suppress_prior:
for f in suppress_prior:
suppressed_prior_list.append(f)
result = dedupe_findings(findings, suppressed_prior_list)
result["next_tool_suggestions"] = [
"generate_report -- export HTML report",
"detect_changes -- risk-scored impact analysis",
]
return result
except Exception as exc:
return _error_response(str(exc))
# ---------------------------------------------------------------------------
# Tool: generate_report
# ---------------------------------------------------------------------------
def _load_report_template() -> str:
"""Load ``report-template.html`` from the installed package assets.
Falls back to the bundled template string when the asset cannot be
loaded, so the tool never fails solely because the package data is
missing.
"""
if _HAS_IMPORTLIB_RESOURCES:
try:
data = _pkg_files("code_review_graph").joinpath(
"assets/report-template.html"
).read_text(encoding="utf-8")
if data:
return data
except (FileNotFoundError, OSError):
pass
return _FALLBACK_TEMPLATE
# Minimal self-contained template (used if the asset file is unavailable).
_FALLBACK_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Code Review Report</title>
<style>
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
margin:2rem auto;max-width:900px;color:#1f2328;line-height:1.5}
h1{font-size:1.5rem} .verdict{font-weight:700}
.pass{color:#1a7f37}.fail{color:#cf222e}
table{border-collapse:collapse;width:100%;margin:1rem 0}
th,td{border:1px solid #d0d7de;padding:.4rem .6rem;text-align:left;font-size:.9rem}
th{background:#f6f8fa}
.good{color:#1a7f37}.warn{color:#bf8700}.fail{color:#cf222e}.na{color:#57606a}
.issue{margin:.5rem 0;padding:.6rem;border-radius:6px;background:#f6f8fa}
.tag{font-weight:700;margin-right:.4rem}
.location{color:#57606a;font-size:.85rem}
</style>
</head>
<body>
<div id="report"></div>
<script>
const data = {{REPORT_DATA}};
const el = document.getElementById("report");
let html = `<h1>Code Review Report</h1>
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
&middot; Tier: ${data.tier || "standard"} &middot; Scope: ${data.scope || "change-level"}</p>`;
if (data.files) html += `<p><b>Files:</b> ${data.files}</p>`;
if (data.summary) html += `<p>${data.summary}</p>`;
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th></tr>`;
for (const [k, m] of Object.entries(data.metrics || {})) {
html += `<tr><td>${k}</td><td>${m.value ?? "N/A"}</td>
<td class="${m.grade || "na"}">${m.grade || "N/A"}</td></tr>`;
}
html += `</table>`;
html += `<h2>Issues (${(data.issues || []).length})</h2>`;
for (const i of data.issues || []) {
html += `<div class="issue"><span class="tag ${i.severity}">${i.severity}</span>
<span>${i.message || ""}</span>
<div class="location">${i.location || ""}</div></div>`;
}
if (data.llm_judged && data.llm_judged.length) {
html += `<p><b>LLM-judged:</b> ${data.llm_judged.join(", ")}</p>`;
}
el.innerHTML = html;
</script>
</body>
</html>
"""
def generate_report_func(
review_data: dict[str, Any],
output_path: str | None = None,
repo_root: str | None = None,
) -> dict[str, Any]:
"""Generate a self-contained HTML code review report.
Injects ``review_data`` (the output of ``score_review_tool`` plus
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
the bundled ``report-template.html`` as ``{{REPORT_DATA}}`` and writes
the standalone file (default ``repo_root/code-review-report.html``).
Args:
review_data: Review data dict (metrics, findings, verdict, tier,
scope, files, baseline, timestamp, manual_review).
output_path: Output file path. Defaults to
``<repo_root>/code-review-report.html``.
repo_root: Repository root. Auto-detected if omitted.
"""
store, root = _get_store(repo_root)
try:
data = build_report_data(review_data)
data["summary"] = review_data.get("summary", "")
template = _load_report_template()
rendered = template.replace(
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
)
if output_path:
out = Path(output_path)
if not out.is_absolute():
out = root / out
else:
out = root / "code-review-report.html"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(rendered, encoding="utf-8")
return {
"status": "ok",
"summary": f"Report written to {out}",
"output_path": str(out),
"report_size_bytes": len(rendered),
}
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()