Files
code-review-graph/code_review_graph/scoring.py
T

1887 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Quantitative code-review scoring for the unified-review workflow.
Implements the objectively computable Layer-2 metrics from the
ai-code-review methodology as code, plus the git-history / graph risk
factors used by the gstack-review workflow. 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:
* :func:`score_review` - all objective Layer-2 metrics for changed files
* :func:`dedupe_findings` - fingerprint dedup + confidence merge
* :func:`build_report_data` - data feed for the HTML report
Every metric returns a dict with ``score``/``value``, ``grade``
(one of ``good``/``warn``/``fail``), ``thresholds`` and ``evidence`` so
the agent can cite the numbers instead of asserting a vibe.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, Callable, Optional
from .changes import (
compute_file_churn,
map_changes_to_nodes,
parse_diff_ranges,
)
from .constants import SECURITY_KEYWORDS
from .graph import GraphStore
from .parser import normalize_file_path
from collections import Counter
# ---------------------------------------------------------------------------
# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
# ---------------------------------------------------------------------------
# good/warn/fail boundaries per metric.
THRESHOLDS: dict[str, dict[str, float]] = {
"redundancy_rate": {"good_max": 10.0, "warn_max": 20.0}, # % duplicate lines
"exception_coverage": {"warn_min": 50.0, "good_min": 30.0}, # exception/normal %
"sql_risk": {"warn_at": 1, "fail_at": 3}, # risk count
"high_risk_density": {"warn_min": 70.0, "good_min": 90.0}, # covered % (0..100)
"vulnerability_risk": {"warn_at": 1, "fail_at": 2}, # risk count
}
# ---------------------------------------------------------------------------
# Keyword tables
# ---------------------------------------------------------------------------
# Exception-path markers used by the exception-coverage heuristic. These are
# deliberately language-agnostic and conservative: a hit is counted as an
# exception/error path, not a happy path.
_EXCEPTION_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\btry\b"),
re.compile(r"\bexcept\b"),
re.compile(r"\bcatch\s*\("),
re.compile(r"\braise\b"),
re.compile(r"\bthrow\b"),
re.compile(r"\?\.\.\."),
re.compile(r"\bif\s+err\s*!=\s*nil\b"),
re.compile(r"\bif\s+.*\berror\b"),
re.compile(r"\.catch\s*\("),
re.compile(r"\belse\s*\{?\s*return\s+(?:false|None|null|nil|0)\b"),
)
# SQL injection / unparameterized-query markers. A hit is a warning, not a
# proof: the agent must confirm the surrounding context before acting.
_SQL_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"[\"'](?:SELECT|INSERT|UPDATE|DELETE)\s+.*?\+\s*[\"']", re.I | re.S),
re.compile(r"\bf?\".*?(?:SELECT|INSERT|UPDATE|DELETE).*?(?:\$\{|\{)\w", re.I | re.S),
re.compile(r"\bexec(?:ute)?\s*\(\s*[\"'].*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
re.compile(r"WHERE\s+.*?=\s*[\"']\s*\+\s*\w", re.I | re.S),
re.compile(r"\.format\s*\(.*\).*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
re.compile(r"%\s*[\"']\s*%\s*\(?.*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
)
# Concurrency / transaction / data-consistency markers for high-risk density.
_HIGH_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\b(?:async|await)\b"),
re.compile(r"\b(?:thread|goroutine|mutex|lock|unlock|semaphore)\b"),
re.compile(r"\b(?:transaction|commit|rollback|begin)\b", re.I),
re.compile(r"\b(?:atomic|concurrent|race)\b", re.I),
re.compile(r"\b(?:SELECT|INSERT|UPDATE|DELETE)\b", re.I),
re.compile(r"\b(?:lock|with_lock|select_for_update)\b", re.I),
re.compile(r"\b(?:cache|invalidate|evict)\b", re.I),
)
# OWASP / secret markers for the vulnerability heuristic.
_VULNERABILITY_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"\b(?:password|passwd|pwd)\s*=\s*[\"'][^\"']{1,32}[\"']", re.I),
re.compile(r"\b(?:api[_-]?key|secret|token)\s*=\s*[\"'][^\"']+[\"']", re.I),
re.compile(r"\b(?:eval|exec)\s*\(", re.I),
re.compile(r"\bsubprocess\s*\(.*shell\s*=\s*True", re.I | re.S),
re.compile(r"\b(?:innerHTML|dangerouslySetInnerHTML|html\.safe|mark_safe|v-html)\b", re.I),
re.compile(r"\b(?:<script|onerror=|javascript:)\b", re.I),
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _grade(metric: str, value: float) -> str:
"""Classify a numeric value into good/warn/fail for one metric."""
t = THRESHOLDS[metric]
if metric in ("sql_risk", "vulnerability_risk"):
# Count-based: 0 = good, warn_at count = warn, fail_at count = fail.
if value <= 0:
return "good"
if value < t["fail_at"]:
return "warn"
return "fail"
if metric == "redundancy_rate":
# percentage-based, lower is better
return "good" if value <= t["good_max"] else (
"warn" if value <= t["warn_max"] else "fail"
)
# percentage-based, higher is better
if metric == "exception_coverage":
return "good" if value >= t["warn_min"] else (
"warn" if value >= t["good_min"] else "fail"
)
return "good" if value >= t["good_min"] else (
"warn" if value >= t["warn_min"] else "fail"
)
def _iter_source_lines(
changed_files: list[str], repo_root: Path,
) -> list[tuple[str, str, int]]:
"""Yield (rel_path, line, line_no) for each changed source line.
Changed files that cannot be read are skipped silently; a missing file is
not a scoring failure. ``changed_files`` is resolved relative to
``repo_root`` when not absolute.
"""
out: list[tuple[str, str, int]] = []
for rel in changed_files:
raw = rel.replace("\\", "/")
candidate = Path(rel)
if not candidate.is_absolute():
candidate = repo_root / raw
if not candidate.is_file():
continue
try:
lines = candidate.read_text(
encoding="utf-8", errors="replace",
).splitlines()
except OSError:
continue
for i, line in enumerate(lines, start=1):
out.append((raw, line, i))
return out
def _count_matching(lines: list[tuple[str, str, int]], patterns: tuple[re.Pattern[str], ...]) -> int:
"""Count lines matching any pattern (deduped per line)."""
hits = 0
for _path, line, _no in lines:
if any(p.search(line) for p in patterns):
hits += 1
return hits
def _normalized_signature(line: str) -> str:
"""Normalise a source line into a reusable duplicate signature."""
text = re.sub(r"\s+", " ", line).strip()
text = re.sub(r"\b\d+\b", "N", text)
text = re.sub(r"[\"'][^\"']*[\"']", '"s"', text)
return text.lower()
def _find_repeated_blocks(
changed_files: list[str], repo_root: Path,
) -> list[dict[str, Any]]:
"""Detect near-duplicate source blocks (>=3 occurrences) across the diff.
A coarse structural proxy for the redundancy metric: a line that appears
with the same normalised signature in at least three places is counted as
duplicate content. This is heuristic -- the agent confirms before acting.
"""
lines = _iter_source_lines(changed_files, repo_root)
sig_count: dict[str, list[tuple[str, int]]] = {}
for rel, line, no in lines:
sig = _normalized_signature(line)
if len(sig) < 24:
continue # ignore trivially short lines
sig_count.setdefault(sig, []).append((rel, no))
blocks: list[dict[str, Any]] = []
dup_lines = 0
for sig, occurrences in sig_count.items():
if len(occurrences) < 3:
continue
dup_lines += len(occurrences)
blocks.append({
"signature": sig[:120],
"occurrences": len(occurrences),
"locations": [
{"file": rel, "line": no}
for rel, no in occurrences[:8]
],
})
blocks.sort(key=lambda b: b["occurrences"], reverse=True)
total = len(lines)
rate = (dup_lines / total * 100.0) if total else 0.0
return [blocks, rate]
# ---------------------------------------------------------------------------
# Public metrics
# ---------------------------------------------------------------------------
def compute_sql_risk(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Scan changed lines for string-interpolated / unparameterized SQL."""
lines = _iter_source_lines(changed_files, repo_root)
locations: list[dict[str, Any]] = []
for rel, line, no in lines:
if any(p.search(line) for p in _SQL_RISK_PATTERNS):
locations.append({
"file": rel,
"line": no,
"snippet": line.strip()[:200],
})
count = len(locations)
return {
"metric": "sql_risk",
"value": count,
"grade": _grade("sql_risk", float(count)),
"thresholds": THRESHOLDS["sql_risk"],
"evidence": locations[:20],
"note": "字符串拼接 SQL 的启发式扫描。修复前请逐一确认每个位置;"
"用 EXPLAIN 评估性能风险。",
}
def compute_exception_coverage(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate exception-path coverage of changed code.
Ratio = exception-path lines / (normal-path lines + exception-path lines).
The ai-code-review rubric uses ~1 exception per 2 happy paths as the
good baseline (>=50% of normal paths have a counterpart), hence the
thresholds on this ratio.
"""
lines = _iter_source_lines(changed_files, repo_root)
total = len(lines)
exc = _count_matching(lines, _EXCEPTION_PATTERNS)
normal = max(0, total - exc)
ratio = (exc / (normal + exc) * 100.0) if (normal + exc) else 0.0
return {
"metric": "exception_coverage",
"value": round(ratio, 2),
"grade": _grade("exception_coverage", ratio),
"thresholds": THRESHOLDS["exception_coverage"],
"evidence": {
"total_lines": total,
"exception_path_lines": exc,
"normal_path_lines": normal,
},
"note": "异常/错误路径行的启发式占比。请人工复核边界条件与错误处理。",
}
def compute_redundancy_rate(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate code redundancy in the changed files."""
blocks, rate = _find_repeated_blocks(changed_files, repo_root)
return {
"metric": "redundancy_rate",
"value": round(rate, 2),
"grade": _grade("redundancy_rate", rate),
"thresholds": THRESHOLDS["redundancy_rate"],
"evidence": blocks[:20],
"note": "重复代码块启发式占比(规范化行在 >=3 处出现)。"
"抽取公共逻辑前请确认。",
}
def compute_high_risk_density(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Estimate high-risk scenario coverage of changed code.
Density = (high-risk lines with matching concurrency/transaction/security
markers) / (high-risk-relevant lines). When no high-risk lines exist the
metric is N/A rather than a pass/fail.
"""
lines = _iter_source_lines(changed_files, repo_root)
relevant = [
(rel, line, no) for rel, line, no in lines
if any(p.search(line) for p in _HIGH_RISK_PATTERNS)
]
if not relevant:
return {
"metric": "high_risk_density",
"value": None,
"grade": "na",
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {},
"note": "变更中未检出并发/事务/数据一致性模式——"
"除非审查发现缺口,标记为 N/A。",
}
covered = 0
for _rel, line, _no in relevant:
# Every relevant line is counted as "covered by review attention";
# the marker is a signal for the agent to verify, not a defect.
if line.strip():
covered += 1
density = covered / len(relevant) * 100.0
return {
"metric": "high_risk_density",
"value": round(density, 2),
"grade": _grade("high_risk_density", density),
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {
"high_risk_lines": len(relevant),
"covered_lines": covered,
"locations": [
{"file": rel, "line": no, "snippet": line.strip()[:160]}
for rel, line, no in relevant[:20]
],
},
"note": "并发/事务/安全模式密度。属审查注意力信号,非正确性评分。",
}
def compute_vulnerability_heuristic(
changed_files: list[str], repo_root: Path,
) -> dict[str, Any]:
"""Scan changed lines for OWASP / secret-like patterns (heuristic)."""
lines = _iter_source_lines(changed_files, repo_root)
locations: list[dict[str, Any]] = []
for rel, line, no in lines:
if any(p.search(line) for p in _VULNERABILITY_PATTERNS):
locations.append({
"file": rel,
"line": no,
"snippet": line.strip()[:200],
})
count = len(locations)
return {
"metric": "vulnerability_risk",
"value": count,
"grade": _grade("vulnerability_risk", float(count)),
"thresholds": THRESHOLDS["vulnerability_risk"],
"evidence": locations[:20],
"note": "OWASP/密钥模式的启发式扫描。真实漏洞需依赖扫描器"
"npm audit、pip-audit、govulncheck)确认。",
}
def compute_risk_factors(
store: GraphStore,
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
) -> dict[str, Any]:
"""Compute git-history + graph risk factors for changed files.
Returns churn hotspots, cross-community coupling and hub dependencies --
the structural input the agent uses to prioritise review attention.
"""
abs_files = [normalize_file_path(repo_root / f) for f in changed_files]
# git churn over the trailing window (reuses changes.compute_file_churn).
churn: dict[str, Any] = {"enabled": include_churn, "hotspots": []}
if include_churn:
counts = compute_file_churn(str(repo_root))
hotspots = [
{"file": f, "commits": c}
for f, c in sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
if c >= 3
]
churn["hotspots"] = hotspots[:20]
# Cross-community callers of the changed functions. A caller from a
# different community than the changed node signals coupling that deserves
# extra review attention.
changed_nodes = []
for fp in abs_files:
changed_nodes.extend(store.get_nodes_by_file(fp))
node_qns = {n.qualified_name for n in changed_nodes}
caller_qns: set[str] = set()
incoming: dict[str, list[str]] = {}
for qn in node_qns:
edges = store.get_edges_by_target(qn)
callers = [e.source_qualified for e in edges if e.kind == "CALLS"]
incoming[qn] = callers
caller_qns.update(callers)
# Batch community lookups: changed nodes and their callers.
all_qns = list(node_qns | caller_qns)
cid_map = store.get_community_ids_by_qualified_names(all_qns)
cross_community: list[dict[str, Any]] = []
hub_dependencies: list[dict[str, Any]] = []
for qn in node_qns:
tgt_cid = cid_map.get(qn)
for caller in incoming.get(qn, []):
src_cid = cid_map.get(caller)
if src_cid is not None and tgt_cid is not None and src_cid != tgt_cid:
cross_community.append({
"caller": caller,
"callee": qn,
"edge": "CALLS",
})
if len(incoming.get(qn, [])) >= 10:
hub_dependencies.append({
"node": qn,
"callers": len(incoming.get(qn, [])),
})
return {
"churn": churn,
"cross_community_edges": cross_community[:20],
"hub_dependencies": hub_dependencies[:20],
"note": "结构性风险因子。高变更频率 + 跨社区耦合 + 中枢依赖"
"意味着该改动需要额外审查关注。",
}
def score_review(
store: GraphStore,
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.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
changed_files: Changed file paths relative to ``repo_root``.
include_churn: Include git-churn risk factors.
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``.
"""
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,
)
grades = [m["grade"] for m in metrics.values() if m["grade"] != "na"]
worst = "fail" if "fail" in grades else (
"warn" if "warn" in grades else "good"
)
summary_parts = [
f"Scored {len(changed_files)} changed file(s) with {len(metrics)} objective metrics.",
f"Overall objective grade: {worst.upper()}",
]
for name, m in metrics.items():
if m["grade"] == "na":
summary_parts.append(f" - {name}: N/A")
else:
summary_parts.append(f" - {name}: {m['grade']} (value={m['value']})")
if risk_factors["churn"]["hotspots"]:
summary_parts.append(
f" - {len(risk_factors['churn']['hotspots'])} churn hotspot(s)"
)
if risk_factors["cross_community_edges"]:
summary_parts.append(
f" - {len(risk_factors['cross_community_edges'])} cross-community edge(s)"
)
return {
"status": "ok",
"summary": "\n".join(summary_parts),
"metrics": metrics,
"risk_factors": risk_factors,
"llm_judged": [],
"objective_grade": worst,
}
# ---------------------------------------------------------------------------
# dedupe_findings
# ---------------------------------------------------------------------------
def _fingerprint(finding: dict[str, Any]) -> str:
path = str(finding.get("path", "")).replace("\\", "/")
line = finding.get("line")
category = str(finding.get("category", "")).strip().lower()
if line:
return f"{path}:{line}:{category}"
return f"{path}:{category}"
def dedupe_findings(
findings: list[dict[str, Any]],
suppress_prior: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Merge findings by fingerprint, boost multi-source confidence.
Mirrors the gstack-review "collect and merge" step:
* same fingerprint -> keep the highest-confidence finding
* findings confirmed by multiple sources -> confidence +1 (cap 10)
* confidence gates: >=7 normal, 5-6 caveat, 3-4 appendix, 1-2 suppressed
* PR quality score = max(0, 10 - (critical*2 + informational*0.5))
Args:
findings: Finding dicts with at least ``path``, ``category``,
``severity``, ``confidence`` and optional ``source``/``line``.
suppress_prior: Findings previously skipped by the user (read from a
prior review-log). Entries whose fingerprint matches AND whose
file was not changed since then are dropped.
Returns:
Dict with ``merged``, ``suppressed``, ``quality_score`` and
``summary``.
"""
groups: dict[str, list[dict[str, Any]]] = {}
for f in findings:
fp = _fingerprint(f)
groups.setdefault(fp, []).append(f)
merged: list[dict[str, Any]] = []
suppressed_by_gate = 0
for fp, items in groups.items():
best = max(items, key=lambda x: x.get("confidence", 0))
sources = {str(i.get("source", "")).strip() for i in items if i.get("source")}
confirmed = len(sources) > 1
conf = float(best.get("confidence", 0))
if confirmed:
conf = min(10.0, conf + 1.0)
best["multi_source_confirmed"] = True
best["confirming_sources"] = sorted(sources)
best["confidence"] = conf
if conf < 2:
suppressed_by_gate += 1
continue # suppressed entirely
if conf < 4:
best["display"] = "appendix"
elif conf < 7:
best["display"] = "normal-with-caveat"
else:
best["display"] = "normal"
merged.append(best)
# Prior-review suppression (user explicitly skipped, file unchanged).
suppressed_prior = 0
if suppress_prior:
prior_fps = {_fingerprint(f) for f in suppress_prior}
kept: list[dict[str, Any]] = []
for f in merged:
if _fingerprint(f) in prior_fps:
suppressed_prior += 1
continue
kept.append(f)
merged = kept
critical = sum(1 for f in merged if str(f.get("severity", "")).upper() in ("CRITICAL", "BLOCKER"))
informational = sum(
1 for f in merged
if str(f.get("severity", "")).upper() in ("INFORMATIONAL", "MINOR", "WARN")
)
quality = max(0.0, 10.0 - (critical * 2.0 + informational * 0.5))
return {
"status": "ok",
"summary": (
f"Merged {len(findings)} raw finding(s) into {len(merged)} "
f"unique issue(s); quality score {quality:.1f}/10."
),
"merged": merged,
"suppressed_by_confidence": suppressed_by_gate,
"suppressed_by_prior": suppressed_prior,
"quality_score": round(quality, 2),
"counts": {"critical": critical, "informational": informational},
}
# ---------------------------------------------------------------------------
# Report data
# ---------------------------------------------------------------------------
def _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]:
"""Normalise score_review/dedupe output into the HTML report feed.
``review_data`` may contain ``metrics`` (from :func:`score_review`),
``findings`` (from :func:`dedupe_findings`) and free-form ``verdict``,
``tier``, ``scope`` fields. The returned dict is JSON-serialisable and
ready to be injected into ``report-template.html`` as ``{{REPORT_DATA}}``.
"""
# 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": 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"),
"counts": review_data.get("counts", {}),
"metrics": {},
"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"),
"value": m.get("value"),
"note": m.get("note", ""),
"evidence": m.get("evidence", {}),
}
findings = review_data.get("findings") or []
for f in findings:
data["issues"].append({
"severity": f.get("severity", "minor"),
"category": f.get("category", ""),
"message": f.get("summary", f.get("message", "")),
"location": (
f"{f.get('path', '')}:{f.get('line', '')}"
if f.get("line")
else str(f.get("path", ""))
),
"confidence": f.get("confidence"),
"fix": f.get("fix", ""),
})
return data
# ---------------------------------------------------------------------------
# Markdown report rendering (Chinese)
# ---------------------------------------------------------------------------
#: Human-readable labels for the Chinese Markdown report.
_METRIC_LABELS: dict[str, str] = {
"sql_risk": "SQL 注入风险",
"exception_coverage": "异常分支覆盖",
"redundancy_rate": "代码冗余率",
"high_risk_density": "高风险场景密度",
"vulnerability_risk": "漏洞风险",
}
_GRADE_LABELS: dict[str, str] = {
"good": "良好",
"warn": "警告",
"fail": "不合格",
"na": "不适用",
}
_SEVERITY_LABELS: dict[str, str] = {
"blocker": "🔴 阻塞",
"critical": "🔴 严重",
"major": "🟡 主要",
"warn": "🟡 主要",
"minor": "🔵 次要",
"informational": "🔵 次要",
}
def render_markdown_report(review_data: dict[str, Any]) -> str:
"""Render the review data as a Chinese, standalone Markdown report.
Mirrors the HTML report content: verdict, tier, scope, objective
metrics, issues, manual-review items and LLM-judged metrics. The
output is a single Markdown document with no external dependencies.
Args:
review_data: Review data dict (see :func:`build_report_data`).
Returns:
The Markdown report text.
"""
data = build_report_data(review_data)
lines: list[str] = []
lines.append("# 代码审查报告\n")
verdict = str(data["verdict"])
verdict_line = f"- **结论**{verdict}"
tier = data.get("tier") or "standard"
scope = data.get("scope") or "change-level"
baseline = data.get("baseline") or "generic"
lines.append(verdict_line)
lines.append(
f"- **档位**{tier} · **范围**{scope} · **基线**{baseline}"
)
if data.get("timestamp"):
lines.append(f"- **生成时间**{data['timestamp']}")
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:
lines.append(f"- **PR 质量分**{qs}/10")
counts = data.get("counts") or {}
if counts:
lines.append(
f"- **问题统计**{counts.get('critical', 0)} 严重 · "
f"{counts.get('informational', 0)} 次要"
)
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:
lines.append("## 客观指标\n")
lines.append("| 指标 | 数值 | 评级 | 说明 |")
lines.append("|---|---|---|---|")
for name, m in metrics.items():
label = _METRIC_LABELS.get(name, name)
value = m.get("value")
value_text = str(value) if value is not None else "N/A"
grade = m.get("grade") or "na"
grade_text = _GRADE_LABELS.get(grade, grade)
note = str(m.get("note", "") or "")
note = note.replace("\n", " ")
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")
if not issues:
lines.append("未发现问题。\n")
for i, issue in enumerate(issues, start=1):
sev = str(issue.get("severity") or "minor").lower()
sev_label = _SEVERITY_LABELS.get(sev, sev)
category = str(issue.get("category") or "")
message = str(issue.get("message") or "")
location = str(issue.get("location") or "")
conf = issue.get("confidence")
fix = str(issue.get("fix") or "")
lines.append(
f"{i}. **{sev_label}** {message}"
f"{f'(置信度 {conf}/10' if conf is not None else ''}"
)
if category:
lines.append(f" - **类别**{category}")
if location:
lines.append(f" - **位置**`{location}`")
if fix:
lines.append(f" - **修复建议**{fix}")
lines.append("")
# Manual review
manual = data.get("manual_review") or []
if manual:
lines.append("## 需要人工审查\n")
for m in manual:
lines.append(f"- {m}")
lines.append("")
# LLM-judged metrics
judged = data.get("llm_judged") or []
if judged:
lines.append("## 需 LLM 判断的指标\n")
lines.append(", ".join(str(j) for j in judged))
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."
),
}