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:
@@ -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 => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
})[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>
|
||||
Tier: <code>${esc(data.tier || "standard")}</code>
|
||||
Scope: <code>${esc(data.scope || "change-level")}</code>
|
||||
${data.baseline ? ` 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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
· Tier: ${data.tier || "standard"} · 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()
|
||||
Reference in New Issue
Block a user