chore: sync local changes, add Chinese docs and opencode config
This commit is contained in:
@@ -8,7 +8,7 @@ from .context_savings import (
|
||||
format_context_savings,
|
||||
)
|
||||
|
||||
__version__ = "2.3.7"
|
||||
__version__ = "2.4.0"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
· Tier: ${data.tier || "standard"} · 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 += ` · overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
|
||||
+ ` · high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
|
||||
}
|
||||
if (cov.line_coverage_pct != null) {
|
||||
c += ` · line ${cov.line_coverage_pct}% · 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")
|
||||
|
||||
Reference in New Issue
Block a user