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:
@@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- Added the **unified-review** skill that fuses CRG graph context with the
|
||||||
|
ai-code-review three-layer scoring methodology and the gstack-review
|
||||||
|
fix-first workflow. The skill is read-only: every finding waits for a
|
||||||
|
manual fix decision. Ships with language / manual-review / specialist
|
||||||
|
checklists under `skills/unified-review/references/`.
|
||||||
|
- Added three MCP tools backing the unified-review workflow:
|
||||||
|
`score_review_tool` (objective Layer-2 metrics with good/warn/fail grades),
|
||||||
|
`dedupe_findings_tool` (fingerprint dedup, multi-source confidence boost,
|
||||||
|
PR quality score), and `generate_report_tool` (standalone
|
||||||
|
`code-review-report.html`). See `code_review_graph/scoring.py`.
|
||||||
|
|
||||||
- Added a Voyage AI embedding provider (`--provider voyage`, key from
|
- Added a Voyage AI embedding provider (`--provider voyage`, key from
|
||||||
`VOYAGE_API_KEY`, opt-in request throttling via
|
`VOYAGE_API_KEY`, opt-in request throttling via
|
||||||
`CRG_VOYAGE_MIN_INTERVAL_SEC`). Embeddings are now persisted after each
|
`CRG_VOYAGE_MIN_INTERVAL_SEC`). Embeddings are now persisted after each
|
||||||
|
|||||||
@@ -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.
|
Never include full files unless explicitly asked.
|
||||||
</section>
|
</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">
|
<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
|
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
|
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]
|
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.
|
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>
|
</section>
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ from .tools import (
|
|||||||
apply_refactor_func,
|
apply_refactor_func,
|
||||||
build_or_update_graph,
|
build_or_update_graph,
|
||||||
cross_repo_search_func,
|
cross_repo_search_func,
|
||||||
|
dedupe_findings_func,
|
||||||
detect_changes_func,
|
detect_changes_func,
|
||||||
embed_graph,
|
embed_graph,
|
||||||
find_large_functions,
|
find_large_functions,
|
||||||
|
generate_report_func,
|
||||||
generate_wiki_func,
|
generate_wiki_func,
|
||||||
get_affected_flows_func,
|
get_affected_flows_func,
|
||||||
get_architecture_overview_func,
|
get_architecture_overview_func,
|
||||||
@@ -58,6 +60,7 @@ from .tools import (
|
|||||||
query_graph,
|
query_graph,
|
||||||
refactor_func,
|
refactor_func,
|
||||||
run_postprocess,
|
run_postprocess,
|
||||||
|
score_review_func,
|
||||||
semantic_search_nodes,
|
semantic_search_nodes,
|
||||||
traverse_graph_func,
|
traverse_graph_func,
|
||||||
with_provenance,
|
with_provenance,
|
||||||
@@ -686,6 +689,108 @@ async def detect_changes_tool(
|
|||||||
return await coro
|
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()
|
@mcp.tool()
|
||||||
def refactor_tool(
|
def refactor_tool(
|
||||||
mode: str = "rename",
|
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."
|
"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": {
|
"debug-issue.md": {
|
||||||
"name": "debug-issue",
|
"name": "debug-issue",
|
||||||
"description": "Systematically debug issues using graph-powered code navigation",
|
"description": "Systematically debug issues using graph-powered code navigation",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""MCP tool definitions for the Code Review Graph server.
|
"""MCP tool definitions for the Code Review Graph server.
|
||||||
|
|
||||||
Exposes 27 tools:
|
Exposes 31 tools:
|
||||||
1. build_or_update_graph - full or incremental build
|
1. build_or_update_graph - full or incremental build
|
||||||
2. get_impact_radius - blast radius from changed files
|
2. get_impact_radius - blast radius from changed files
|
||||||
3. query_graph - predefined graph queries
|
3. query_graph - predefined graph queries
|
||||||
@@ -29,6 +29,9 @@ Exposes 27 tools:
|
|||||||
26. get_surprising_connections - find unexpected architectural coupling
|
26. get_surprising_connections - find unexpected architectural coupling
|
||||||
27. get_suggested_questions - auto-generated review questions from graph analysis
|
27. get_suggested_questions - auto-generated review questions from graph analysis
|
||||||
28. traverse_graph - BFS/DFS traversal from best-matching node
|
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
|
from __future__ import annotations
|
||||||
@@ -103,6 +106,13 @@ from .review import (
|
|||||||
get_review_context,
|
get_review_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -- scoring (unified-review) ------------------------------------------------
|
||||||
|
from .scoring_tools import (
|
||||||
|
dedupe_findings_func,
|
||||||
|
generate_report_func,
|
||||||
|
score_review_func,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# _common
|
# _common
|
||||||
"_BUILTIN_CALL_NAMES",
|
"_BUILTIN_CALL_NAMES",
|
||||||
@@ -143,6 +153,10 @@ __all__ = [
|
|||||||
"detect_changes_func",
|
"detect_changes_func",
|
||||||
"get_affected_flows_func",
|
"get_affected_flows_func",
|
||||||
"get_review_context",
|
"get_review_context",
|
||||||
|
# scoring (unified-review)
|
||||||
|
"score_review_func",
|
||||||
|
"dedupe_findings_func",
|
||||||
|
"generate_report_func",
|
||||||
# analysis_tools
|
# analysis_tools
|
||||||
"get_bridge_nodes_func",
|
"get_bridge_nodes_func",
|
||||||
"get_hub_nodes_func",
|
"get_hub_nodes_func",
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ def get_docs_section(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
section_name: Exact section name. One of: usage, review-delta,
|
section_name: Exact section name. One of: usage, review-delta,
|
||||||
review-pr, commands, legal, watch, embeddings,
|
review-pr, unified-review, score-review, commands,
|
||||||
languages, troubleshooting.
|
legal, watch, embeddings, languages, troubleshooting.
|
||||||
repo_root: Repository root path. Auto-detected from current
|
repo_root: Repository root path. Auto-detected from current
|
||||||
directory if omitted.
|
directory if omitted.
|
||||||
|
|
||||||
@@ -177,8 +177,9 @@ def get_docs_section(
|
|||||||
}
|
}
|
||||||
|
|
||||||
available = [
|
available = [
|
||||||
"usage", "review-delta", "review-pr", "commands",
|
"usage", "review-delta", "review-pr", "unified-review",
|
||||||
"legal", "watch", "embeddings", "languages", "troubleshooting",
|
"score-review", "commands", "legal", "watch", "embeddings",
|
||||||
|
"languages", "troubleshooting",
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"status": "not_found",
|
"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()
|
||||||
@@ -21,6 +21,13 @@ Review a PR or branch diff.
|
|||||||
- Full impact analysis across all PR commits
|
- Full impact analysis across all PR commits
|
||||||
- Structured output with risk assessment
|
- Structured output with risk assessment
|
||||||
|
|
||||||
|
### `/code-review-graph:unified-review`
|
||||||
|
Three-layer unified code review (CRG graph context + ai-code-review scoring + gstack-review workflow).
|
||||||
|
- Read-only: every finding waits for a manual fix decision
|
||||||
|
- Objective Layer-2 metrics via `score_review_tool`
|
||||||
|
- Fingerprint merge + quality score via `dedupe_findings_tool`
|
||||||
|
- Standalone HTML report via `generate_report_tool`
|
||||||
|
|
||||||
## MCP Tools
|
## MCP Tools
|
||||||
|
|
||||||
### Core Tools
|
### Core Tools
|
||||||
@@ -223,6 +230,41 @@ detail_level: str = "standard"
|
|||||||
Primary tool for code review. Maps changed files to affected functions, flows, communities, and test coverage gaps. Returns risk scores and prioritized review items.
|
Primary tool for code review. Maps changed files to affected functions, flows, communities, and test coverage gaps. Returns risk scores and prioritized review items.
|
||||||
Relevant responses may include compact estimated `context_savings` metadata.
|
Relevant responses may include compact estimated `context_savings` metadata.
|
||||||
|
|
||||||
|
### Unified Review Tools
|
||||||
|
|
||||||
|
#### `score_review_tool`
|
||||||
|
```
|
||||||
|
changed_files: list[str] | None # Auto-detected from git diff if omitted
|
||||||
|
base: str = "HEAD~1"
|
||||||
|
include_churn: bool = True
|
||||||
|
repo_root: str | None
|
||||||
|
detail_level: str = "standard" # "minimal" for grades + values only
|
||||||
|
```
|
||||||
|
Computes objective Layer-2 metrics for changed files: `sql_risk`,
|
||||||
|
`exception_coverage`, `redundancy_rate`, `high_risk_density`,
|
||||||
|
`vulnerability_risk`. Each metric carries a `good`/`warn`/`fail` grade,
|
||||||
|
thresholds and evidence. LLM-judged metrics are listed in `llm_judged`.
|
||||||
|
|
||||||
|
#### `dedupe_findings_tool`
|
||||||
|
```
|
||||||
|
findings: list # [{path, category, severity, confidence, source?}]
|
||||||
|
suppress_prior: list | None # Previously user-skipped findings to suppress
|
||||||
|
repo_root: str | None
|
||||||
|
```
|
||||||
|
Merges findings by `path:line:category` fingerprint (highest confidence
|
||||||
|
wins), boosts multi-source confidence (+1, cap 10), routes low-confidence
|
||||||
|
findings to the appendix, and computes `PR quality score = max(0, 10 -
|
||||||
|
(critical*2 + informational*0.5))`.
|
||||||
|
|
||||||
|
#### `generate_report_tool`
|
||||||
|
```
|
||||||
|
review_data: dict # metrics + findings + verdict + tier + scope
|
||||||
|
output_path: str | None # Default: <repo_root>/code-review-report.html
|
||||||
|
repo_root: str | None
|
||||||
|
```
|
||||||
|
Renders the standalone HTML code review report (self-contained, no external
|
||||||
|
dependencies) from the bundled `report-template.html`.
|
||||||
|
|
||||||
#### `refactor_tool`
|
#### `refactor_tool`
|
||||||
```
|
```
|
||||||
mode: str = "rename" # "rename", "dead_code", or "suggest"
|
mode: str = "rename" # "rename", "dead_code", or "suggest"
|
||||||
|
|||||||
+17
-10
@@ -27,6 +27,8 @@
|
|||||||
│ │ ├── Communities: list, get, architecture │ │
|
│ │ ├── Communities: list, get, architecture │ │
|
||||||
│ │ ├── Analysis: detect_changes, refactor, │ │
|
│ │ ├── Analysis: detect_changes, refactor, │ │
|
||||||
│ │ │ apply_refactor, hotspots, gaps │ │
|
│ │ │ apply_refactor, hotspots, gaps │ │
|
||||||
|
│ │ ├── Scoring: score_review, dedupe_ │ │
|
||||||
|
│ │ │ findings, generate_report │ │
|
||||||
│ │ ├── Wiki: generate, get_page │ │
|
│ │ ├── Wiki: generate, get_page │ │
|
||||||
│ │ └── Multi-repo: list_repos, cross_search │ │
|
│ │ └── Multi-repo: list_repos, cross_search │ │
|
||||||
│ └────────────────┬───────────────────────────┘ │
|
│ └────────────────┬───────────────────────────┘ │
|
||||||
@@ -34,16 +36,21 @@
|
|||||||
│
|
│
|
||||||
┌───────────┼───────────────┐
|
┌───────────┼───────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────────┐
|
┌─────────┐ ┌─────────┐ ┌─────────────┐
|
||||||
│ Parser │ │ Graph │ │ Incremental │
|
│ Parser │ │ Graph │ │ Incremental │
|
||||||
│ │ │ Store │ │ Engine │
|
│ │ │ Store │ │ Engine │
|
||||||
└────┬────┘ └────┬────┘ └──────┬──────┘
|
└────┬────┘ └────┬────┘ └──────┬──────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
Tree-sitter SQLite DB git/svn diff
|
Tree-sitter SQLite DB git/svn diff
|
||||||
grammars (.code-review- subprocess
|
grammars (.code-review- subprocess
|
||||||
graph/
|
graph/
|
||||||
graph.db)
|
graph.db)
|
||||||
|
|
||||||
|
Scoring module (code_review_graph/scoring.py) sits on top of the
|
||||||
|
GraphStore and reads changed files + git history to compute the
|
||||||
|
objective review metrics consumed by the unified-review skill and the
|
||||||
|
score_review / dedupe_findings / generate_report MCP tools.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
---
|
||||||
|
|
||||||
|
# Unified Review
|
||||||
|
|
||||||
|
Perform a three-layer, read-only code review that fuses:
|
||||||
|
|
||||||
|
- **CRG graph context** (blast radius, test gaps, affected flows)
|
||||||
|
- **ai-code-review methodology** (Layer-1 chain decomposition, Layer-2 quantitative scoring, Layer-3 acceptance)
|
||||||
|
- **gstack-review workflow** (confidence calibration, fix-first, specialist subagents, review-log persistence)
|
||||||
|
|
||||||
|
**This skill is READ-ONLY.** Every finding is presented to the user for a manual fix decision. Never apply code changes, commit, or push.
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Step 0 - Scope and tier
|
||||||
|
|
||||||
|
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.
|
||||||
|
Detect the project language/framework and the review scope (change/file/service/chain level). Declare both in the report header.
|
||||||
|
|
||||||
|
## Step 1 - Graph context (CRG)
|
||||||
|
|
||||||
|
1. Call `build_or_update_graph_tool()` to ensure the graph is current.
|
||||||
|
2. Call `get_review_context_tool()` for changed files, blast radius, source snippets and review guidance.
|
||||||
|
3. Call `detect_changes_tool()` for risk-scored change analysis, test gaps and affected flows.
|
||||||
|
|
||||||
|
## Step 2 - Layer 1: Chain decomposition (ai-code-review)
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Step 3 - Layer 2: Quantitative scoring
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Step 4 - Specialist dispatch (gstack, diff >= 50 lines)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Step 5 - Merge and dedupe
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Step 6 - Manual adjudication (READ-ONLY)
|
||||||
|
|
||||||
|
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.**
|
||||||
|
|
||||||
|
## Step 7 - Acceptance gate (ai-code-review)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Step 8 - Report
|
||||||
|
|
||||||
|
Call `generate_report_tool(review_data=<collected verdict, metrics, findings, tier, scope>)` to write `code-review-report.html`. Also present the text report inline.
|
||||||
|
|
||||||
|
## Step 9 - Persistence (optional)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## Token Efficiency Rules
|
||||||
|
- ALWAYS start with `get_minimal_context(task="unified review")` before any other graph tool.
|
||||||
|
- Use `detail_level="minimal"` on all calls. Only escalate to `"standard"` when minimal is insufficient.
|
||||||
|
- Target: complete a unified review in ≤8 tool calls and ≤1200 total output tokens.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Unified Review — Common Mistakes
|
||||||
|
|
||||||
|
- **Skipping graph context** — always run `get_minimal_context` first; CRG
|
||||||
|
context is what makes the review token-efficient and blast-radius aware.
|
||||||
|
- **Rushing to fix** — this skill is READ-ONLY. Present findings, wait for
|
||||||
|
user decision. Never apply fixes, commit, or push.
|
||||||
|
- **Ignoring tier** — read `.code-review.yaml`. `fast` skips Layer 2/3;
|
||||||
|
`strict` requires per-item confirmation for every blocker/major.
|
||||||
|
- **Judging metrics without evidence** — `score_review_tool` outputs are
|
||||||
|
heuristics. Cite the evidence, and let the LLM confirm SQL/exception/vuln
|
||||||
|
findings before presenting them as facts.
|
||||||
|
- **Missing manual-review modules** — payment, order, inventory, permission,
|
||||||
|
distributed-lock, data-migration always require a manual review checklist.
|
||||||
|
- **Forgetting enum completeness reads OUTSIDE the diff** — grep sibling
|
||||||
|
values, then read each consumer; in-diff review alone is insufficient.
|
||||||
|
- **Batch-skipping blockers** — 🔴 blockers cannot be batch-skipped; each
|
||||||
|
needs an explicit user decision.
|
||||||
|
- **Not producing the report** — always call `generate_report_tool` at the
|
||||||
|
end and present the text report inline.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Data Migration — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: schema/data migration requires human confirmation.
|
||||||
|
|
||||||
|
- [ ] Migration is idempotent and re-runnable
|
||||||
|
- [ ] Forward and rollback paths both defined and tested
|
||||||
|
- [ ] Backfill is batched / resumable for large tables
|
||||||
|
- [ ] Data type / precision changes do not silently truncate
|
||||||
|
- [ ] Nullability and default changes safe for existing rows
|
||||||
|
- [ ] Migration ordering across shards / replicas is consistent
|
||||||
|
- [ ] Application deploys compatibly with both old and new schema (expand/contract)
|
||||||
|
- [ ] Irreversible operations are flagged with a documented reason
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Distributed Lock — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: distributed-lock changes require architecture confirmation.
|
||||||
|
|
||||||
|
- [ ] Lock has a TTL / expiry — no permanent deadlock after crash
|
||||||
|
- [ ] Lock release is atomic and ownership-checked (compare-and-delete)
|
||||||
|
- [ ] Lock scope is correct (key includes the right entity identifiers)
|
||||||
|
- [ ] Renewal / watchdog exists for long critical sections
|
||||||
|
- [ ] Locking order is consistent across paths (no lock-ordering deadlock)
|
||||||
|
- [ ] Fencing tokens / version check prevents stale-holder writes
|
||||||
|
- [ ] Fail-open vs fail-closed behavior is intentional and documented
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Inventory Module — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: stock/inventory changes require human confirmation.
|
||||||
|
|
||||||
|
- [ ] Stock decrement is atomic (conditional UPDATE, not read-then-write)
|
||||||
|
- [ ] Oversell prevented: `UPDATE ... SET qty = qty - ? WHERE qty >= ?`
|
||||||
|
- [ ] Reservation vs. deduction semantics are consistent
|
||||||
|
- [ ] Concurrent orders cannot both reserve the last unit
|
||||||
|
- [ ] Restock/return increments handled correctly
|
||||||
|
- [ ] Inventory events are idempotent (retry-safe)
|
||||||
|
- [ ] Async stock updates propagate to downstream (warehouse, carts) safely
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Order Module — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: order lifecycle changes require human confirmation.
|
||||||
|
|
||||||
|
- [ ] State machine transitions are atomic (`WHERE status = ?` updates)
|
||||||
|
- [ ] Cancellation / timeout / expiry paths complete all side effects
|
||||||
|
- [ ] Order idempotency key prevents duplicate order creation
|
||||||
|
- [ ] Price/lock snapshot captured at order time, not at payment time
|
||||||
|
- [ ] Partial fulfillment / split-shipment handled
|
||||||
|
- [ ] Negative or inconsistent totals impossible
|
||||||
|
- [ ] Concurrent edits (cart + order) do not corrupt state
|
||||||
|
- [ ] Audit trail: every status change logged with reason
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Payment Module — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: payment changes require human confirmation for every
|
||||||
|
blocker/major fix.
|
||||||
|
|
||||||
|
- [ ] Callback idempotency: a duplicated webhook/callback does not double-charge
|
||||||
|
- [ ] Amounts stored as fixed-point (integers/cents), never floats
|
||||||
|
- [ ] Currency codes and precision handled correctly
|
||||||
|
- [ ] Provider signature / HMAC verification on callbacks
|
||||||
|
- [ ] Refund logic: correct reversal, no double-refund
|
||||||
|
- [ ] Failure path: payment timeout, declined, retry semantics
|
||||||
|
- [ ] Transaction boundary spans charge + order-state update
|
||||||
|
- [ ] Sensitive data (PAN, tokens) never logged or masked on output
|
||||||
|
- [ ] Ledger/journal entries are append-only and auditable
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Permission Module — Manual Review Checklist
|
||||||
|
|
||||||
|
High-risk module: authorization changes require product/human confirmation.
|
||||||
|
|
||||||
|
- [ ] Every endpoint/action enforces the intended permission — no default-allow
|
||||||
|
- [ ] Role hierarchy / scoping (tenant, org, user) is consistent
|
||||||
|
- [ ] Object-level permissions checked on read AND write
|
||||||
|
- [ ] Deny-before-allow ordering is safe
|
||||||
|
- [ ] Permission checks cannot be bypassed via IDs, query params, or bulk ops
|
||||||
|
- [ ] New permission/role values handled by all consumers (enum completeness)
|
||||||
|
- [ ] Sensitive actions audited with actor + target
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Unified Review — Generic Checklist
|
||||||
|
|
||||||
|
Reference for the Layer-1 chain decomposition and gstack CRITICAL sub-pass.
|
||||||
|
Load the language-specific checklist when available (`java-spring.md`,
|
||||||
|
`python-django.md`, `python-fastapi.md`, `node-express.md`, `go-gin.md`,
|
||||||
|
`csharp-dotnet.md`, `rust.md`, `php-laravel.md`, `ruby-rails.md`); otherwise
|
||||||
|
use this generic list.
|
||||||
|
|
||||||
|
## Layer 1 — eight categories
|
||||||
|
|
||||||
|
For each changed area mark ✅ Clean / ⚠️ Issues Found / — N/A.
|
||||||
|
|
||||||
|
1. **Interface** — parameter validation, response conventions, HTTP status
|
||||||
|
codes, rate limiting, API versioning, protocol correctness
|
||||||
|
2. **Business** — logic aligns with requirements, state machine correctness,
|
||||||
|
idempotency design, distributed locks
|
||||||
|
3. **Data** — SQL injection, query performance, index usage, transaction
|
||||||
|
boundaries, cache invalidation
|
||||||
|
4. **Utility** — input validity, no side effects, error return values,
|
||||||
|
date/time timezone handling
|
||||||
|
5. **Error handling** — exception classification, fallback logic, error
|
||||||
|
message sanitization, retry with backoff
|
||||||
|
6. **Security** — AuthN/AuthZ, sensitive data masking, permission control,
|
||||||
|
CSRF/XSS prevention
|
||||||
|
7. **Performance** — N+1 queries, caching strategy, connection pooling,
|
||||||
|
batch operations, blocking in async paths
|
||||||
|
8. **Observability** — structured logging with correlation IDs, metrics,
|
||||||
|
health checks
|
||||||
|
|
||||||
|
## gstack CRITICAL sub-pass (highest severity)
|
||||||
|
|
||||||
|
### SQL & Data Safety
|
||||||
|
- String interpolation in SQL — use parameterized queries
|
||||||
|
- TOCTOU check-then-set — use atomic `WHERE` + update
|
||||||
|
- Bypassing model validations for direct DB writes
|
||||||
|
- N+1 queries — missing eager loading
|
||||||
|
|
||||||
|
### Race Conditions & Concurrency
|
||||||
|
- Read-check-write without uniqueness constraint / duplicate-key retry
|
||||||
|
- find-or-create without a unique DB index
|
||||||
|
- Status transitions not atomic (`WHERE old_status = ? UPDATE ...`)
|
||||||
|
- Unsafe HTML rendering on user-controlled data
|
||||||
|
|
||||||
|
### LLM Output Trust Boundary
|
||||||
|
- LLM-generated values (emails, URLs, names) written to DB without format
|
||||||
|
validation
|
||||||
|
- Structured tool output accepted without type/shape checks
|
||||||
|
- LLM-generated URLs fetched without an allowlist (SSRF)
|
||||||
|
- LLM output stored in knowledge bases without sanitization (stored prompt
|
||||||
|
injection)
|
||||||
|
|
||||||
|
### Shell Injection
|
||||||
|
- `subprocess` with `shell=True` AND interpolated command strings
|
||||||
|
- `os.system()` with variable interpolation
|
||||||
|
- `eval()`/`exec()` on LLM-generated code without sandboxing
|
||||||
|
|
||||||
|
### Enum & Value Completeness
|
||||||
|
- New enum/status/tier values: read (not just grep) every consumer that
|
||||||
|
switches/filters/displays the value
|
||||||
|
- Check allowlists and `case`/`if-elsif` chains for fall-through
|
||||||
|
|
||||||
|
## Suppressions — do NOT flag
|
||||||
|
- Harmless redundancy that aids readability
|
||||||
|
- "Add a comment explaining a threshold" — thresholds drift
|
||||||
|
- Consistency-only changes
|
||||||
|
- Anything already addressed in the diff
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
- 🔴 **blocker** — must fix before merge (injection, secrets, missing
|
||||||
|
transaction, auth bypass) → verdict FAIL
|
||||||
|
- 🟡 **major** — should fix before merge (missing validation, missing
|
||||||
|
fallback, N+1, unmasked data)
|
||||||
|
- 🔵 **minor** — can optimize later (naming, duplicate code, comments)
|
||||||
|
|
||||||
|
## Confidence calibration
|
||||||
|
- 9-10 verified by reading specific code
|
||||||
|
- 7-8 high-confidence pattern match
|
||||||
|
- 5-6 medium — show with caveat
|
||||||
|
- 3-4 low — move to appendix
|
||||||
|
- 1-2 speculation — suppress unless severity would be P0
|
||||||
|
|
||||||
|
Every finding: `[SEVERITY] (confidence: N/10) file:line — problem → fix`.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# API Contract Specialist
|
||||||
|
|
||||||
|
Focus: API and interface contract changes in the diff.
|
||||||
|
|
||||||
|
- [ ] Breaking changes to public endpoints (paths, params, response shape)
|
||||||
|
- [ ] Versioning compatibility (deprecations, fallbacks)
|
||||||
|
- [ ] Request/response validation matches the schema
|
||||||
|
- [ ] Error response shape is consistent
|
||||||
|
- [ ] Authentication/authorization behavior unchanged for existing consumers
|
||||||
|
- [ ] Renamed/moved functions: all callers updated
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"api-contract","summary":"...","fix":"...","source":"api-contract"}`
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Data Migration Specialist
|
||||||
|
|
||||||
|
Focus: database schema and data migrations in the diff.
|
||||||
|
|
||||||
|
- [ ] Migration idempotent and re-runnable
|
||||||
|
- [ ] Forward + rollback paths defined
|
||||||
|
- [ ] Backfill batched / resumable
|
||||||
|
- [ ] Type/precision changes do not truncate data
|
||||||
|
- [ ] Nullability/default changes safe for existing rows
|
||||||
|
- [ ] Application deploy compatible with old + new schema (expand/contract)
|
||||||
|
|
||||||
|
Insurance specialist — always runs, even when silent.
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"data-migration","summary":"...","fix":"...","source":"data-migration"}`
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Maintainability Specialist
|
||||||
|
|
||||||
|
Focus: code quality and maintainability issues.
|
||||||
|
|
||||||
|
- [ ] Dead code / unreachable branches / unused variables
|
||||||
|
- [ ] Magic numbers → named constants
|
||||||
|
- [ ] Overcomplicated abstractions (indirection without payoff)
|
||||||
|
- [ ] Copy-paste blocks that should be shared (only when it aids clarity)
|
||||||
|
- [ ] Functions too large / doing too much
|
||||||
|
- [ ] Stale comments contradicting the code
|
||||||
|
|
||||||
|
Suppress: harmless redundancy that aids readability, comment-on-threshold
|
||||||
|
requests, consistency-only changes.
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"maintainability","summary":"...","fix":"...","source":"maintainability"}`
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Performance Specialist
|
||||||
|
|
||||||
|
Focus: performance and resource efficiency in the diff.
|
||||||
|
|
||||||
|
- [ ] N+1 queries — missing eager loading
|
||||||
|
- [ ] Unindexed lookups in hot loops
|
||||||
|
- [ ] O(n×m) lookups in views/loops
|
||||||
|
- [ ] Blocking calls in async paths (sync subprocess, requests, sleep)
|
||||||
|
- [ ] Connection pool exhaustion, unbounded retries
|
||||||
|
- [ ] Bundle/asset size regressions (frontend)
|
||||||
|
- [ ] Redundant recomputation / missing caching
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"performance","summary":"...","fix":"...","source":"performance"}`
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Red Team Specialist (conditional)
|
||||||
|
|
||||||
|
Focus: find what the primary and specialist reviewers MISSED. Only dispatched
|
||||||
|
when the diff is large (>200 lines) or a specialist found a critical issue.
|
||||||
|
|
||||||
|
Think like an attacker and a chaos engineer:
|
||||||
|
|
||||||
|
- [ ] Cross-cutting concerns the specialist checklists do not cover
|
||||||
|
- [ ] Integration boundary failures (service-to-service, module-to-module)
|
||||||
|
- [ ] Failure modes: what breaks in production under load, restart, partial
|
||||||
|
failure
|
||||||
|
- [ ] Silent data corruption paths (wrong results without errors)
|
||||||
|
- [ ] Error handling that swallows failures
|
||||||
|
- [ ] Trust boundary violations
|
||||||
|
- [ ] Race conditions and edge cases the primary review missed
|
||||||
|
|
||||||
|
Be adversarial. No compliments — just the problems. Tag findings with
|
||||||
|
`"source":"red-team"`. Output `NO FINDINGS` when nothing new is found.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Security Specialist
|
||||||
|
|
||||||
|
Focus: security vulnerabilities in the diff.
|
||||||
|
|
||||||
|
- [ ] SQL injection (string interpolation, parameterized queries)
|
||||||
|
- [ ] AuthN/AuthZ bypasses, missing permission checks
|
||||||
|
- [ ] XSS (unsafe HTML rendering on user data)
|
||||||
|
- [ ] Sensitive data exposure / missing masking in logs and responses
|
||||||
|
- [ ] SSRF (fetching user/LLM-controlled URLs without allowlist)
|
||||||
|
- [ ] Command injection (`shell=True` + interpolation)
|
||||||
|
- [ ] Hardcoded secrets / credentials
|
||||||
|
- [ ] CSRF / missing rate limiting on auth endpoints
|
||||||
|
|
||||||
|
Insurance specialist — always runs, even when silent.
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"security","summary":"...","fix":"...","source":"security"}`
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Testing Specialist
|
||||||
|
|
||||||
|
Focus: test coverage gaps and tests that would catch the issues found.
|
||||||
|
|
||||||
|
- [ ] Every changed function has at least a happy-path test
|
||||||
|
- [ ] Negative/error paths tested (invalid input, failure branches)
|
||||||
|
- [ ] Edge cases mirror the happy-path structure
|
||||||
|
- [ ] If the fix for a finding can be caught by a test, propose a minimal
|
||||||
|
`test_stub` (framework-detected: jest/vitest/rspec/pytest/go-test)
|
||||||
|
- [ ] Integration coverage for critical flows (DB, external calls)
|
||||||
|
- [ ] No assertion-only tests that pass trivially
|
||||||
|
|
||||||
|
Output JSON lines:
|
||||||
|
`{"severity":"CRITICAL|INFORMATIONAL","confidence":N,"path":"file","line":N,"category":"testing","summary":"...","fix":"...","test_stub":"...","source":"testing"}`
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for the unified-review HTML report tool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from code_review_graph.scoring import build_report_data # noqa: E402
|
||||||
|
from code_review_graph.tools.scoring_tools import ( # noqa: E402
|
||||||
|
_load_report_template,
|
||||||
|
generate_report_func,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _review_data() -> dict:
|
||||||
|
return {
|
||||||
|
"scope": "change-level",
|
||||||
|
"tier": "standard",
|
||||||
|
"timestamp": "2026-08-05T00:00:00Z",
|
||||||
|
"files": "app.py, main.py",
|
||||||
|
"baseline": "generic",
|
||||||
|
"verdict": "\u2705 PASS",
|
||||||
|
"metrics": {
|
||||||
|
"sql_risk": {"value": 0, "grade": "good", "note": "heuristic"},
|
||||||
|
"exception_coverage": {"value": 33.33, "grade": "warn", "note": "heuristic"},
|
||||||
|
},
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"severity": "MAJOR",
|
||||||
|
"category": "Data",
|
||||||
|
"message": "Possible N+1 query",
|
||||||
|
"path": "src/app.py",
|
||||||
|
"line": 42,
|
||||||
|
"confidence": 7,
|
||||||
|
"fix": "Add eager loading",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"manual_review": ["Payment callback idempotency"],
|
||||||
|
"llm_judged": ["requirement_coverage", "logic_alignment"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildReportData:
|
||||||
|
def test_normalises_review_data(self):
|
||||||
|
data = build_report_data(_review_data())
|
||||||
|
assert data["verdict"] == "\u2705 PASS"
|
||||||
|
assert data["tier"] == "standard"
|
||||||
|
assert data["scope"] == "change-level"
|
||||||
|
assert data["metrics"]["sql_risk"]["grade"] == "good"
|
||||||
|
assert len(data["issues"]) == 1
|
||||||
|
assert data["issues"][0]["location"] == "src/app.py:42"
|
||||||
|
assert "requirement_coverage" in data["llm_judged"]
|
||||||
|
|
||||||
|
def test_empty_findings(self):
|
||||||
|
rd = _review_data()
|
||||||
|
rd["findings"] = []
|
||||||
|
data = build_report_data(rd)
|
||||||
|
assert data["issues"] == []
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
data = build_report_data({})
|
||||||
|
assert data["scope"] == "change-level"
|
||||||
|
assert data["tier"] == "standard"
|
||||||
|
assert data["verdict"] == "\u274c FAIL"
|
||||||
|
assert data["issues"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadTemplate:
|
||||||
|
def test_loads_package_asset(self):
|
||||||
|
template = _load_report_template()
|
||||||
|
assert "{{REPORT_DATA}}" in template
|
||||||
|
assert template.startswith("<!DOCTYPE html>")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateReport:
|
||||||
|
def test_writes_self_contained_html(self, tmp_path):
|
||||||
|
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
|
||||||
|
out = tmp_path / "sub" / "report.html"
|
||||||
|
result = generate_report_func(
|
||||||
|
_review_data(),
|
||||||
|
output_path=str(out),
|
||||||
|
repo_root=str(tmp_path),
|
||||||
|
)
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert Path(result["output_path"]).is_file()
|
||||||
|
html = Path(result["output_path"]).read_text(encoding="utf-8")
|
||||||
|
assert "{{REPORT_DATA}}" not in html
|
||||||
|
assert "\u2705 PASS" in html
|
||||||
|
assert "N+1" in html
|
||||||
|
|
||||||
|
def test_default_output_path_is_repo_root(self, tmp_path):
|
||||||
|
# repo_root must look like a project root: create .code-review-graph.
|
||||||
|
Path(tmp_path, ".code-review-graph").mkdir(exist_ok=True)
|
||||||
|
result = generate_report_func(_review_data(), repo_root=str(tmp_path))
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert result["output_path"].endswith("code-review-report.html")
|
||||||
|
assert Path(result["output_path"]).is_file()
|
||||||
|
|
||||||
|
def test_handles_missing_repo_root(self, tmp_path):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
generate_report_func(_review_data(), repo_root=str(tmp_path / "missing"))
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Tests for the unified-review scoring module (score_review metrics)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from code_review_graph.scoring import ( # noqa: E402
|
||||||
|
THRESHOLDS,
|
||||||
|
_grade,
|
||||||
|
compute_exception_coverage,
|
||||||
|
compute_redundancy_rate,
|
||||||
|
compute_sql_risk,
|
||||||
|
compute_vulnerability_heuristic,
|
||||||
|
dedupe_findings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_repo(tmp_path: Path) -> Path:
|
||||||
|
"""A small repository root with one source file."""
|
||||||
|
Path(tmp_path, "app.py").write_text(
|
||||||
|
'import sqlite3\n'
|
||||||
|
'def query(conn, user_id):\n'
|
||||||
|
' conn.execute("SELECT * FROM users WHERE id = " + str(user_id))\n'
|
||||||
|
'def safe(conn, user_id):\n'
|
||||||
|
' conn.execute("SELECT * FROM users WHERE id = ?", (user_id,))\n'
|
||||||
|
'def risky(a):\n'
|
||||||
|
' try:\n'
|
||||||
|
' return compute(a)\n'
|
||||||
|
' except Exception as e:\n'
|
||||||
|
' return None\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _grade threshold boundaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGradeThresholds:
|
||||||
|
def test_sql_risk_counts(self):
|
||||||
|
# 0 = good, warn_at count = warn, fail_at count = fail.
|
||||||
|
assert _grade("sql_risk", 0) == "good"
|
||||||
|
assert _grade("sql_risk", 1) == "warn"
|
||||||
|
assert _grade("sql_risk", 2) == "warn"
|
||||||
|
assert _grade("sql_risk", 3) == "fail"
|
||||||
|
|
||||||
|
def test_vulnerability_risk_counts(self):
|
||||||
|
assert _grade("vulnerability_risk", 0) == "good"
|
||||||
|
assert _grade("vulnerability_risk", 1) == "warn"
|
||||||
|
assert _grade("vulnerability_risk", 2) == "fail"
|
||||||
|
|
||||||
|
def test_exception_coverage_pct(self):
|
||||||
|
assert _grade("exception_coverage", 60.0) == "good"
|
||||||
|
assert _grade("exception_coverage", 40.0) == "warn"
|
||||||
|
assert _grade("exception_coverage", 20.0) == "fail"
|
||||||
|
|
||||||
|
def test_redundancy_rate_pct(self):
|
||||||
|
assert _grade("redundancy_rate", 5.0) == "good"
|
||||||
|
assert _grade("redundancy_rate", 15.0) == "warn"
|
||||||
|
assert _grade("redundancy_rate", 25.0) == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metric functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSQLRisk:
|
||||||
|
def test_detects_interpolated_sql(self, tmp_repo):
|
||||||
|
result = compute_sql_risk(["app.py"], tmp_repo)
|
||||||
|
assert result["value"] >= 1
|
||||||
|
assert result["grade"] in ("warn", "fail")
|
||||||
|
assert any("line" in loc for loc in result["evidence"])
|
||||||
|
|
||||||
|
def test_clean_file_no_risk(self, tmp_path):
|
||||||
|
Path(tmp_path, "clean.py").write_text(
|
||||||
|
"def f(a):\n return a * 2\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = compute_sql_risk(["clean.py"], tmp_path)
|
||||||
|
assert result["value"] == 0
|
||||||
|
assert result["grade"] == "good"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExceptionCoverage:
|
||||||
|
def test_counts_exception_paths(self, tmp_repo):
|
||||||
|
result = compute_exception_coverage(["app.py"], tmp_repo)
|
||||||
|
# app.py has a try/except path, so exception coverage > 0.
|
||||||
|
assert result["value"] > 0
|
||||||
|
assert "exception_path_lines" in result["evidence"]
|
||||||
|
|
||||||
|
def test_empty_file(self, tmp_path):
|
||||||
|
Path(tmp_path, "e.py").write_text("# comment only\n", encoding="utf-8")
|
||||||
|
result = compute_exception_coverage(["e.py"], tmp_path)
|
||||||
|
assert result["value"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRedundancy:
|
||||||
|
def test_repeated_blocks_detected(self, tmp_path):
|
||||||
|
body = (
|
||||||
|
"def transform_item(item):\n"
|
||||||
|
" return item.strip().lower().replace(' ', '_')\n"
|
||||||
|
)
|
||||||
|
Path(tmp_path, "r.py").write_text(
|
||||||
|
body + body + body + "def other(x):\n return x\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = compute_redundancy_rate(["r.py"], tmp_path)
|
||||||
|
# The shared normalised transform line appears >=3 times.
|
||||||
|
assert result["value"] > 0
|
||||||
|
assert len(result["evidence"]) >= 1
|
||||||
|
|
||||||
|
def test_no_redundancy(self, tmp_path):
|
||||||
|
Path(tmp_path, "n.py").write_text(
|
||||||
|
"def a(x):\n return x\n"
|
||||||
|
"def b(y):\n return y * 2\n"
|
||||||
|
"def c(z):\n return z - 1\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = compute_redundancy_rate(["n.py"], tmp_path)
|
||||||
|
assert result["value"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestVulnerabilityHeuristic:
|
||||||
|
def test_detects_secret_like_pattern(self, tmp_path):
|
||||||
|
Path(tmp_path, "s.py").write_text(
|
||||||
|
'password = "hunter2"\ndef f():\n pass\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
result = compute_vulnerability_heuristic(["s.py"], tmp_path)
|
||||||
|
assert result["value"] >= 1
|
||||||
|
assert result["grade"] in ("warn", "fail")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# dedupe_findings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDedupeFindings:
|
||||||
|
def test_same_fingerprint_merges(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 8, "source": "main"},
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 6, "source": "security"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
assert len(result["merged"]) == 1
|
||||||
|
# Multi-source confirmed: confidence 8 -> 9 (cap 10).
|
||||||
|
assert result["merged"][0]["confidence"] == 9.0
|
||||||
|
assert result["merged"][0]["multi_source_confirmed"] is True
|
||||||
|
|
||||||
|
def test_confidence_cap_at_10(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 10, "source": "main"},
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 10, "source": "security"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
assert result["merged"][0]["confidence"] == 10.0
|
||||||
|
|
||||||
|
def test_low_confidence_suppressed(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "style",
|
||||||
|
"severity": "INFORMATIONAL", "confidence": 1, "source": "main"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
assert result["merged"] == []
|
||||||
|
assert result["suppressed_by_confidence"] == 1
|
||||||
|
|
||||||
|
def test_appendix_routing(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "style",
|
||||||
|
"severity": "INFORMATIONAL", "confidence": 3, "source": "main"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
assert result["merged"][0]["display"] == "appendix"
|
||||||
|
|
||||||
|
def test_quality_score_formula(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 9, "source": "main"},
|
||||||
|
{"path": "b.py", "line": 2, "category": "style",
|
||||||
|
"severity": "INFORMATIONAL", "confidence": 7, "source": "main"},
|
||||||
|
{"path": "c.py", "line": 3, "category": "style",
|
||||||
|
"severity": "INFORMATIONAL", "confidence": 7, "source": "main"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
# 10 - (1*2 + 2*0.5) = 10 - 3 = 7.0
|
||||||
|
assert result["quality_score"] == 7.0
|
||||||
|
assert result["counts"] == {"critical": 1, "informational": 2}
|
||||||
|
|
||||||
|
def test_prior_suppression(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "style",
|
||||||
|
"severity": "INFORMATIONAL", "confidence": 8, "source": "main"},
|
||||||
|
]
|
||||||
|
prior = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "style"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings, suppress_prior=prior)
|
||||||
|
assert result["merged"] == []
|
||||||
|
assert result["suppressed_by_prior"] == 1
|
||||||
|
|
||||||
|
def test_distinct_fingerprints_not_merged(self):
|
||||||
|
findings = [
|
||||||
|
{"path": "a.py", "line": 1, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 8, "source": "main"},
|
||||||
|
{"path": "a.py", "line": 2, "category": "sql",
|
||||||
|
"severity": "CRITICAL", "confidence": 8, "source": "main"},
|
||||||
|
]
|
||||||
|
result = dedupe_findings(findings)
|
||||||
|
assert len(result["merged"]) == 2
|
||||||
@@ -118,7 +118,7 @@ class TestGenerateSkills:
|
|||||||
assert result.is_dir()
|
assert result.is_dir()
|
||||||
assert result == tmp_path / ".claude" / "skills"
|
assert result == tmp_path / ".claude" / "skills"
|
||||||
|
|
||||||
def test_creates_four_skill_subdirs(self, tmp_path):
|
def test_creates_five_skill_subdirs(self, tmp_path):
|
||||||
skills_dir = generate_skills(tmp_path)
|
skills_dir = generate_skills(tmp_path)
|
||||||
subdirs = sorted(f.name for f in skills_dir.iterdir() if f.is_dir())
|
subdirs = sorted(f.name for f in skills_dir.iterdir() if f.is_dir())
|
||||||
assert subdirs == [
|
assert subdirs == [
|
||||||
@@ -126,6 +126,7 @@ class TestGenerateSkills:
|
|||||||
"explore-codebase",
|
"explore-codebase",
|
||||||
"refactor-safely",
|
"refactor-safely",
|
||||||
"review-changes",
|
"review-changes",
|
||||||
|
"unified-review",
|
||||||
]
|
]
|
||||||
for d in skills_dir.iterdir():
|
for d in skills_dir.iterdir():
|
||||||
assert (d / "SKILL.md").is_file()
|
assert (d / "SKILL.md").is_file()
|
||||||
@@ -154,6 +155,7 @@ class TestGenerateSkills:
|
|||||||
"explore-codebase",
|
"explore-codebase",
|
||||||
"refactor-safely",
|
"refactor-safely",
|
||||||
"review-changes",
|
"review-changes",
|
||||||
|
"unified-review",
|
||||||
):
|
):
|
||||||
for skill_file in (
|
for skill_file in (
|
||||||
generated / skill_name / "SKILL.md",
|
generated / skill_name / "SKILL.md",
|
||||||
@@ -167,7 +169,7 @@ class TestGenerateSkills:
|
|||||||
result = generate_skills(tmp_path, skills_dir=custom)
|
result = generate_skills(tmp_path, skills_dir=custom)
|
||||||
assert result == custom
|
assert result == custom
|
||||||
assert result.is_dir()
|
assert result.is_dir()
|
||||||
assert len(list(result.iterdir())) == 4
|
assert len(list(result.iterdir())) == 5
|
||||||
|
|
||||||
def test_skill_content_includes_get_minimal_context(self, tmp_path):
|
def test_skill_content_includes_get_minimal_context(self, tmp_path):
|
||||||
"""Every skill template must reference get_minimal_context."""
|
"""Every skill template must reference get_minimal_context."""
|
||||||
@@ -192,7 +194,7 @@ class TestGenerateSkills:
|
|||||||
generate_skills(tmp_path)
|
generate_skills(tmp_path)
|
||||||
generate_skills(tmp_path)
|
generate_skills(tmp_path)
|
||||||
skills_dir = tmp_path / ".claude" / "skills"
|
skills_dir = tmp_path / ".claude" / "skills"
|
||||||
assert len(list(skills_dir.iterdir())) == 4
|
assert len(list(skills_dir.iterdir())) == 5
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateHooksConfig:
|
class TestGenerateHooksConfig:
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Tests for unified-review MCP tool wiring (registration + docs sections)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from code_review_graph.tools import ( # noqa: E402
|
||||||
|
dedupe_findings_func,
|
||||||
|
generate_report_func,
|
||||||
|
score_review_func,
|
||||||
|
)
|
||||||
|
from code_review_graph.tools.docs import get_docs_section # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistration:
|
||||||
|
def test_scoring_funcs_exported(self):
|
||||||
|
assert callable(score_review_func)
|
||||||
|
assert callable(dedupe_findings_func)
|
||||||
|
assert callable(generate_report_func)
|
||||||
|
|
||||||
|
def test_mcp_tools_exposed(self):
|
||||||
|
import code_review_graph.main as m
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"score_review_tool",
|
||||||
|
"dedupe_findings_tool",
|
||||||
|
"generate_report_tool",
|
||||||
|
):
|
||||||
|
assert hasattr(m, name), f"{name} not exposed by main module"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDocsSections:
|
||||||
|
def test_unified_review_section(self):
|
||||||
|
result = get_docs_section("unified-review")
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "score_review_tool" in result["content"]
|
||||||
|
|
||||||
|
def test_score_review_section(self):
|
||||||
|
result = get_docs_section("score-review")
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert "dedupe_findings_tool" in result["content"]
|
||||||
|
|
||||||
|
def test_unknown_section(self):
|
||||||
|
result = get_docs_section("does-not-exist")
|
||||||
|
assert result["status"] == "not_found"
|
||||||
|
assert "unified-review" in result["error"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateSkills:
|
||||||
|
def test_unified_review_generated(self, tmp_path):
|
||||||
|
from code_review_graph.skills import generate_skills
|
||||||
|
|
||||||
|
skills_dir = generate_skills(tmp_path)
|
||||||
|
skill_file = skills_dir / "unified-review" / "SKILL.md"
|
||||||
|
assert skill_file.is_file()
|
||||||
|
content = skill_file.read_text(encoding="utf-8")
|
||||||
|
assert "score_review_tool" in content
|
||||||
|
assert "get_minimal_context" in content
|
||||||
|
assert "detail_level" in content
|
||||||
|
|
||||||
|
def test_uninstall_knows_unified_review(self):
|
||||||
|
from code_review_graph.uninstall import _generated_skill_slugs
|
||||||
|
|
||||||
|
assert "unified-review" in _generated_skill_slugs()
|
||||||
Reference in New Issue
Block a user