chore: sync local changes, add Chinese docs and opencode config

This commit is contained in:
AuraK Developer
2026-08-31 11:37:03 +08:00
parent 307d2fd471
commit ecc55158c1
81 changed files with 7645 additions and 144 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ from .context_savings import (
format_context_savings,
)
__version__ = "2.3.7"
__version__ = "2.4.0"
__all__ = [
"__version__",
+97 -1
View File
@@ -43,6 +43,11 @@
.muted { color:var(--muted); font-size:.85rem; }
code { background:var(--bg); padding:.1rem .3rem; border-radius:4px;
font-size:.88em; }
details.reviewed { margin:.5rem 0; border:1px solid var(--border);
border-radius:6px; padding:.4rem .8rem; }
details.reviewed summary { cursor:pointer; font-weight:600; }
details.reviewed ul { margin:.4rem 0 0; padding-left:1.2rem; }
details.reviewed li { margin:.15rem 0; }
</style>
</head>
<body>
@@ -61,6 +66,23 @@ function verdictClass(v) {
return v.includes("PASS") ? "pass" : "fail";
}
// Collapsible list of the reviewed files (native <details>/<summary>, no JS).
// Accepts an array OR a comma-separated string (agents pass both). Falls
// back to the flat ``files`` string when nothing structured is given.
function renderReviewedFiles(data) {
let arr = data.reviewed_files;
if (typeof arr === "string") {
arr = arr.split(",").map(s => s.trim()).filter(Boolean);
}
if (!Array.isArray(arr) || !arr.length) {
return data.files ? `<p><b>文件:</b> ${esc(data.files)}</p>` : "";
}
return `<details class="reviewed">
<summary>审查文件 (${arr.length}) <span class="muted">点击展开/收起</span></summary>
<ul>${arr.map(f => `<li><code>${esc(f)}</code></li>`).join("")}</ul>
</details>`;
}
const metricLabels = {
sql_risk: "SQL 注入风险",
exception_coverage: "异常分支覆盖",
@@ -84,9 +106,83 @@ let html = `<h1>代码审查报告</h1>
</div>`;
if (data.quality_score != null) html += `<p><b>PR 质量分:</b> ${esc(data.quality_score)}/10</p>`;
if (data.timestamp) html += `<p class="muted">生成时间: ${esc(data.timestamp)}</p>`;
if (data.files) html += `<p><b>文件:</b> ${esc(data.files)}</p>`;
html += renderReviewedFiles(data);
if (data.summary) html += `<p>${esc(data.summary)}</p>`;
const cov = data.coverage || {};
// The coverage section renders whenever file-count coverage OR line/unit
// coverage was computed. gate="line+unit" (feature reviews) returns
// coverage_pct=null, so the 全库/高风险 rows are skipped and only the
// line/unit rows + spot-check render.
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const isLineOnly = cov.coverage_pct == null;
const covOk = cov.target_reached;
const covCls = covOk ? "good" : "fail";
const covStatus = covOk ? "✅ 达标" : "🔴 覆盖不足";
const oTarget = cov.overall_target ?? cov.target ?? "N/A";
const hTarget = cov.high_risk_target ?? cov.target ?? "N/A";
html += `<h2>覆盖度</h2>
<p><span class="verdict ${covCls}">${covStatus}</span></p>`;
if (!isLineOnly) {
html += `<p><b>全库覆盖:</b> ${esc(cov.coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.deep_read_count ?? "N/A")}/${esc(cov.total_files ?? "N/A")}(目标 ${esc(oTarget)}%</p>
<p><b>高风险覆盖:</b> ${esc(cov.high_risk_coverage_pct ?? "N/A")}% — 已深读 ${esc(cov.high_risk_deep_count ?? "N/A")}/${esc(cov.high_risk_total_files ?? "N/A")}(目标 ${esc(hTarget)}%</p>`;
}
html += renderLineUnit(cov);
html += renderSpotCheck(data.spot_check);
if (!isLineOnly && (cov.uncovered_files || []).length) {
html += `<p class="muted"><b>未深读文件:</b> ${esc(cov.uncovered_files.length)} 个(静默文件 ${esc((cov.silent_files || []).length)} 个)</p>`;
}
}
// Line / unit coverage (three-piece suite items 1-2). Fail-closed: a
// missing line/unit coverage renders "未执行 🔴" so reviews that skipped
// gate="both+line" or the three-piece data are visible, never silent green.
function renderLineUnit(cov) {
const linePct = cov.line_coverage_pct;
const unitPct = cov.unit_coverage_pct;
const lineTarget = cov.line_target ?? 95.0;
const unitTarget = cov.unit_target ?? 100.0;
const lineGap = (cov.line_gap_files || []).length;
const unitGap = (cov.unit_gap_files || []).length;
const missing = (cov.missing_data_files || []).length;
let s = "";
if (linePct == null || unitPct == null) {
s += `<p><b>行覆盖:</b> 未执行 🔴 <span class="muted">(coverage_tool 未用 gate="both+line" 或未传三件套数据)</span></p>`;
} else {
const lineOk = linePct >= lineTarget && lineGap === 0;
const unitOk = unitPct >= unitTarget && unitGap === 0;
s += `<p><b>行覆盖:</b> ${esc(linePct)}% — 目标 ${esc(lineTarget)}%(缺口 ${esc(lineGap)} 文件)${lineOk ? "✅" : "🔴"}</p>`;
s += `<p><b>单元覆盖:</b> ${esc(unitPct)}% — 目标 ${esc(unitTarget)}%(缺口 ${esc(unitGap)} 文件)${unitOk ? "✅" : "🔴"}</p>`;
}
if (missing) {
s += `<p class="muted"><b>三件套数据缺失:</b> ${esc(missing)} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)</p>`;
}
return s;
}
// Anti-fake spot check (three-piece suite item 3). Fail-closed: a
// missing/incomplete spot_check renders "未执行 🔴" so reviews that
// skipped the sampled re-read are visible instead of silently green.
function renderSpotCheck(spot) {
if (!spot) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(主代理未回读任何语义单元;Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check)</span></p>`;
}
const groups = spot.groups_sampled;
const files = spot.files_sampled;
const units = spot.units_sampled;
const fake = spot.fake_read_found || 0;
const rereread = spot.groups_rereread || [];
if (!units) {
return `<p><b>防伪抽验:</b> 未执行 🔴 <span class="muted">(spot_check 已上报但单元数为 0)</span></p>`;
}
const mark = (fake || rereread.length) ? "🔴 发现假读" : "✅";
let s = `<p><b>防伪抽验:</b> 抽样 ${esc(files)} 文件 / ${esc(units)} 单元 / ${esc(groups)} 组,假读 ${esc(fake)} ${mark}</p>`;
if (rereread.length) {
s += `<p class="muted">因假读重读组:${esc(rereread.join(", "))}</p>`;
}
return s;
}
const metrics = data.metrics || {};
const mkeys = Object.keys(metrics);
if (mkeys.length) {
+15 -2
View File
@@ -11,7 +11,7 @@ import os
import re
import subprocess
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from .constants import SECURITY_KEYWORDS as _SECURITY_KEYWORDS
from .flows import get_affected_flows
@@ -204,12 +204,20 @@ def _parse_numstat(log_text: str) -> dict[str, int]:
def compute_file_churn(
repo_root: str,
window_days: int | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, int]:
"""Count commits touching each file over a trailing window.
Returns an empty mapping when the window is invalid or Git cannot be
queried. Renames are deliberately not followed: churn belongs to the path
that existed in each commit.
Args:
repo_root: Repository root.
window_days: Trailing window; defaults to ``CRG_CHURN_WINDOW_DAYS``.
progress_cb: Optional ``(fraction, message)`` progress callback; the
single blocking step (``git log --numstat``) reports stage 0 before
and 1 after it runs.
"""
if window_days is None:
raw_window = os.environ.get("CRG_CHURN_WINDOW_DAYS", "90")
@@ -224,6 +232,8 @@ def compute_file_churn(
if window_days <= 0:
return {}
if progress_cb is not None:
progress_cb(0.0, "computing git churn")
try:
result = subprocess.run(
[
@@ -257,7 +267,10 @@ def compute_file_churn(
logger.warning("git log error: %s", exc)
return {}
return _parse_numstat(result.stdout)
parsed = _parse_numstat(result.stdout)
if progress_cb is not None:
progress_cb(1.0, "git churn done")
return parsed
# ---------------------------------------------------------------------------
@@ -1,4 +1,4 @@
# LLM-OPTIMIZED REFERENCE -- code-review-graph v2.3.6
# LLM-OPTIMIZED REFERENCE -- code-review-graph v2.4.0
AI coding agents: Read ONLY the exact `<section>` you need. Never load the whole file.
@@ -29,7 +29,32 @@ Full three-layer review: 1) get_minimal_context_tool + build_or_update_graph_too
</section>
<section name="project-review">
Whole-project or feature review (not diff-based): 1) get_minimal_context_tool + build_or_update_graph_tool; 2) get_architecture_overview_tool + list_communities_tool for the module map; 3) get_knowledge_gaps_tool + get_hub_nodes_tool + get_bridge_nodes_tool + find_large_functions_tool + get_surprising_connections_tool for high-risk areas; 4) whole-project: score_review_tool(all_files=True); feature: semantic_search_nodes_tool + query_graph_tool(children_of) to locate files, then score_review_tool(changed_files) + get_impact_radius_tool; 5) dedupe_findings_tool; 6) READ-ONLY adjudication; 7) generate_report_tool (format=both). Parse scope from the user instruction (全面/整个项目 -> whole-project, else feature + target). Target: <=12 tool calls, <=1800 tokens.
Whole-project or feature code review (not git-diff based). Parse scope: 全面/整个项目/所有/all -> whole-project; else feature + target keyword. review_data.scope MUST be exactly "whole-project" / "feature" / "change-level" — never a feature name like "evm" (the verify scripts rely on it).
WORKFLOW:
1) get_minimal_context_tool + build_or_update_graph_tool
2) get_architecture_overview_tool + list_communities_tool (module map)
3) get_knowledge_gaps_tool + get_hub_nodes_tool + get_bridge_nodes_tool + find_large_functions_tool + get_surprising_connections_tool (whole-project hotspots)
4) scoring: whole-project -> score_review_tool(all_files=True); feature -> semantic_search_nodes_tool(query=<target>) + query_graph_tool(pattern="children_of", target=<target>) to locate files, then score_review_tool(changed_files=<files>) + get_impact_radius_tool(changed_files=<files>)
5) dedupe_findings_tool(findings=<all raw findings>)
6) READ-ONLY adjudication (present findings by severity; fix/skip per batch; 🔴 blockers cannot be batch-skipped)
COVERAGE SELF-CHECK (mandatory before report):
- community_health_tool(): if needs_postprocess=true run code-review-graph postprocess first
- coverage_tool REQUIRES the three-piece deep-read data: pass file_read_ranges={<rel>:[[s,e],...]} AND file_semantic_units={<rel>:[{"range":[s,e],"kind":..,"name":..},...]} (record these while deep-reading each file). Without them the line/unit coverage is FAIL-CLOSED to 0% (each file listed in line_gap_files/missing_data_files).
- feature review: gate="line+unit" (line coverage >=95% + unit completeness gap-free ONLY; coverage_pct/high_risk_coverage_pct are null — no file-count/high-risk gate)
- whole-project: gate="both+line" (overall >=85% AND high-risk >=95% AND line >=95% AND unit gap-free)
- G1 confirm the deep-read list is complete; G2 spot-check 15% of silent_files (>=1 major found -> promote to full deep-read)
- G3 REREAD LOOP (HARD REQUIREMENT): if target_reached=false (line coverage <95% or unit gaps exist) you MUST NOT generate the report yet. Re-deep-read the files listed in line_gap_files / unit_gap_files / missing_data_files (read the missing line ranges / semantic units), then re-run coverage_tool until target_reached=true. A file you cannot fully read must be REMOVED from deep_read_files (the line+unit gate does not count file numbers — only files actually read to >=95% belong there). After generating the report run verify-line-coverage.ps1; exit 1 means re-read and regenerate.
generate_report_tool(review_data=..., output_path="docs/reviews/{name}-review-{YYYY-MM-DD-HHMMSS}"):
- MUST pass reviewed_files (an ARRAY of paths OR a comma-separated string — both are auto-normalised; report header renders a collapsible <details> list). files (if any) is a comma-separated STRING, never a list/array.
- TRANSMIT THE FULL coverage_tool RESULT VERBATIM into review_data.coverage (ALL fields: coverage_pct, high_risk_coverage_pct, grade, deep_read_count, total_files, high_risk_total_files, high_risk_deep_count, deep_read_weight, total_weight, target_reached, target, overall_target, high_risk_target, gate, line_coverage_pct, unit_coverage_pct, line_gap_files, unit_gap_files, unit_exempt_files, missing_data_files, remaining_files_to_target, remaining_weight_to_target, priority_deep_read_files, uncovered_files, silent_files, note) — do NOT hand-pick a subset, else counts render 0/0 and gap/missing hints disappear.
- metrics contains ONLY the five objective keys (sql_risk, exception_coverage, redundancy_rate, high_risk_density, vulnerability_risk); each entry MUST carry note (copy from the score_review_tool return value), do NOT mix in blast_radius / objective_grade / llm_judged.
- findings use message/fix fields (path + line synthesize location); counts use critical/informational keys.
- format="both" (writes .html + .md). Full schema: project-review skill SKILL.md + references/report-schema.md.
Target: <=14 tool calls, <=2000 tokens.
</section>
<section name="score-review">
@@ -38,7 +63,7 @@ score_review_tool returns objective metrics (sql_risk, exception_coverage, redun
<section name="commands">
Core MCP tools: get_minimal_context_tool, detect_changes_tool, get_review_context_tool, get_impact_radius_tool, query_graph_tool, semantic_search_nodes_tool, get_architecture_overview_tool, get_affected_flows_tool, list_flows_tool, list_communities_tool, refactor_tool, build_or_update_graph_tool, run_postprocess_tool, embed_graph_tool, list_graph_stats_tool, get_docs_section_tool
Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool
Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool, coverage_tool, community_health_tool
MCP prompts (7): review_changes, architecture_map, debug_issue, onboard_developer, pre_merge_check, unified_review, project_review
Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr, unified-review, project-review
CLI: code-review-graph [install|init|build|update|status|watch|visualize|serve|mcp|wiki|detect-changes|postprocess|embed|register|unregister|repos|eval|daemon]
+315 -47
View File
@@ -14,10 +14,13 @@ import asyncio
import logging
import os
import sys
import threading
import time
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
from fastmcp import FastMCP
from fastmcp.server.context import Context
from . import incremental as _incremental
from .graph import GraphStore
@@ -34,8 +37,11 @@ from .prompts import (
from .tools import (
apply_refactor_func,
build_or_update_graph,
community_health_func,
coverage_func,
cross_repo_search_func,
dedupe_findings_func,
deep_read_plan_func,
detect_changes_func,
embed_graph,
find_large_functions,
@@ -62,6 +68,7 @@ from .tools import (
query_graph,
refactor_func,
run_postprocess,
save_coverage_index_func,
score_review_func,
semantic_search_nodes,
traverse_graph_func,
@@ -91,6 +98,115 @@ def _resolve_repo_root(repo_root: Optional[str]) -> Optional[str]:
return repo_root if repo_root else _default_repo_root
class _ProgressSink:
"""Thread-safe progress channel from a worker thread back to the event loop.
The worker thread (``asyncio.to_thread``) calls the ``progress_cb`` that
the engine functions accept; each call stores the latest ``(fraction,
message)`` under a lock. The event-loop coroutine reads it via
:meth:`snapshot` on every heartbeat so ``Context.report_progress`` runs in
the MCP request context (where the contextvar / progress token lives) —
never from the worker thread.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._fraction = 0.0
self._message: Optional[str] = None
def progress_cb(self, fraction: float, message: Optional[str]) -> None:
with self._lock:
self._fraction = fraction
self._message = message
def snapshot(self) -> tuple[float, Optional[str]]:
with self._lock:
return self._fraction, self._message
#: Default interval (seconds) between MCP progress notifications. Must stay well
#: below the MCP SDK's 60s request timeout so opencode's
#: ``resetTimeoutOnProgress`` keeps the request alive on large repos.
_PROGRESS_HEARTBEAT = 15.0
async def _run_with_progress(
ctx: Context,
fn: Callable[..., Any],
*args: Any,
heartbeat: float = _PROGRESS_HEARTBEAT,
tool_timeout: int = 0,
provenance_root: Optional[str] = None,
**kwargs: Any,
) -> Any:
"""Run a blocking tool body in a thread while keeping the MCP client alive.
The MCP SDK default request timeout is 60s; opencode resets it whenever a
``notifications/progress`` arrives (``resetTimeoutOnProgress``). This
helper runs ``fn`` via ``asyncio.to_thread`` and, in the event-loop
coroutine (which is inside the MCP request context, so ``request_ctx`` /
``progressToken`` are available), periodically calls
``ctx.report_progress``. If ``fn``'s engine accepts a ``progress_cb``, a
thread-safe sink relays real progress; otherwise a heartbeat keeps the
connection alive regardless.
``provenance_root``, when given, wraps the result with
:func:`with_provenance` (called from the worker thread).
``tool_timeout`` (seconds, ``CRG_TOOL_TIMEOUT``; 0 = disabled) is a
server-side backstop that returns a readable error dict instead of letting
the client time out.
"""
loop = asyncio.get_running_loop()
sink = _ProgressSink()
# Bridge: if the target accepts progress_cb, wire the thread-safe sink in.
try:
import inspect as _inspect
accepts_cb = "progress_cb" in _inspect.signature(fn).parameters
except (TypeError, ValueError):
accepts_cb = False
if accepts_cb:
kwargs["progress_cb"] = sink.progress_cb
def _worker() -> Any:
result = fn(*args, **kwargs)
result = with_provenance(result, provenance_root)
return result
async def _run() -> Any:
task = asyncio.create_task(asyncio.to_thread(_worker))
try:
while not task.done():
if ctx is not None:
fraction, message = sink.snapshot()
await ctx.report_progress(fraction, 1, message or "processing...")
try:
await asyncio.wait_for(asyncio.shield(task), timeout=heartbeat)
except asyncio.TimeoutError:
pass
return task.result()
except Exception:
task.cancel()
raise
if tool_timeout > 0:
try:
return await asyncio.wait_for(_run(), timeout=tool_timeout)
except asyncio.TimeoutError:
message = (
f"tool timed out after {tool_timeout}s (CRG_TOOL_TIMEOUT). "
"Increase CRG_TOOL_TIMEOUT or reduce the review scope."
)
error_response = {"status": "error", "error": message, "summary": message}
if provenance_root is not None:
return await asyncio.to_thread(with_provenance, error_response, provenance_root)
return error_response
return await _run()
mcp = FastMCP(
"code-review-graph",
instructions=(
@@ -642,6 +758,7 @@ async def detect_changes_tool(
max_depth: int = 2,
repo_root: Optional[str] = None,
detail_level: str = "standard",
ctx: Context = None,
) -> dict:
"""Detect changes and produce risk-scored, priority-ordered review guidance.
@@ -649,9 +766,9 @@ async def detect_changes_tool(
flows, communities, and test coverage gaps. Returns risk scores and
prioritized review items. Replaces get_review_context for change-aware reviews.
Offloaded to a thread via ``asyncio.to_thread`` — runs `git diff`
subprocesses and BFS traversals that can take several seconds on
large repos. See: #46, #136.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
base: Git ref to diff against. Default: HEAD~1.
@@ -663,32 +780,14 @@ async def detect_changes_tool(
token-efficient summary. Default: standard.
"""
root = _resolve_repo_root(repo_root)
def _run() -> dict:
return with_provenance(detect_changes_func(
base=base, changed_files=changed_files,
include_source=include_source, max_depth=max_depth,
repo_root=root, detail_level=detail_level,
), root)
coro = asyncio.to_thread(_run)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
if tool_timeout > 0:
try:
return await asyncio.wait_for(coro, timeout=tool_timeout)
except asyncio.TimeoutError:
message = (
f"detect_changes_tool timed out after {tool_timeout}s. "
"Reduce scope with CRG_MAX_CHANGED_FUNCS / CRG_MAX_TRANSITIVE_FRONTIER, "
"or increase CRG_TOOL_TIMEOUT."
)
error_response = {
"status": "error",
"error": message,
"summary": message,
}
return await asyncio.to_thread(with_provenance, error_response, root)
return await coro
return await _run_with_progress(
ctx, detect_changes_func,
base=base, changed_files=changed_files,
include_source=include_source, max_depth=max_depth,
repo_root=root, detail_level=detail_level,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
@@ -699,17 +798,19 @@ async def score_review_tool(
repo_root: Optional[str] = None,
detail_level: str = "standard",
all_files: bool = False,
ctx: Context = None,
) -> 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.
vulnerability) that back the unified-review scoring. These five
objective metrics are the full report metric set; ``llm_judged`` is
returned empty for backward compatibility.
Offloaded to a thread via ``asyncio.to_thread`` — runs `git log`
subprocesses and graph queries that can take several seconds.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
changed_files: Files to score (auto-detected from git diff if
@@ -724,15 +825,14 @@ async def score_review_tool(
whole-project reviews (default: False).
"""
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, all_files=all_files,
), root)
return await asyncio.to_thread(_run)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, score_review_func,
changed_files=changed_files, base=base,
include_churn=include_churn, repo_root=root,
detail_level=detail_level, all_files=all_files,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
@@ -763,6 +863,175 @@ def dedupe_findings_tool(
)
@mcp.tool()
async def coverage_tool(
deep_read_files: list,
all_files: bool = True,
include_churn: bool = True,
gate: str = "high_risk",
include_prior: bool = False,
file_read_ranges: Optional[dict] = None,
file_semantic_units: Optional[dict] = None,
repo_root: Optional[str] = None,
ctx: Context = None,
) -> dict:
"""Compute file-count review coverage for deep-read files.
Coverage = number of deep-read files / number of all source files.
Per-file risk weight (worst metric grade + graph topology hits +
normalised git churn) still ranks the priority deep-read list so the
highest-risk files are read first. Backs the project-review Step 7.5
coverage self-check (G3).
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths). Required.
all_files: When True (default), the denominator is every source
file in the graph (whole-project semantics).
include_churn: Include git-churn as the w3 weight term.
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
``"both"`` (both overall and high-risk must meet the target) /
``"both+line"`` (both + line/unit three-piece gate) /
``"line+unit"`` (line + unit only, feature reviews; file-count
/ high-risk coverage skipped and returned as ``None``).
include_prior: When True, merge the cross-round coverage index
(files whose SHA is unchanged) into the deep-read set.
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
sub-agent actually read per deep-read file. REQUIRED for
gate="both+line"/"line+unit" — without it those files are
FAIL-CLOSED as line gaps (line coverage 0%).
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
reported semantic units per deep-read file (same gates).
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with coverage_pct, grade, deep_read_count, total_files,
target_reached, target, remaining_files_to_target,
priority_deep_read_files, uncovered_files, silent_files and note.
``silent_files`` feeds the G2 spot-check sampling.
"""
root = _resolve_repo_root(repo_root)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, coverage_func,
deep_read_files=deep_read_files, all_files=all_files,
include_churn=include_churn, gate=gate,
include_prior=include_prior,
file_read_ranges=file_read_ranges or {},
file_semantic_units=file_semantic_units or {},
repo_root=root,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
async def deep_read_plan_tool(
repo_root: Optional[str] = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
include_prior: bool = False,
deep_read_files: Optional[list] = None,
ctx: Context = None,
) -> dict:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects the highest-risk files not yet deep-read until the
file-count overall coverage target is reached, then groups them by
parent directory (≤ ``batch_size`` per group) so each group can be
dispatched to one parallel sub-agent. With ``include_prior``, files
already deep-read in earlier rounds (coverage index, SHA unchanged)
are excluded so incremental reviews only re-read what actually
changed or is new.
Runs in a worker thread while the event loop reports progress
notifications (keeps MCP clients such as opencode from timing out on
large repos).
Args:
repo_root: Repository root path. Auto-detected if omitted.
target_coverage: Overall coverage target percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
include_prior: Exclude files already covered by the cross-round
coverage index from the plan.
deep_read_files: Files already deep-read this round.
Returns:
Dict with current_coverage_pct, current/target/remaining file
counts, planned_files (priority-ordered), directory groups and
estimated_batches.
"""
root = _resolve_repo_root(repo_root)
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
return await _run_with_progress(
ctx, deep_read_plan_func,
repo_root=root, target_coverage=target_coverage,
batch_size=batch_size, include_churn=include_churn,
include_prior=include_prior, deep_read_files=deep_read_files,
tool_timeout=tool_timeout, provenance_root=root,
)
@mcp.tool()
def save_coverage_index_tool(
deep_read_files: list,
file_read_ranges: Optional[dict] = None,
file_semantic_units: Optional[dict] = None,
repo_root: Optional[str] = None,
) -> dict:
"""Persist the deep-read file list to the cross-round coverage index.
Records each file's per-file SHA at HEAD so a later review can tell
which previously-deep-read files are still current (SHA unchanged)
and which are stale and must be re-read. Call at the end of every
review round (after the report is generated).
Args:
deep_read_files: Files deep-read this round (relative or absolute).
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
persist so a later review can reuse line coverage of
SHA-unchanged files.
file_semantic_units: Optional {rel_path: [{range,kind,name},...]}
semantic units to persist likewise.
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with index_path, entry count and head_sha.
"""
return save_coverage_index_func(
deep_read_files=deep_read_files,
file_read_ranges=file_read_ranges or {},
file_semantic_units=file_semantic_units or {},
repo_root=repo_root,
)
@mcp.tool()
def community_health_tool(
repo_root: Optional[str] = None,
) -> dict:
"""Check node->community attribution health (nodes.community_id).
Detects the desync where ``communities.size`` is correct but
``nodes.community_id`` is mostly NULL (e.g. after an incremental
rebuild). Returns ``needs_postprocess``; the review skill should run
``code-review-graph postprocess`` when True before computing coverage.
Args:
repo_root: Repository root path. Auto-detected if omitted.
Returns:
Dict with total_nodes, attributed_nodes, non_file_nodes,
attribution_pct, needs_postprocess and note.
"""
return community_health_func(repo_root=repo_root)
@mcp.tool()
async def generate_report_tool(
review_data: dict,
@@ -1135,18 +1404,17 @@ def pre_merge_check(base: str = "HEAD~1") -> list[dict]:
@mcp.prompt()
def unified_review(base: str = "HEAD~1", tier: str = "standard") -> list[dict]:
def unified_review(base: str = "HEAD~1") -> list[dict]:
"""Three-layer unified review (CRG graph context + scoring + dedupe + report).
Fuses graph context with the objective scoring metrics, finding merge,
and the standalone HTML report. READ-ONLY: every finding waits for a
manual fix decision.
manual fix decision. Runs at the fixed standard tier (all layers).
Args:
base: Git ref to diff against. Default: HEAD~1.
tier: Review tier (fast / standard / strict). Default: standard.
"""
return unified_review_prompt(base=base, tier=tier)
return unified_review_prompt(base=base)
@mcp.prompt()
+3 -18
View File
@@ -163,7 +163,6 @@ def pre_merge_check_prompt(base: str = "HEAD~1") -> list[Message]:
def unified_review_prompt(
base: str = "HEAD~1",
tier: str = "standard",
) -> list[Message]:
"""Three-layer unified review workflow (READ-ONLY).
@@ -174,24 +173,11 @@ def unified_review_prompt(
Args:
base: Git ref to diff against. Default: HEAD~1.
tier: Review tier. "fast" (Layer 1 + blockers only),
"standard" (all layers), "strict" (full + per-item
confirmation). Default: standard.
"""
tier_notes = {
"fast": (
"fast tier: run Layers 1 and the blocker check only; "
"skip Layer 2 metrics and the report."
),
"strict": (
"strict tier: full review; every blocker and major finding "
"needs per-item user confirmation before it is recorded."
),
}.get(tier, "standard tier: run all layers.")
return _user(
f"{_TOKEN_EFFICIENCY_PREAMBLE}\n"
f"## Unified Review Workflow (base={base}, tier={tier})\n"
f"{tier_notes}\n"
f"## Unified Review Workflow (base={base})\n"
"Standard tier: run all layers.\n"
"**READ-ONLY.** Present every finding for a manual fix decision. "
"Never modify code, commit, or push.\n"
'1. Call `get_minimal_context(task="unified review")` for the '
@@ -202,8 +188,7 @@ def unified_review_prompt(
"files, risk score, test gaps and affected flows.\n"
'4. Call `score_review(detail_level="standard")` for the '
"objective metrics (sql_risk, exception_coverage, redundancy, "
"high-risk density, vulnerability). Trust the tool grades; "
"LLM-judged metrics are in `llm_judged`.\n"
"high-risk density, vulnerability). Trust the tool grades.\n"
"5. Review the changed source (Layer 1 chain decomposition) and "
"produce findings with severity (blocker/major/minor), "
"confidence (1-10), file:line and a proposed fix.\n"
+1130 -36
View File
@@ -2,10 +2,9 @@
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.
factors used by the gstack-review workflow. The report metric set
consists solely of the five objective heuristic metrics computed here;
``llm_judged`` is returned empty for backward compatibility.
The three public entry points are:
@@ -22,7 +21,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from .changes import (
compute_file_churn,
@@ -32,6 +31,7 @@ from .changes import (
from .constants import SECURITY_KEYWORDS
from .graph import GraphStore
from .parser import normalize_file_path
from collections import Counter
# ---------------------------------------------------------------------------
# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
@@ -235,8 +235,8 @@ def compute_sql_risk(
"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.",
"note": "字符串拼接 SQL 的启发式扫描。修复前请逐一确认每个位置;"
"用 EXPLAIN 评估性能风险。",
}
@@ -265,8 +265,7 @@ def compute_exception_coverage(
"exception_path_lines": exc,
"normal_path_lines": normal,
},
"note": "Heuristic ratio of exception/error-path lines. "
"Review edge cases and error handling manually.",
"note": "异常/错误路径行的启发式占比。请人工复核边界条件与错误处理。",
}
@@ -281,8 +280,8 @@ def compute_redundancy_rate(
"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.",
"note": "重复代码块启发式占比(规范化行在 >=3 处出现)。"
"抽取公共逻辑前请确认。",
}
@@ -307,8 +306,8 @@ def compute_high_risk_density(
"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.",
"note": "变更中未检出并发/事务/数据一致性模式——"
"除非审查发现缺口,标记为 N/A。",
}
covered = 0
for _rel, line, _no in relevant:
@@ -330,8 +329,7 @@ def compute_high_risk_density(
for rel, line, no in relevant[:20]
],
},
"note": "Density of concurrency/transaction/security patterns. "
"This is a review-attention signal, not a correctness score.",
"note": "并发/事务/安全模式密度。属审查注意力信号,非正确性评分。",
}
@@ -355,10 +353,8 @@ def compute_vulnerability_heuristic(
"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.",
"note": "OWASP/密钥模式的启发式扫描。真实漏洞需依赖扫描器"
"npm audit、pip-audit、govulncheck)确认。",
}
@@ -428,8 +424,8 @@ def compute_risk_factors(
"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.",
"note": "结构性风险因子。高变更频率 + 跨社区耦合 + 中枢依赖"
"意味着该改动需要额外审查关注。",
}
@@ -438,6 +434,7 @@ def score_review(
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute all objective Layer-2 metrics for a set of changed files.
@@ -446,18 +443,27 @@ def score_review(
repo_root: Repository root.
changed_files: Changed file paths relative to ``repo_root``.
include_churn: Include git-churn risk factors.
progress_cb: Optional ``(fraction, message)`` progress callback;
invoked once per metric (fraction = k/5).
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),
metric_fns = {
"sql_risk": compute_sql_risk,
"exception_coverage": compute_exception_coverage,
"redundancy_rate": compute_redundancy_rate,
"high_risk_density": compute_high_risk_density,
"vulnerability_risk": compute_vulnerability_heuristic,
}
metrics: dict[str, Any] = {}
for idx, (name, fn) in enumerate(metric_fns.items()):
if progress_cb is not None:
progress_cb(idx / len(metric_fns), f"computing {name}")
metrics[name] = fn(changed_files, repo_root)
if progress_cb is not None:
progress_cb(1.0, "metrics done")
risk_factors = compute_risk_factors(
store, repo_root, changed_files, include_churn=include_churn,
@@ -491,13 +497,7 @@ def score_review(
"summary": "\n".join(summary_parts),
"metrics": metrics,
"risk_factors": risk_factors,
"llm_judged": [
"requirement_coverage",
"logic_alignment",
"llm_trust_boundary",
"shell_injection",
"enum_completeness",
],
"llm_judged": [],
"objective_grade": worst,
}
@@ -607,6 +607,34 @@ def dedupe_findings(
# ---------------------------------------------------------------------------
def _normalise_coverage(coverage: Any) -> dict[str, Any] | None:
"""Normalise the ``coverage`` block passed into the HTML report feed.
If ``coverage`` is not a dict (e.g. an AI agent passed ``True`` or a
partial subset), return ``None`` so the report renders no coverage
section rather than crashing. When a dict is given, keep it as-is so
every field the agent chose to transmit survives; the HTML/Markdown
templates provide ``N/A`` fallbacks for missing counts so a partial
transmission never shows a misleading ``0/0``.
"""
if not isinstance(coverage, dict) or not coverage:
return None
return coverage
#: The five objective metrics produced by score_review_tool. Any other key
#: an agent passes in review_data.metrics (e.g. blast_radius, objective_grade,
#: llm_judged leftovers) is filtered out so the report only renders the
#: canonical five rows.
_OBJECTIVE_METRIC_KEYS: frozenset[str] = frozenset({
"sql_risk",
"exception_coverage",
"redundancy_rate",
"high_risk_density",
"vulnerability_risk",
})
def build_report_data(
review_data: dict[str, Any],
) -> dict[str, Any]:
@@ -617,11 +645,33 @@ def build_report_data(
``tier``, ``scope`` fields. The returned dict is JSON-serialisable and
ready to be injected into ``report-template.html`` as ``{{REPORT_DATA}}``.
"""
# Normalise ``files``: agents sometimes pass a list instead of a
# comma-separated string. Accept both; a list is joined so the report
# never renders Python/JSON list syntax.
raw_files = review_data.get("files", "")
files_text = (
", ".join(str(f) for f in raw_files)
if isinstance(raw_files, list)
else raw_files
)
# ``reviewed_files`` drives the collapsible <details> list. Agents may
# pass it as an array OR as a comma-separated string; accept both (a
# string is split so the Markdown renderer never iterates char-by-char).
# Fall back to the ``files`` array when nothing structured was passed.
reviewed_files = review_data.get("reviewed_files") or []
if isinstance(reviewed_files, str):
reviewed_files = [
p.strip() for p in reviewed_files.split(",") if p.strip()
]
elif not reviewed_files and isinstance(raw_files, list):
reviewed_files = [str(f) for f in raw_files]
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", ""),
"files": files_text,
"reviewed_files": reviewed_files,
"baseline": review_data.get("baseline", "generic"),
"verdict": review_data.get("verdict", "❌ FAIL"),
"quality_score": review_data.get("quality_score"),
@@ -630,10 +680,16 @@ def build_report_data(
"issues": [],
"manual_review": review_data.get("manual_review", []),
"llm_judged": review_data.get("llm_judged", []),
"coverage": _normalise_coverage(review_data.get("coverage")),
"spot_check": _normalise_coverage(review_data.get("spot_check")),
}
# Objective metrics only: filter out stray keys (blast_radius,
# objective_grade, ...) so the report always renders the canonical five.
metrics = review_data.get("metrics") or {}
for name, m in metrics.items():
if name not in _OBJECTIVE_METRIC_KEYS:
continue
if isinstance(m, dict):
data["metrics"][name] = {
"grade": m.get("grade"),
@@ -719,7 +775,7 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
if data.get("timestamp"):
lines.append(f"- **生成时间**{data['timestamp']}")
if data.get("files"):
if data.get("files") and not (data.get("reviewed_files") or []):
lines.append(f"- **文件**{data['files']}")
qs = data.get("quality_score")
if qs is not None:
@@ -732,6 +788,26 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
lines.append("")
# Reviewed files (collapsible). Renders from the structured
# ``reviewed_files`` array when present; otherwise the flat ``files``
# string is used (as the meta line above). Defensive: if a raw string
# ever slips through (build_report_data already normalises it), split it
# so we never iterate a string char-by-char.
reviewed = data.get("reviewed_files") or []
if isinstance(reviewed, str):
reviewed = [p.strip() for p in reviewed.split(",") if p.strip()]
if reviewed:
lines.append(f"**审查文件({len(reviewed)} 个)**")
lines.append("")
lines.append("<details>")
lines.append(f"<summary>点击展开 / 收起({len(reviewed)} 个文件)</summary>")
lines.append("")
for f in reviewed:
lines.append(f"- `{f}`")
lines.append("")
lines.append("</details>")
lines.append("")
# Objective metrics
metrics = data.get("metrics") or {}
if metrics:
@@ -749,6 +825,101 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append(f"| {label} | {value_text} | {grade_text} | {note} |")
lines.append("")
# Coverage. For gate="both+line" (whole-project) the file-count and
# high-risk rows render; for gate="line+unit" (feature reviews) the
# engine returns coverage_pct=None so only the line/unit rows plus the
# fail-closed status line render.
coverage = data.get("coverage") or {}
if coverage:
pct = coverage.get("coverage_pct")
hr_pct = coverage.get("high_risk_coverage_pct")
grade = coverage.get("grade") or "na"
grade_text = _GRADE_LABELS.get(grade, grade)
deep_read = coverage.get("deep_read_count", "N/A")
total = coverage.get("total_files", "N/A")
hr_total = coverage.get("high_risk_total_files", "N/A")
hr_deep = coverage.get("high_risk_deep_count", "N/A")
overall_target = coverage.get("overall_target", coverage.get("target", "N/A"))
hr_target = coverage.get("high_risk_target", coverage.get("target", "N/A"))
reached = coverage.get("target_reached", False)
status = "✅ 达标" if reached else "🔴 覆盖不足"
uncovered = coverage.get("uncovered_files") or []
silent = coverage.get("silent_files") or []
# Line / unit coverage (gate="both+line" / "line+unit"): fail-closed.
# A missing value means the review never ran the line-coverage gate -
# surface it explicitly instead of silently omitting the field.
line_pct = coverage.get("line_coverage_pct")
unit_pct = coverage.get("unit_coverage_pct")
line_target = coverage.get("line_target", 95.0)
unit_target = coverage.get("unit_target", 100.0)
line_gap_n = len(coverage.get("line_gap_files") or [])
unit_gap_n = len(coverage.get("unit_gap_files") or [])
missing_n = len(coverage.get("missing_data_files") or [])
lines.append("## 覆盖度\n")
if pct is not None:
lines.append(
f"- **覆盖度(全库)**{pct}% — 已深读 {deep_read}/{total} 个源文件"
f"(目标 {overall_target}%"
)
lines.append(
f"- **覆盖度(高风险)**{hr_pct}% — 已深读 {hr_deep}/{hr_total} 个高风险文件"
f"(目标 {hr_target}%{status}"
)
else:
lines.append(
f"- **状态**{status}gate=\"line+unit\":仅行/单元覆盖,不做文件数覆盖检查)"
)
if line_pct is None or unit_pct is None:
lines.append("- **行覆盖**:未执行 🔴(coverage_tool 未用 gate=\"both+line\" 或未传三件套数据)")
else:
line_ok = "" if line_pct >= line_target and line_gap_n == 0 else "🔴"
unit_ok = "" if unit_pct >= unit_target and unit_gap_n == 0 else "🔴"
lines.append(
f"- **行覆盖**{line_pct}% — 目标 {line_target}%(缺口 {line_gap_n} 文件){line_ok}"
)
lines.append(
f"- **单元覆盖**{unit_pct}% — 目标 {unit_target}%(缺口 {unit_gap_n} 文件){unit_ok}"
)
if missing_n:
lines.append(
f"- **三件套数据缺失**{missing_n} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)"
)
if uncovered:
lines.append(
f"- **未深读文件**{len(uncovered)}"
f"(静默文件 {len(silent)} 个)"
)
lines.append("")
# Anti-fake spot check (three-piece suite item 3). Fail-closed: a
# missing/incomplete spot_check renders "未执行 🔴" so reviews that
# skipped the sampled re-read are visible instead of silently green.
spot = data.get("spot_check")
if spot:
groups = spot.get("groups_sampled")
files = spot.get("files_sampled")
units = spot.get("units_sampled")
fake = spot.get("fake_read_found", 0)
rereread = spot.get("groups_rereread") or []
if units:
mark = "🔴 发现假读" if (fake or rereread) else ""
lines.append(
f"- **防伪抽验**:抽样 {files} 文件 / {units} 单元 / {groups} 组,"
f"假读 {fake}{mark}"
)
if rereread:
lines.append(
f" - 因假读重读组:{', '.join(rereread)}"
)
else:
lines.append("- **防伪抽验**:未执行 🔴(spot_check 已上报但单元数为 0")
else:
lines.append(
"- **防伪抽验**:未执行 🔴(主代理未回读任何语义单元;"
"Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check"
)
lines.append("")
# Issues
issues = data.get("issues") or []
lines.append(f"## 问题清单({len(issues)}\n")
@@ -790,3 +961,926 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append("")
return "\n".join(lines).strip() + "\n"
# ---------------------------------------------------------------------------
# Coverage computation (review coverage of the whole project)
# ---------------------------------------------------------------------------
#: Coverage targets (percentage of source files deep-read). Fixed to the
#: single standard tier; the fast/strict tiers were removed. Per-gate
#: targets: ``overall`` is the whole-project file-count target, ``high_risk``
#: the signal-flagged subset target. ``gate="both"`` requires both to pass.
COVERAGE_TARGETS: dict[str, dict[str, float]] = {
"standard": {
"overall": 85.0,
"high_risk": 95.0,
},
}
def _coverage_targets(tier: str = "standard") -> tuple[float, float]:
"""Resolve the ``(overall, high_risk)`` target pair for a tier.
Backwards-compatible: a legacy flat float value (e.g. ``95.0``) is
treated as applying to *both* gates. A per-gate dict is honoured as-is.
"""
cfg = COVERAGE_TARGETS.get(tier, COVERAGE_TARGETS.get("standard", {}))
if isinstance(cfg, dict):
return (
float(cfg.get("overall", 85.0)),
float(cfg.get("high_risk", 95.0)),
)
value = float(cfg)
return value, value
#: Subdirectories excluded from the coverage denominator (non-source).
COVERAGE_EXCLUDE_DIRS: tuple[str, ...] = (
"docs/",
"test-output/",
"tests/",
"scripts/",
"node_modules/",
".git/",
)
#: Weight of each metric grade for the w1 term (worst grade wins per file).
_GRADE_WEIGHT: dict[str, float] = {
"fail": 3.0,
"warn": 2.0,
"good": 1.0,
"na": 0.5,
}
def _is_source_file(rel_path: str) -> bool:
"""Heuristic filter for source files vs. docs/tests/generated output."""
normalized = rel_path.replace("\\", "/").lower()
if any(normalized.startswith(d) for d in COVERAGE_EXCLUDE_DIRS):
return False
if normalized.endswith(
(".bak", ".clean", ".debug1", ".fullbak", ".tmp", ".map")
):
return False
return True
def _per_file_w1(file_rel: str, repo_root: Path) -> float:
"""Compute the risk grade (w1 term) for a single source file.
Uses the per-file SQL / vulnerability / redundancy heuristic scans.
``exception_coverage`` is deliberately excluded: on a per-file basis it
is ~always ``fail`` for real-world modules (most files have few
explicit error branches), which gives w1 zero discriminative power.
``high_risk_density`` is excluded too (it is ~always 100% for any file
containing SQL/async/transaction markers). ``repo_root`` is resolved
against when ``file_rel`` is not absolute.
"""
raw = file_rel.replace("\\", "/")
candidate = Path(file_rel)
if not candidate.is_absolute():
candidate = repo_root / raw
if not candidate.is_file():
return 0.0
try:
lines = candidate.read_text(
encoding="utf-8", errors="replace",
).splitlines()
except OSError:
return 0.0
if not lines:
return 0.0
line_items = [(raw, line, i) for i, line in enumerate(lines, start=1)]
sql_hits = _count_matching(line_items, _SQL_RISK_PATTERNS)
sql_grade = _grade("sql_risk", float(sql_hits))
vuln_hits = _count_matching(line_items, _VULNERABILITY_PATTERNS)
vuln_grade = _grade("vulnerability_risk", float(vuln_hits))
sig_count: dict[str, int] = Counter()
for _p, line, _n in line_items:
sig = _normalized_signature(line)
if len(sig) >= 24:
sig_count[sig] += 1
dup_lines = sum(c for c in sig_count.values() if c >= 3)
redundancy_rate = (dup_lines / len(line_items) * 100.0)
redund_grade = _grade("redundancy_rate", redundancy_rate)
grades = [
g for g in (sql_grade, vuln_grade, redund_grade)
if g != "na"
]
if not grades:
return _GRADE_WEIGHT["na"]
worst = max(grades, key=lambda g: _GRADE_WEIGHT.get(g, 0.0))
return _GRADE_WEIGHT.get(worst, _GRADE_WEIGHT["na"])
def _topology_hits(store: GraphStore, file_rel: str) -> float:
"""Count graph topology signal hits (w2 term) for a file's nodes.
Uses hub degree (>= 10 incoming calls) and untested hotspot flags as
lightweight proxies; avoids re-running the top-N truncated tools so the
w2 term is computed over the whole graph, not the first N nodes.
"""
abs_path = normalize_file_path(Path(file_rel))
nodes = store.get_nodes_by_file(abs_path)
if not nodes:
return 0.0
hits = 0
for n in nodes:
edges = store.get_edges_by_target(n.qualified_name)
incoming = [
e for e in edges
if e.kind in ("CALLS", "REFERENCES", "IMPLEMENTS")
]
if len(incoming) >= 10:
hits += 1
if not n.is_test and len(incoming) >= 5 and not _has_tested_by(store, n.qualified_name):
hits += 0.5
return hits
def _has_tested_by(store: GraphStore, qualified_name: str) -> bool:
try:
edges = store.get_edges_by_target(qualified_name)
return any(e.kind == "TESTED_BY" for e in edges)
except Exception:
return False
def _file_weights(
store: GraphStore,
repo_root: Path,
source_files: list[str],
source_abs: list[str],
churn_map: dict[str, int],
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, dict[str, Any]]:
"""Compute the risk weight ``w = w1(grade) + w2(topology) + w3(churn)``
for every source file, plus its high-risk flag.
Shared by :func:`compute_coverage` and :func:`deep_read_plan` so both
derive weights from exactly the same model. Keys are the normalized
absolute paths used by the graph identity.
Args:
store: Open graph store.
repo_root: Repository root.
source_files: Source file paths relative to ``repo_root``.
source_abs: Parallel list of normalized absolute paths.
churn_map: Per-file commit counts.
progress_cb: Optional ``(fraction, message)`` progress callback,
invoked every 50 files.
"""
weights: dict[str, dict[str, Any]] = {}
max_churn = max(churn_map.values()) if churn_map else 1
total = max(len(source_files), 1)
for idx, (f, f_abs) in enumerate(zip(source_files, source_abs)):
if progress_cb is not None and idx % 50 == 0:
progress_cb(idx / total, f"computing risk weights ({idx}/{total})")
w1 = _per_file_w1(f_abs, repo_root)
w2 = _topology_hits(store, f_abs)
rel_for_churn = f.lstrip("/").replace("\\", "/")
raw_churn = churn_map.get(f, churn_map.get(rel_for_churn, 0))
w3 = (raw_churn / max_churn) if max_churn else 0.0
is_high_risk = (w1 >= 2.0 or w2 > 0.0 or raw_churn >= 3)
weights[f_abs] = {
"w": w1 + w2 + w3,
"w1": w1,
"w2": w2,
"w3": w3,
"raw_churn": raw_churn,
"is_high_risk": is_high_risk,
}
if progress_cb is not None:
progress_cb(1.0, "risk weights done")
return weights
def _real_line_count(repo_root: Path, rel: str, cache: dict) -> int:
"""Real line count of a source file, cached. Independent of graph node
``line_end`` (verified to have a +-1 skew vs actual file length)."""
if rel in cache:
return cache[rel]
try:
n = len((repo_root / rel).read_text(encoding="utf-8", errors="replace").splitlines())
except OSError:
n = 0
cache[rel] = n
return n
def _union_len(ranges: list[list[int]]) -> int:
"""Covered line count of a list of inclusive [s,e] ranges (merged)."""
if not ranges:
return 0
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return sum(e - s + 1 for s, e in merged)
def _graph_semantic_units(store: GraphStore, root: Path, rel: str) -> list[dict]:
"""Graph semantic-unit nodes (Function/Class/Test) of a file."""
if not hasattr(store, "_conn"):
return []
q = (root / rel).as_posix()
try:
rows = store._conn.execute(
"SELECT kind, name, line_start, line_end FROM nodes "
"WHERE file_path = ? AND kind IN ('Function','Class','Test') "
"ORDER BY line_start",
(q,),
).fetchall()
except Exception:
return []
out = []
for r in rows:
try:
out.append(
{
"kind": r["kind"],
"name": r["name"],
"line_start": int(r["line_start"]),
"line_end": int(r["line_end"]),
}
)
except (KeyError, TypeError):
continue
return out
def _is_giant_file(graph_units: list[dict], real_lines: int) -> bool:
"""Unit-exempt when the largest unit spans >80% of the file's lines.
A single huge function (e.g. migrations.rs run_migrations = 98% of the
file) makes unit-completeness meaningless, so such files are checked on
line coverage only. Small files with 2-3 ordinary units are NOT exempt:
they must still cover every unit."""
if not graph_units or real_lines <= 0:
return False
largest = max(u["line_end"] - u["line_start"] + 1 for u in graph_units)
return (largest / real_lines) > 0.8
def _unit_overlap(a: list[int], b: list[int]) -> int:
lo, hi = max(a[0], b[0]), min(a[1], b[1])
return max(0, hi - lo + 1)
def _unit_covered(
graph_unit: dict,
read_ranges: list[list[int]],
unit_ranges: list[list[int]],
matched: set[int],
) -> bool:
"""A graph unit is covered iff one reported unit range matches exactly
(preferred) or overlaps >=80% of the graph unit span, is not already
claimed by a higher-overlap unit (one-to-one), and >=80% of the graph
unit's lines fall inside union(read_ranges)."""
gs, ge = graph_unit["line_start"], graph_unit["line_end"]
gspan = max(1, ge - gs + 1)
exact = [i for i, (s, e) in enumerate(unit_ranges) if s == gs and e == ge]
if exact:
idx = exact[0]
if idx in matched:
return False
matched.add(idx)
else:
best_idx, best_overlap = None, 0
for i, (s, e) in enumerate(unit_ranges):
ov = _unit_overlap([gs, ge], [s, e])
if ov > best_overlap:
best_overlap, best_idx = ov, i
if best_idx is None or best_idx in matched:
return False
if best_overlap / gspan < 0.8:
return False
matched.add(best_idx)
in_union = 0
for s, e in _merge_ranges(read_ranges):
lo, hi = max(gs, s), min(ge, e)
if lo <= hi:
in_union += hi - lo + 1
return (in_union / gspan) >= 0.8
def _merge_ranges(ranges: list[list[int]]) -> list[list[int]]:
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges or []):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def _unit_gaps(
graph_units: list[dict],
reported_units: list[dict],
read_ranges: list[list[int]],
) -> list[dict]:
"""Return graph units not covered by the reported semantic units."""
unit_ranges = [
[int(u.get("range", [0, 0])[0]), int(u.get("range", [0, 0])[1])]
for u in reported_units
]
matched: set[int] = set()
gaps = []
for u in graph_units:
if not _unit_covered(u, read_ranges, unit_ranges, matched):
gaps.append(
{
"name": u["name"],
"range": [u["line_start"], u["line_end"]],
}
)
return gaps
def compute_coverage(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str],
include_churn: bool = True,
gate: str = "high_risk",
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
line_target: float = 95.0,
unit_target: float = 100.0,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / total number of source files.
The overall coverage uses every source file as the denominator; the
high-risk coverage uses only the signal-flagged subset. Per-file risk
weights (w = w1 grade + w2 topology + w3 churn) are still computed and
used to rank ``priority_deep_read_files`` so the highest-risk files
are read first, but the coverage percentage itself is file-count based.
Also returns the list of files never touched by any deep-read /
signal (``silent_files``) for G2 spot-check sampling, the uncovered
files list, and (new) the file count still needed to reach the target
plus the priority deep-read file list that would close that gap.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths).
include_churn: Include git-churn as the w3 weight term.
gate: Coverage gate mode. ``"high_risk"`` (default) gates on the
signal-flagged subset only; ``"overall"`` gates on all source
files; ``"both"`` requires *both* the overall and the high-risk
coverage to meet the target; ``"both+line"`` additionally
requires per-file line coverage >= ``line_target`` and unit
completeness (gap-free) per file; ``"line+unit"`` (feature
reviews) checks ONLY line coverage and unit completeness -
file-count / high-risk coverage are not computed and
``coverage_pct`` / ``high_risk_coverage_pct`` return ``None``.
file_read_ranges: Optional mapping {rel_path: [[s,e], ...]} of the
line ranges a sub-agent actually read per deep-read file.
Used for the line-coverage gate (denominator = real file line
count). Absent file => line coverage treated as satisfied.
file_semantic_units: Optional mapping {rel_path: [{"range":[s,e],
"kind":.., "name":..}, ...]} reported per deep-read file.
Used for the unit-completeness gate (gap-free vs graph units).
Absent file => unit completeness treated as satisfied.
line_target: Min per-file line coverage percent for gate="both+line".
unit_target: Min unit completeness percent (gap-free share).
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with coverage_pct, high_risk_coverage_pct, deep_read_count,
total_files, deep_read_weight, total_weight, target_reached,
target (high-risk target, back-compat), overall_target,
high_risk_target, remaining_files_to_target (file-count gap) and the
compatible remaining_weight_to_target (risk-weight gap),
priority_deep_read_files, uncovered_files, silent_files and grade.
With gate="both+line" also returns line_coverage_pct,
unit_coverage_pct, line_gap_files, unit_gap_files,
unit_exempt_files, missing_data_files.
With gate="line+unit" same line/unit fields plus
``gate="line+unit"``; ``coverage_pct``/``high_risk_coverage_pct``
are ``None`` because file-count coverage is not computed.
FAIL-CLOSED (v2.5.2): a deep-read file with no file_read_ranges
(or no file_semantic_units when the graph has units) is recorded
as a line/unit gap and listed in missing_data_files. Missing data
therefore makes target_reached=false instead of silently passing.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"coverage_pct": 0.0,
"grade": "na",
"deep_read_count": 0,
"total_files": 0,
"deep_read_weight": 0.0,
"total_weight": 0.0,
"target_reached": False,
"target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"overall_target": COVERAGE_TARGETS.get("standard", {}).get("overall", 85.0),
"high_risk_target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"remaining_files_to_target": 0.0,
"remaining_weight_to_target": 0.0,
"priority_deep_read_files": [],
"uncovered_files": [],
"silent_files": [],
"note": "No source files found in graph.",
}
# Resolve relative paths against repo_root so graph identity matches.
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
# Normalise deep-read list against the graph's file paths.
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
# Line / unit coverage data (gate="both+line" / "line+unit").
line_gap_files: list[dict] = []
unit_gap_files: list[dict] = []
unit_exempt_files: list[dict] = []
missing_data_files: list[dict] = []
_line_cache: dict[str, int] = {}
_line_tot = 0
_line_cov = 0
_unit_tot = 0
_unit_cov = 0
if gate in ("both+line", "line+unit"):
root_str = str(repo_root).replace("\\", "/").rstrip("/")
for f, f_abs in zip(source_files, source_abs):
if f_abs not in deep_read_set:
continue
# rel is relative to repo_root (sub-agents report relative paths)
rel = f.replace("\\", "/")
if rel.startswith(root_str + "/"):
rel = rel[len(root_str) + 1:]
_line_tot += 1
_unit_tot += 1
# --- line coverage ---
ranges = (file_read_ranges or {}).get(rel) or (
file_read_ranges or {}
).get(f_abs)
real_lines = _real_line_count(repo_root, rel, _line_cache)
if ranges and real_lines > 0:
covered = _union_len(ranges)
pct = covered / real_lines * 100.0
_line_cov += 1 if pct >= line_target else 0
if pct < line_target:
line_gap_files.append(
{
"path": rel,
"coverage_pct": round(pct, 1),
"total_lines": real_lines,
"covered_lines": covered,
}
)
else:
# FAIL-CLOSED: a deep-read file without read_ranges (or an
# empty/unreadable file) cannot be verified - record a gap
# instead of silently treating it as satisfied. Without this
# branch, gate="both+line" would report target_reached=true
# while line_coverage_pct stays 0 (silent green).
line_gap_files.append(
{
"path": rel,
"coverage_pct": 0.0,
"reason": (
"missing read_ranges" if not ranges
else "unreadable/empty file"
),
}
)
missing_data_files.append(
{"path": rel, "field": "file_read_ranges"}
)
# --- unit completeness ---
units = (file_semantic_units or {}).get(rel) or (
file_semantic_units or {}
).get(f_abs)
g_units = _graph_semantic_units(store, repo_root, rel)
if g_units and units is None:
# FAIL-CLOSED: graph has semantic units but the sub-agent
# reported none - cannot verify completeness, record a gap.
unit_gap_files.append(
{
"path": rel,
"reason": "missing semantic_units",
}
)
missing_data_files.append(
{"path": rel, "field": "file_semantic_units"}
)
elif g_units and units is not None:
giant = _is_giant_file(g_units, real_lines)
if giant:
unit_exempt_files.append(
{
"path": rel,
"reason": (
"giant-file: units=%d largest_span=%.0f%%"
% (
len(g_units),
100
* max(u["line_end"] - u["line_start"] + 1 for u in g_units)
/ max(1, real_lines),
)
),
}
)
_unit_tot -= 1 # exempt from unit gate
else:
uncovered = _unit_gaps(g_units, units, ranges or [])
if uncovered:
unit_gap_files.append(
{
"path": rel,
"total_units": len(g_units),
"covered_units": len(g_units) - len(uncovered),
"uncovered": uncovered,
}
)
else:
_unit_cov += 1
unit_coverage_pct = (_unit_cov / _unit_tot * 100.0) if _unit_tot else 100.0
line_coverage_pct = (_line_cov / _line_tot * 100.0) if _line_tot else 100.0
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
# Single pass: compute overall weight (all source files) and the
# high-risk subset weight (files flagged by any signal).
total_weight = 0.0
deep_read_weight = 0.0
high_risk_total = 0.0
high_risk_deep = 0.0
high_risk_deep_count = 0
high_risk_files: list[str] = []
uncovered_files: list[str] = []
silent_files: list[str] = []
for f, f_abs in zip(source_files, source_abs):
wi = weights[f_abs]
w = wi["w"]
total_weight += w
if f_abs in deep_read_set:
deep_read_weight += w
if wi["is_high_risk"]:
high_risk_total += w
high_risk_files.append(f)
if f_abs in deep_read_set:
high_risk_deep += w
high_risk_deep_count += 1
elif f_abs not in deep_read_set:
# Not high-risk and not deep-read: silent candidate.
if wi["w1"] < 2.0 and wi["w2"] == 0.0 and wi["raw_churn"] < 3:
silent_files.append(f)
uncovered_files = [
f for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
total_files = len(source_files)
deep_read_count = len(deep_read_set)
coverage_pct = (
(deep_read_count / total_files * 100.0)
if total_files else 0.0
)
high_risk_total_files = len(high_risk_files)
high_risk_pct = (
(high_risk_deep_count / high_risk_total_files * 100.0)
if high_risk_total_files else 0.0
)
overall_target, high_risk_target = _coverage_targets()
overall_ok = coverage_pct >= overall_target
high_risk_ok = (
(high_risk_pct >= high_risk_target) if high_risk_total_files else overall_ok
)
if gate == "overall":
target_reached = overall_ok
gate_pct = coverage_pct
gate_target = overall_target
elif gate == "both":
target_reached = overall_ok and high_risk_ok
gate_pct = min(coverage_pct, high_risk_pct) if high_risk_total_files else coverage_pct
gate_target = min(overall_target, high_risk_target)
elif gate == "both+line":
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = overall_ok and high_risk_ok and quality_ok
gate_pct = (
min(coverage_pct, high_risk_pct, line_coverage_pct, unit_coverage_pct)
if high_risk_total_files
else min(coverage_pct, line_coverage_pct, unit_coverage_pct)
)
gate_target = min(overall_target, high_risk_target, line_target, unit_target)
elif gate == "line+unit":
# Feature reviews: check ONLY line coverage + unit completeness.
# File-count / high-risk coverage is deliberately not part of the
# gate (coverage_pct / high_risk_coverage_pct are returned as None).
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = quality_ok
gate_pct = min(line_coverage_pct, unit_coverage_pct)
gate_target = min(line_target, unit_target)
else: # "high_risk" (default)
target_reached = high_risk_ok
gate_pct = high_risk_pct
gate_target = high_risk_target
grade = (
"good" if target_reached
else ("warn" if gate_pct >= gate_target * 0.8 else "fail")
)
remaining_files_to_target = max(
0.0, total_files * overall_target / 100.0 - deep_read_count
)
remaining_weight_to_target = max(
0.0, total_weight * overall_target / 100.0 - deep_read_weight
)
priority_deep_read_files = [
{"path": f_abs, "weight": round(weights[f_abs]["w"], 3)}
for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
priority_deep_read_files.sort(key=lambda e: e["weight"], reverse=True)
return {
"status": "ok",
"coverage_pct": round(coverage_pct, 1) if gate != "line+unit" else None,
"high_risk_coverage_pct": round(high_risk_pct, 1) if gate != "line+unit" else None,
"grade": grade,
"deep_read_count": deep_read_count,
"total_files": total_files,
"high_risk_total_files": high_risk_total_files,
"high_risk_deep_count": high_risk_deep_count,
"deep_read_weight": round(deep_read_weight, 2),
"total_weight": round(total_weight, 2),
"target_reached": target_reached,
"target": high_risk_target,
"overall_target": overall_target,
"high_risk_target": high_risk_target,
"gate": gate,
"line_coverage_pct": round(line_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"unit_coverage_pct": round(unit_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"line_gap_files": line_gap_files if gate in ("both+line", "line+unit") else [],
"unit_gap_files": unit_gap_files if gate in ("both+line", "line+unit") else [],
"unit_exempt_files": unit_exempt_files if gate in ("both+line", "line+unit") else [],
"missing_data_files": missing_data_files if gate in ("both+line", "line+unit") else [],
"remaining_files_to_target": round(remaining_files_to_target, 2),
"remaining_weight_to_target": round(remaining_weight_to_target, 2),
"priority_deep_read_files": priority_deep_read_files,
"uncovered_files": uncovered_files,
"silent_files": silent_files,
"note": (
"行/单元门禁(feature):仅要求行覆盖 >= "
f"{line_target}% 且单元完整性无缺口;coverage_pct / "
"high_risk_coverage_pct 为 None(未做文件数/高风险覆盖检查)。"
) if gate == "line+unit" else (
"双重覆盖口径:全库 = 深读文件数 / 全部源文件数;高风险 = 深读 / "
"信号点名文件数。G3 门禁口径可配置(high_risk/overall/both)。"
"每文件风险权重(w = w1 指标分 + w2 拓扑 + w3 变更频率)仍用于"
"排序 priority_deep_read_files;低于目标 => 审查覆盖不足。"
),
}
def deep_read_plan(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str] | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
prior_covered: set[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects files not yet deep-read until the file-count coverage
target is reached, preferring the highest-risk (highest-weight) files
first, then groups them by parent directory so each batch can be
dispatched to one parallel sub-agent.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files already deep-read this round.
target_coverage: Target overall coverage percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
prior_covered: Absolute paths already deep-read in prior rounds
(from the coverage index); excluded from the plan.
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with current/remaining file counts (primary) plus compatible
weight counts, the greedy gap-closing file list and the directory
groups ready for sub-agent dispatch.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"current_coverage_pct": 0.0,
"current_files": 0,
"target_files": 0,
"remaining_files": 0,
"current_weight": 0.0,
"target_weight": 0.0,
"remaining_weight": 0.0,
"planned_files": [],
"planned_weight": 0.0,
"groups": [],
"estimated_batches": 0,
"gate": "overall",
}
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
if prior_covered:
deep_read_set |= {_abs(p) for p in prior_covered if _abs(p) in source_abs_set}
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
total_weight = sum(wi["w"] for wi in weights.values())
total_files = len(source_files)
current_files = len(deep_read_set)
current_weight = sum(
weights[f_abs]["w"]
for f_abs in source_abs_set
if f_abs in deep_read_set and f_abs in weights
)
current_pct = (current_files / total_files * 100.0) if total_files else 0.0
target_files = total_files * target_coverage / 100.0
remaining_files = max(0.0, target_files - current_files)
target_weight = total_weight * target_coverage / 100.0
remaining_weight = max(0.0, target_weight - current_weight)
# Greedy: pick highest-weight uncovered files until the gap is closed.
candidates = sorted(
(
{"path": f_abs, "weight": weights[f_abs]["w"]}
for f_abs in source_abs
if f_abs not in deep_read_set and f_abs in weights
),
key=lambda e: e["weight"],
reverse=True,
)
planned: list[str] = []
accrued_weight = 0.0
for cand in candidates:
if len(planned) >= remaining_files:
break
planned.append(cand["path"])
accrued_weight += cand["weight"]
# Group by parent directory, keeping group order by total weight desc.
from collections import OrderedDict
by_dir: "OrderedDict[str, list[str]]" = OrderedDict()
for p in planned:
d = Path(p).parent.name or "."
by_dir.setdefault(d, []).append(p)
groups: list[dict[str, Any]] = []
for d, files in by_dir.items():
g_weight = sum(weights[_abs(f)]["w"] for f in files)
for i in range(0, len(files), batch_size):
chunk = files[i : i + batch_size]
groups.append(
{
"name": d,
"weight": round(g_weight, 2),
"files": chunk,
"batch_index": i // batch_size,
}
)
return {
"status": "ok",
"target_coverage": target_coverage,
"current_coverage_pct": round(current_pct, 1),
"current_files": current_files,
"target_files": round(target_files, 2),
"remaining_files": round(remaining_files, 2),
"current_weight": round(current_weight, 2),
"target_weight": round(target_weight, 2),
"remaining_weight": round(remaining_weight, 2),
"planned_files": planned,
"planned_weight": round(accrued_weight, 2),
"groups": groups,
"estimated_batches": len(groups),
"gate": "overall",
}
def check_community_health(store: GraphStore) -> dict[str, Any]:
"""Detect community/node attribution desync (nodes.community_id NULL).
The communities table may carry a correct ``size`` while
``nodes.community_id`` is stale (e.g. after an incremental build that
rebuilt nodes but skipped community re-attribution). Returns the
non-null ratio and a ``needs_postprocess`` flag so the review skill
can trigger ``postprocess`` before computing coverage.
Args:
store: Open graph store (caller owns and closes it).
Returns:
Dict with total_nodes, attributed_nodes, attribution_pct,
needs_postprocess and note.
"""
try:
total = store._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
attributed = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE community_id IS NOT NULL"
).fetchone()[0]
non_file = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE kind != 'File'"
).fetchone()[0]
except Exception as exc: # pragma: no cover
return {
"status": "error",
"needs_postprocess": True,
"note": f"Community health check failed: {exc}",
}
ratio = (attributed / non_file * 100.0) if non_file else 0.0
# A fully healthy graph has ~100% attribution; allow a small delta for
# nodes without community membership.
needs = ratio < 90.0
return {
"status": "ok",
"total_nodes": total,
"attributed_nodes": attributed,
"non_file_nodes": non_file,
"attribution_pct": round(ratio, 1),
"needs_postprocess": needs,
"note": (
"nodes.community_id attribution ratio. Low ratio => run "
"`code-review-graph postprocess` to re-attach members."
),
}
+3 -10
View File
@@ -750,12 +750,8 @@ _SKILLS: dict[str, dict[str, str]] = {
'- 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"
"### Step 0 - Scope\n"
"Review always runs at the fixed `standard` tier (all layers).\n"
"Detect the project language/framework and the review scope "
"(change/file/service/chain level). Declare both in the report "
"header.\n\n"
@@ -778,10 +774,7 @@ _SKILLS: dict[str, dict[str, str]] = {
"### 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"
"vulnerability heuristic); these five are the full metric set.\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 "
+11 -1
View File
@@ -1,6 +1,6 @@
"""MCP tool definitions for the Code Review Graph server.
Exposes 31 tools:
Exposes 33 tools:
1. build_or_update_graph - full or incremental build
2. get_impact_radius - blast radius from changed files
3. query_graph - predefined graph queries
@@ -32,6 +32,8 @@ Exposes 31 tools:
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 HTML and/or Markdown review report
32. coverage - risk-weighted deep-read coverage (project-review G3)
33. community_health - nodes.community_id attribution health check
"""
from __future__ import annotations
@@ -108,8 +110,12 @@ from .review import (
# -- scoring (unified-review) ------------------------------------------------
from .scoring_tools import (
community_health_func,
coverage_func,
dedupe_findings_func,
deep_read_plan_func,
generate_report_func,
save_coverage_index_func,
score_review_func,
)
@@ -157,6 +163,10 @@ __all__ = [
"score_review_func",
"dedupe_findings_func",
"generate_report_func",
"coverage_func",
"deep_read_plan_func",
"save_coverage_index_func",
"community_health_func",
# analysis_tools
"get_bridge_nodes_func",
"get_hub_nodes_func",
+415 -7
View File
@@ -12,18 +12,165 @@ build_report_data) into the three MCP tools consumed by the
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from ..incremental import get_changed_files, get_staged_and_unstaged
from ..scoring import (
build_report_data,
check_community_health,
compute_coverage,
dedupe_findings,
deep_read_plan,
render_markdown_report,
score_review,
)
from ._common import _get_store, _error_response
#: Persistent cross-round deep-read index (relative path -> per-file SHA).
COVERAGE_INDEX_REL = ".code-review-graph/coverage-index.json"
def _git_head_sha(repo_root: Path) -> str | None:
"""Current git HEAD SHA (None when not a repo / git unavailable)."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _git_file_sha(repo_root: Path, rel_path: str) -> str | None:
"""Blob SHA of the *current working-tree* content of ``rel_path``.
Uses ``git hash-object`` (content hash, does not touch the index) so an
uncommitted edit still counts as stale. Returns None when the file does
not exist in the working tree or git is unavailable.
"""
try:
result = subprocess.run(
["git", "hash-object", rel_path],
capture_output=True,
text=True,
cwd=str(repo_root),
timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return None
def _coverage_index_path(repo_root: Path) -> Path:
return repo_root / COVERAGE_INDEX_REL
def _load_coverage_index(repo_root: Path) -> list[str]:
"""Return the relative paths whose per-file SHA still matches HEAD.
Files that changed since the last review are automatically excluded
(stale), so an incremental round never masks new code with old findings.
SHA check uses the graph's ``nodes.file_hash`` when the graph is
current, else falls back to a single batched ``git hash-object``
invocation (never one subprocess per file, see #46/#136).
"""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return []
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
entries = payload.get("entries") or {}
if not entries:
return []
return [
rel
for rel, info in entries.items()
if _index_entry_current(repo_root, rel, info)
]
def _load_coverage_index_full(
repo_root: Path,
) -> tuple[list[str], dict[str, list[list[int]]]]:
"""Load (current rel paths, their persisted read ranges)."""
index_path = _coverage_index_path(repo_root)
if not index_path.is_file():
return [], {}
try:
payload = json.loads(index_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return [], {}
entries = payload.get("entries") or {}
if not entries:
return [], {}
current, ranges = [], {}
for rel, info in entries.items():
if _index_entry_current(repo_root, rel, info):
current.append(rel)
if info.get("ranges"):
ranges[rel] = info["ranges"]
return current, ranges
def _index_entry_current(repo_root: Path, rel: str, info: dict) -> bool:
"""True when a persisted entry's SHA matches the current file content."""
if not (repo_root / rel).is_file():
return False
expected = info.get("sha")
if not expected:
return False
# Prefer graph file_hash (single SQL fetch, no subprocess). Fall back to
# a batched git hash-object for files not in the graph.
graph_hash = _graph_file_hashes(repo_root).get(rel)
if graph_hash:
return graph_hash == expected
return _git_file_sha(repo_root, rel) == expected
_GRAPH_HASH_CACHE: dict[str, dict[str, str]] = {}
def _graph_file_hashes(repo_root: Path) -> dict[str, str]:
"""All File-node content hashes for a repo, cached (one SQL fetch)."""
key = str(repo_root)
if key in _GRAPH_HASH_CACHE:
return _GRAPH_HASH_CACHE[key]
result: dict[str, str] = {}
try:
store, root = _get_store(str(repo_root))
try:
rows = store._conn.execute(
"SELECT file_path, file_hash FROM nodes WHERE kind='File'"
).fetchall()
for r in rows:
fp = str(r["file_path"])
rel = fp.replace("\\", "/")
if rel.startswith(str(root).replace("\\", "/") + "/"):
rel = rel[len(str(root).replace("\\", "/")) + 1:].replace("/", "\\")
rel = rel.replace("\\", "/")
result[rel] = r["file_hash"] or ""
finally:
store.close()
except Exception:
result = {}
_GRAPH_HASH_CACHE[key] = result
return result
try:
from importlib.resources import files as _pkg_files # Python 3.9+
@@ -44,13 +191,14 @@ def score_review_func(
repo_root: str | None = None,
detail_level: str = "standard",
all_files: bool = False,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> 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.
vulnerability). These five objective metrics form the full report
metric set; ``llm_judged`` is returned empty for compatibility.
Args:
changed_files: Files to score (auto-detected from git diff if
@@ -63,6 +211,8 @@ def score_review_func(
all_files: When True, score every source file in the graph,
ignoring ``changed_files`` and the git diff. Used for
whole-project reviews (default: False).
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
"""
store, root = _get_store(repo_root)
try:
@@ -85,6 +235,7 @@ def score_review_func(
root,
changed_files,
include_churn=include_churn,
progress_cb=progress_cb,
)
if detail_level == "minimal":
@@ -154,6 +305,238 @@ def dedupe_findings_func(
return _error_response(str(exc))
# ---------------------------------------------------------------------------
# Tool: coverage
# ---------------------------------------------------------------------------
def coverage_func(
deep_read_files: list[str],
all_files: bool = True,
include_churn: bool = True,
gate: str = "high_risk",
include_prior: bool = False,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
repo_root: str | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / number of all source files.
The high-risk coverage uses only the signal-flagged subset (also
file-count based). Per-file risk weights still rank the priority
deep-read list so the highest-risk files are read first. This backs
the project-review Step 7.5 coverage self-check (G3).
Args:
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths). Required.
all_files: When True, the denominator is every source file in the
graph (default: True, whole-project semantics).
include_churn: Include git-churn as the w3 weight term.
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
``"both"`` (both overall and high-risk must meet the target) /
``"both+line"`` (both + per-file line coverage >= line_target
and unit-completeness gap-free) / ``"line+unit"`` (line + unit
only, feature reviews; file-count / high-risk coverage are
skipped and returned as ``None``).
include_prior: When True, merge the cross-round coverage index
(files whose SHA is unchanged since the last review) into the
deep-read set.
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
sub-agent actually read per deep-read file (for gate="both+line").
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
reported semantic units per deep-read file (gate="both+line").
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with coverage_pct, grade, deep_read_count, total_files,
target_reached, target, remaining_files_to_target,
priority_deep_read_files, uncovered_files, silent_files and note.
``silent_files`` feeds the G2 spot-check sampling. For
``gate="both+line"`` / ``"line+unit"`` also returns
line_coverage_pct, unit_coverage_pct, line_gap_files,
unit_gap_files, unit_exempt_files.
"""
store, root = _get_store(repo_root)
try:
deep_read = list(deep_read_files or [])
ranges = dict(file_read_ranges or {})
units = dict(file_semantic_units or {})
if include_prior:
prior, prior_ranges = _load_coverage_index_full(root)
deep_read += prior
for rel, rr in prior_ranges.items():
ranges.setdefault(rel, rr)
return compute_coverage(
store,
root,
deep_read_files=deep_read,
include_churn=include_churn,
gate=gate,
file_read_ranges=ranges,
file_semantic_units=units,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def deep_read_plan_func(
repo_root: str | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
include_prior: bool = False,
deep_read_files: list[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects the highest-risk files not yet deep-read until the
file-count overall coverage target is reached, then groups them by
parent directory (≤ ``batch_size`` per group) so each group can be
dispatched to one parallel sub-agent.
Args:
repo_root: Repository root. Auto-detected if omitted.
target_coverage: Overall coverage target percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
include_prior: Exclude files already covered by the cross-round
coverage index (SHA unchanged) from the plan.
deep_read_files: Files already deep-read this round.
progress_cb: Optional ``(fraction, message)`` progress callback
forwarded to the scoring engine.
Returns:
Dict with current_coverage_pct, current/target/remaining file
counts (plus compatible weight counts), planned_files
(priority-ordered), directory groups and estimated_batches.
"""
store, root = _get_store(repo_root)
try:
prior = set(_load_coverage_index(root)) if include_prior else None
return deep_read_plan(
store,
root,
deep_read_files=deep_read_files,
target_coverage=target_coverage,
batch_size=batch_size,
include_churn=include_churn,
prior_covered=prior,
progress_cb=progress_cb,
)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
def save_coverage_index_func(
deep_read_files: list[str],
repo_root: str | None = None,
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
) -> dict[str, Any]:
"""Persist the deep-read file list to the cross-round coverage index.
Records each file's per-file SHA at HEAD so a later review can tell
which previously-deep-read files are still current (SHA unchanged)
and which are stale and must be re-read.
Version 2: SHA comes from the graph's ``nodes.file_hash`` (one SQL
query, zero subprocesses) instead of one ``git hash-object`` per file.
Line ranges (``file_read_ranges``) are persisted so a later review can
reuse the line coverage of SHA-unchanged files.
Args:
deep_read_files: Files deep-read this round (relative or absolute).
repo_root: Repository root. Auto-detected if omitted.
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
persist per file (index v2, reused by include_prior).
file_semantic_units: Optional per-file semantic units; persisted
for informational purposes (unit completeness is recomputed
against the live graph each round, so this is advisory only).
Returns:
Dict with index_path, entry count and head_sha.
"""
store, root = _get_store(repo_root)
store.close()
file_hashes = _graph_file_hashes(root)
def _hash_for(rel: str) -> str | None:
return file_hashes.get(rel) or _git_file_sha(root, rel)
entries: dict[str, dict[str, Any]] = {}
for f in deep_read_files or []:
p = Path(f)
rel = str(p.relative_to(root)) if p.is_absolute() else str(p)
rel = rel.replace("\\", "/")
entry: dict[str, Any] = {"sha": _hash_for(rel) or _git_file_sha(root, rel)}
ranges = (file_read_ranges or {}).get(rel)
if ranges:
entry["ranges"] = ranges
units = (file_semantic_units or {}).get(rel)
if units:
entry["units"] = units
entries[rel] = entry
payload = {
"version": 2,
"last_updated": datetime.now().isoformat(timespec="seconds"),
"head_sha": _git_head_sha(root),
"entries": entries,
}
index_path = _coverage_index_path(root)
try:
index_path.parent.mkdir(parents=True, exist_ok=True)
index_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except OSError as exc:
return _error_response(f"Failed to write coverage index: {exc}")
return {
"status": "ok",
"index_path": str(index_path),
"entries": len(entries),
"head_sha": payload["head_sha"],
}
def community_health_func(
repo_root: str | None = None,
) -> dict[str, Any]:
"""Check node->community attribution health (nodes.community_id).
Detects the desync where ``communities.size`` is correct but
``nodes.community_id`` is mostly NULL (e.g. after an incremental
rebuild). Returns ``needs_postprocess``; the review skill should run
``code-review-graph postprocess`` when True before computing coverage.
Args:
repo_root: Repository root. Auto-detected if omitted.
Returns:
Dict with total_nodes, attributed_nodes, non_file_nodes,
attribution_pct, needs_postprocess and note.
"""
store, root = _get_store(repo_root)
try:
return check_community_health(store)
except Exception as exc:
return _error_response(str(exc))
finally:
store.close()
# ---------------------------------------------------------------------------
# Tool: generate_report
# ---------------------------------------------------------------------------
@@ -206,8 +589,30 @@ const el = document.getElementById("report");
let html = `<h1>Code Review Report</h1>
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
&middot; Tier: ${data.tier || "standard"} &middot; Scope: ${data.scope || "change-level"}</p>`;
if (data.files) html += `<p><b>Files:</b> ${data.files}</p>`;
if (data.files || (data.reviewed_files && data.reviewed_files.length)) {
if (Array.isArray(data.reviewed_files) && data.reviewed_files.length) {
html += `<details><summary><b>Files (${data.reviewed_files.length})</b></summary><ul>`
+ data.reviewed_files.map(f => `<li><code>${f}</code></li>`).join("")
+ `</ul></details>`;
} else {
html += `<p><b>Files:</b> ${data.files}</p>`;
}
}
if (data.summary) html += `<p>${data.summary}</p>`;
const cov = data.coverage || {};
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
const covOk = cov.target_reached;
let c = `<p><b>Coverage:</b> ${covOk ? "target reached" : "coverage insufficient"}`;
if (cov.coverage_pct != null) {
c += ` &middot; overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
+ ` &middot; high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
}
if (cov.line_coverage_pct != null) {
c += ` &middot; line ${cov.line_coverage_pct}% &middot; unit ${cov.unit_coverage_pct}%`;
}
c += `</p>`;
html += c;
}
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>
@@ -275,9 +680,12 @@ def generate_report_func(
written: list[dict[str, Any]] = []
if format in ("html", "both"):
template = _load_report_template()
rendered = template.replace(
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
)
# Escape "<" as "\u003c" so finding text can never close the
# surrounding <script> tag (e.g. a literal "</script>" in a
# message/fix). JSON keeps it a valid escape; the JS template
# string re-parses it as "<" before esc() HTML-escapes it.
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
rendered = template.replace("{{REPORT_DATA}}", payload)
html_path = base.with_suffix(".html")
html_path.parent.mkdir(parents=True, exist_ok=True)
html_path.write_text(rendered, encoding="utf-8")