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

This commit is contained in:
AuraK Developer
2026-08-31 11:37:03 +08:00
parent 307d2fd471
commit ecc55158c1
81 changed files with 7645 additions and 144 deletions
+1130 -36
View File
@@ -2,10 +2,9 @@
Implements the objectively computable Layer-2 metrics from the
ai-code-review methodology as code, plus the git-history / graph risk
factors used by the gstack-review workflow. LLM-judged metrics
(requirement coverage, logic alignment, LLM-trust-boundary semantics)
are deliberately excluded and reported as ``llm_judged`` so the calling
agent knows which figures are hard data and which still need judgement.
factors used by the gstack-review workflow. The report metric set
consists solely of the five objective heuristic metrics computed here;
``llm_judged`` is returned empty for backward compatibility.
The three public entry points are:
@@ -22,7 +21,7 @@ from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from .changes import (
compute_file_churn,
@@ -32,6 +31,7 @@ from .changes import (
from .constants import SECURITY_KEYWORDS
from .graph import GraphStore
from .parser import normalize_file_path
from collections import Counter
# ---------------------------------------------------------------------------
# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
@@ -235,8 +235,8 @@ def compute_sql_risk(
"grade": _grade("sql_risk", float(count)),
"thresholds": THRESHOLDS["sql_risk"],
"evidence": locations[:20],
"note": "Heuristic scan for string-interpolated SQL. "
"Confirm each location before fixing; run EXPLAIN for performance risk.",
"note": "字符串拼接 SQL 的启发式扫描。修复前请逐一确认每个位置;"
"用 EXPLAIN 评估性能风险。",
}
@@ -265,8 +265,7 @@ def compute_exception_coverage(
"exception_path_lines": exc,
"normal_path_lines": normal,
},
"note": "Heuristic ratio of exception/error-path lines. "
"Review edge cases and error handling manually.",
"note": "异常/错误路径行的启发式占比。请人工复核边界条件与错误处理。",
}
@@ -281,8 +280,8 @@ def compute_redundancy_rate(
"grade": _grade("redundancy_rate", rate),
"thresholds": THRESHOLDS["redundancy_rate"],
"evidence": blocks[:20],
"note": "Heuristic duplicate-block rate (normalised lines appearing in "
">=3 places). Confirm before extracting shared logic.",
"note": "重复代码块启发式占比(规范化行在 >=3 处出现)。"
"抽取公共逻辑前请确认。",
}
@@ -307,8 +306,8 @@ def compute_high_risk_density(
"grade": "na",
"thresholds": THRESHOLDS["high_risk_density"],
"evidence": {},
"note": "No concurrency/transaction/data-integrity patterns detected "
"in the diff -- mark as N/A unless the agent finds a gap.",
"note": "变更中未检出并发/事务/数据一致性模式——"
"除非审查发现缺口,标记为 N/A。",
}
covered = 0
for _rel, line, _no in relevant:
@@ -330,8 +329,7 @@ def compute_high_risk_density(
for rel, line, no in relevant[:20]
],
},
"note": "Density of concurrency/transaction/security patterns. "
"This is a review-attention signal, not a correctness score.",
"note": "并发/事务/安全模式密度。属审查注意力信号,非正确性评分。",
}
@@ -355,10 +353,8 @@ def compute_vulnerability_heuristic(
"grade": _grade("vulnerability_risk", float(count)),
"thresholds": THRESHOLDS["vulnerability_risk"],
"evidence": locations[:20],
"note": "Heuristic OWASP/secret-pattern scan. Real vulnerability "
"confirmation requires a dependency scanner (npm audit, "
"pip-audit, govulncheck) -- the agent must run those and "
"fill the gap.",
"note": "OWASP/密钥模式的启发式扫描。真实漏洞需依赖扫描器"
"npm audit、pip-audit、govulncheck)确认。",
}
@@ -428,8 +424,8 @@ def compute_risk_factors(
"churn": churn,
"cross_community_edges": cross_community[:20],
"hub_dependencies": hub_dependencies[:20],
"note": "Structural risk factors. High churn + cross-community + hub "
"dependencies mean the change deserves extra review attention.",
"note": "结构性风险因子。高变更频率 + 跨社区耦合 + 中枢依赖"
"意味着该改动需要额外审查关注。",
}
@@ -438,6 +434,7 @@ def score_review(
repo_root: Path,
changed_files: list[str],
include_churn: bool = True,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute all objective Layer-2 metrics for a set of changed files.
@@ -446,18 +443,27 @@ def score_review(
repo_root: Repository root.
changed_files: Changed file paths relative to ``repo_root``.
include_churn: Include git-churn risk factors.
progress_cb: Optional ``(fraction, message)`` progress callback;
invoked once per metric (fraction = k/5).
Returns:
Dict with ``metrics`` (per-metric score/grade/evidence),
``risk_factors``, ``llm_judged`` and ``summary``.
"""
metrics = {
"sql_risk": compute_sql_risk(changed_files, repo_root),
"exception_coverage": compute_exception_coverage(changed_files, repo_root),
"redundancy_rate": compute_redundancy_rate(changed_files, repo_root),
"high_risk_density": compute_high_risk_density(changed_files, repo_root),
"vulnerability_risk": compute_vulnerability_heuristic(changed_files, repo_root),
metric_fns = {
"sql_risk": compute_sql_risk,
"exception_coverage": compute_exception_coverage,
"redundancy_rate": compute_redundancy_rate,
"high_risk_density": compute_high_risk_density,
"vulnerability_risk": compute_vulnerability_heuristic,
}
metrics: dict[str, Any] = {}
for idx, (name, fn) in enumerate(metric_fns.items()):
if progress_cb is not None:
progress_cb(idx / len(metric_fns), f"computing {name}")
metrics[name] = fn(changed_files, repo_root)
if progress_cb is not None:
progress_cb(1.0, "metrics done")
risk_factors = compute_risk_factors(
store, repo_root, changed_files, include_churn=include_churn,
@@ -491,13 +497,7 @@ def score_review(
"summary": "\n".join(summary_parts),
"metrics": metrics,
"risk_factors": risk_factors,
"llm_judged": [
"requirement_coverage",
"logic_alignment",
"llm_trust_boundary",
"shell_injection",
"enum_completeness",
],
"llm_judged": [],
"objective_grade": worst,
}
@@ -607,6 +607,34 @@ def dedupe_findings(
# ---------------------------------------------------------------------------
def _normalise_coverage(coverage: Any) -> dict[str, Any] | None:
"""Normalise the ``coverage`` block passed into the HTML report feed.
If ``coverage`` is not a dict (e.g. an AI agent passed ``True`` or a
partial subset), return ``None`` so the report renders no coverage
section rather than crashing. When a dict is given, keep it as-is so
every field the agent chose to transmit survives; the HTML/Markdown
templates provide ``N/A`` fallbacks for missing counts so a partial
transmission never shows a misleading ``0/0``.
"""
if not isinstance(coverage, dict) or not coverage:
return None
return coverage
#: The five objective metrics produced by score_review_tool. Any other key
#: an agent passes in review_data.metrics (e.g. blast_radius, objective_grade,
#: llm_judged leftovers) is filtered out so the report only renders the
#: canonical five rows.
_OBJECTIVE_METRIC_KEYS: frozenset[str] = frozenset({
"sql_risk",
"exception_coverage",
"redundancy_rate",
"high_risk_density",
"vulnerability_risk",
})
def build_report_data(
review_data: dict[str, Any],
) -> dict[str, Any]:
@@ -617,11 +645,33 @@ def build_report_data(
``tier``, ``scope`` fields. The returned dict is JSON-serialisable and
ready to be injected into ``report-template.html`` as ``{{REPORT_DATA}}``.
"""
# Normalise ``files``: agents sometimes pass a list instead of a
# comma-separated string. Accept both; a list is joined so the report
# never renders Python/JSON list syntax.
raw_files = review_data.get("files", "")
files_text = (
", ".join(str(f) for f in raw_files)
if isinstance(raw_files, list)
else raw_files
)
# ``reviewed_files`` drives the collapsible <details> list. Agents may
# pass it as an array OR as a comma-separated string; accept both (a
# string is split so the Markdown renderer never iterates char-by-char).
# Fall back to the ``files`` array when nothing structured was passed.
reviewed_files = review_data.get("reviewed_files") or []
if isinstance(reviewed_files, str):
reviewed_files = [
p.strip() for p in reviewed_files.split(",") if p.strip()
]
elif not reviewed_files and isinstance(raw_files, list):
reviewed_files = [str(f) for f in raw_files]
data: dict[str, Any] = {
"scope": review_data.get("scope", "change-level"),
"tier": review_data.get("tier", "standard"),
"timestamp": review_data.get("timestamp", ""),
"files": review_data.get("files", ""),
"files": files_text,
"reviewed_files": reviewed_files,
"baseline": review_data.get("baseline", "generic"),
"verdict": review_data.get("verdict", "❌ FAIL"),
"quality_score": review_data.get("quality_score"),
@@ -630,10 +680,16 @@ def build_report_data(
"issues": [],
"manual_review": review_data.get("manual_review", []),
"llm_judged": review_data.get("llm_judged", []),
"coverage": _normalise_coverage(review_data.get("coverage")),
"spot_check": _normalise_coverage(review_data.get("spot_check")),
}
# Objective metrics only: filter out stray keys (blast_radius,
# objective_grade, ...) so the report always renders the canonical five.
metrics = review_data.get("metrics") or {}
for name, m in metrics.items():
if name not in _OBJECTIVE_METRIC_KEYS:
continue
if isinstance(m, dict):
data["metrics"][name] = {
"grade": m.get("grade"),
@@ -719,7 +775,7 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
if data.get("timestamp"):
lines.append(f"- **生成时间**{data['timestamp']}")
if data.get("files"):
if data.get("files") and not (data.get("reviewed_files") or []):
lines.append(f"- **文件**{data['files']}")
qs = data.get("quality_score")
if qs is not None:
@@ -732,6 +788,26 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
)
lines.append("")
# Reviewed files (collapsible). Renders from the structured
# ``reviewed_files`` array when present; otherwise the flat ``files``
# string is used (as the meta line above). Defensive: if a raw string
# ever slips through (build_report_data already normalises it), split it
# so we never iterate a string char-by-char.
reviewed = data.get("reviewed_files") or []
if isinstance(reviewed, str):
reviewed = [p.strip() for p in reviewed.split(",") if p.strip()]
if reviewed:
lines.append(f"**审查文件({len(reviewed)} 个)**")
lines.append("")
lines.append("<details>")
lines.append(f"<summary>点击展开 / 收起({len(reviewed)} 个文件)</summary>")
lines.append("")
for f in reviewed:
lines.append(f"- `{f}`")
lines.append("")
lines.append("</details>")
lines.append("")
# Objective metrics
metrics = data.get("metrics") or {}
if metrics:
@@ -749,6 +825,101 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append(f"| {label} | {value_text} | {grade_text} | {note} |")
lines.append("")
# Coverage. For gate="both+line" (whole-project) the file-count and
# high-risk rows render; for gate="line+unit" (feature reviews) the
# engine returns coverage_pct=None so only the line/unit rows plus the
# fail-closed status line render.
coverage = data.get("coverage") or {}
if coverage:
pct = coverage.get("coverage_pct")
hr_pct = coverage.get("high_risk_coverage_pct")
grade = coverage.get("grade") or "na"
grade_text = _GRADE_LABELS.get(grade, grade)
deep_read = coverage.get("deep_read_count", "N/A")
total = coverage.get("total_files", "N/A")
hr_total = coverage.get("high_risk_total_files", "N/A")
hr_deep = coverage.get("high_risk_deep_count", "N/A")
overall_target = coverage.get("overall_target", coverage.get("target", "N/A"))
hr_target = coverage.get("high_risk_target", coverage.get("target", "N/A"))
reached = coverage.get("target_reached", False)
status = "✅ 达标" if reached else "🔴 覆盖不足"
uncovered = coverage.get("uncovered_files") or []
silent = coverage.get("silent_files") or []
# Line / unit coverage (gate="both+line" / "line+unit"): fail-closed.
# A missing value means the review never ran the line-coverage gate -
# surface it explicitly instead of silently omitting the field.
line_pct = coverage.get("line_coverage_pct")
unit_pct = coverage.get("unit_coverage_pct")
line_target = coverage.get("line_target", 95.0)
unit_target = coverage.get("unit_target", 100.0)
line_gap_n = len(coverage.get("line_gap_files") or [])
unit_gap_n = len(coverage.get("unit_gap_files") or [])
missing_n = len(coverage.get("missing_data_files") or [])
lines.append("## 覆盖度\n")
if pct is not None:
lines.append(
f"- **覆盖度(全库)**{pct}% — 已深读 {deep_read}/{total} 个源文件"
f"(目标 {overall_target}%"
)
lines.append(
f"- **覆盖度(高风险)**{hr_pct}% — 已深读 {hr_deep}/{hr_total} 个高风险文件"
f"(目标 {hr_target}%{status}"
)
else:
lines.append(
f"- **状态**{status}gate=\"line+unit\":仅行/单元覆盖,不做文件数覆盖检查)"
)
if line_pct is None or unit_pct is None:
lines.append("- **行覆盖**:未执行 🔴(coverage_tool 未用 gate=\"both+line\" 或未传三件套数据)")
else:
line_ok = "" if line_pct >= line_target and line_gap_n == 0 else "🔴"
unit_ok = "" if unit_pct >= unit_target and unit_gap_n == 0 else "🔴"
lines.append(
f"- **行覆盖**{line_pct}% — 目标 {line_target}%(缺口 {line_gap_n} 文件){line_ok}"
)
lines.append(
f"- **单元覆盖**{unit_pct}% — 目标 {unit_target}%(缺口 {unit_gap_n} 文件){unit_ok}"
)
if missing_n:
lines.append(
f"- **三件套数据缺失**{missing_n} 个文件(缺 read_ranges/语义单元,已按 fail-closed 计为缺口)"
)
if uncovered:
lines.append(
f"- **未深读文件**{len(uncovered)}"
f"(静默文件 {len(silent)} 个)"
)
lines.append("")
# Anti-fake spot check (three-piece suite item 3). Fail-closed: a
# missing/incomplete spot_check renders "未执行 🔴" so reviews that
# skipped the sampled re-read are visible instead of silently green.
spot = data.get("spot_check")
if spot:
groups = spot.get("groups_sampled")
files = spot.get("files_sampled")
units = spot.get("units_sampled")
fake = spot.get("fake_read_found", 0)
rereread = spot.get("groups_rereread") or []
if units:
mark = "🔴 发现假读" if (fake or rereread) else ""
lines.append(
f"- **防伪抽验**:抽样 {files} 文件 / {units} 单元 / {groups} 组,"
f"假读 {fake}{mark}"
)
if rereread:
lines.append(
f" - 因假读重读组:{', '.join(rereread)}"
)
else:
lines.append("- **防伪抽验**:未执行 🔴(spot_check 已上报但单元数为 0")
else:
lines.append(
"- **防伪抽验**:未执行 🔴(主代理未回读任何语义单元;"
"Step 5.5 应执行每组抽 2 文件 × 2-3 单元并落盘 spot_check"
)
lines.append("")
# Issues
issues = data.get("issues") or []
lines.append(f"## 问题清单({len(issues)}\n")
@@ -790,3 +961,926 @@ def render_markdown_report(review_data: dict[str, Any]) -> str:
lines.append("")
return "\n".join(lines).strip() + "\n"
# ---------------------------------------------------------------------------
# Coverage computation (review coverage of the whole project)
# ---------------------------------------------------------------------------
#: Coverage targets (percentage of source files deep-read). Fixed to the
#: single standard tier; the fast/strict tiers were removed. Per-gate
#: targets: ``overall`` is the whole-project file-count target, ``high_risk``
#: the signal-flagged subset target. ``gate="both"`` requires both to pass.
COVERAGE_TARGETS: dict[str, dict[str, float]] = {
"standard": {
"overall": 85.0,
"high_risk": 95.0,
},
}
def _coverage_targets(tier: str = "standard") -> tuple[float, float]:
"""Resolve the ``(overall, high_risk)`` target pair for a tier.
Backwards-compatible: a legacy flat float value (e.g. ``95.0``) is
treated as applying to *both* gates. A per-gate dict is honoured as-is.
"""
cfg = COVERAGE_TARGETS.get(tier, COVERAGE_TARGETS.get("standard", {}))
if isinstance(cfg, dict):
return (
float(cfg.get("overall", 85.0)),
float(cfg.get("high_risk", 95.0)),
)
value = float(cfg)
return value, value
#: Subdirectories excluded from the coverage denominator (non-source).
COVERAGE_EXCLUDE_DIRS: tuple[str, ...] = (
"docs/",
"test-output/",
"tests/",
"scripts/",
"node_modules/",
".git/",
)
#: Weight of each metric grade for the w1 term (worst grade wins per file).
_GRADE_WEIGHT: dict[str, float] = {
"fail": 3.0,
"warn": 2.0,
"good": 1.0,
"na": 0.5,
}
def _is_source_file(rel_path: str) -> bool:
"""Heuristic filter for source files vs. docs/tests/generated output."""
normalized = rel_path.replace("\\", "/").lower()
if any(normalized.startswith(d) for d in COVERAGE_EXCLUDE_DIRS):
return False
if normalized.endswith(
(".bak", ".clean", ".debug1", ".fullbak", ".tmp", ".map")
):
return False
return True
def _per_file_w1(file_rel: str, repo_root: Path) -> float:
"""Compute the risk grade (w1 term) for a single source file.
Uses the per-file SQL / vulnerability / redundancy heuristic scans.
``exception_coverage`` is deliberately excluded: on a per-file basis it
is ~always ``fail`` for real-world modules (most files have few
explicit error branches), which gives w1 zero discriminative power.
``high_risk_density`` is excluded too (it is ~always 100% for any file
containing SQL/async/transaction markers). ``repo_root`` is resolved
against when ``file_rel`` is not absolute.
"""
raw = file_rel.replace("\\", "/")
candidate = Path(file_rel)
if not candidate.is_absolute():
candidate = repo_root / raw
if not candidate.is_file():
return 0.0
try:
lines = candidate.read_text(
encoding="utf-8", errors="replace",
).splitlines()
except OSError:
return 0.0
if not lines:
return 0.0
line_items = [(raw, line, i) for i, line in enumerate(lines, start=1)]
sql_hits = _count_matching(line_items, _SQL_RISK_PATTERNS)
sql_grade = _grade("sql_risk", float(sql_hits))
vuln_hits = _count_matching(line_items, _VULNERABILITY_PATTERNS)
vuln_grade = _grade("vulnerability_risk", float(vuln_hits))
sig_count: dict[str, int] = Counter()
for _p, line, _n in line_items:
sig = _normalized_signature(line)
if len(sig) >= 24:
sig_count[sig] += 1
dup_lines = sum(c for c in sig_count.values() if c >= 3)
redundancy_rate = (dup_lines / len(line_items) * 100.0)
redund_grade = _grade("redundancy_rate", redundancy_rate)
grades = [
g for g in (sql_grade, vuln_grade, redund_grade)
if g != "na"
]
if not grades:
return _GRADE_WEIGHT["na"]
worst = max(grades, key=lambda g: _GRADE_WEIGHT.get(g, 0.0))
return _GRADE_WEIGHT.get(worst, _GRADE_WEIGHT["na"])
def _topology_hits(store: GraphStore, file_rel: str) -> float:
"""Count graph topology signal hits (w2 term) for a file's nodes.
Uses hub degree (>= 10 incoming calls) and untested hotspot flags as
lightweight proxies; avoids re-running the top-N truncated tools so the
w2 term is computed over the whole graph, not the first N nodes.
"""
abs_path = normalize_file_path(Path(file_rel))
nodes = store.get_nodes_by_file(abs_path)
if not nodes:
return 0.0
hits = 0
for n in nodes:
edges = store.get_edges_by_target(n.qualified_name)
incoming = [
e for e in edges
if e.kind in ("CALLS", "REFERENCES", "IMPLEMENTS")
]
if len(incoming) >= 10:
hits += 1
if not n.is_test and len(incoming) >= 5 and not _has_tested_by(store, n.qualified_name):
hits += 0.5
return hits
def _has_tested_by(store: GraphStore, qualified_name: str) -> bool:
try:
edges = store.get_edges_by_target(qualified_name)
return any(e.kind == "TESTED_BY" for e in edges)
except Exception:
return False
def _file_weights(
store: GraphStore,
repo_root: Path,
source_files: list[str],
source_abs: list[str],
churn_map: dict[str, int],
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, dict[str, Any]]:
"""Compute the risk weight ``w = w1(grade) + w2(topology) + w3(churn)``
for every source file, plus its high-risk flag.
Shared by :func:`compute_coverage` and :func:`deep_read_plan` so both
derive weights from exactly the same model. Keys are the normalized
absolute paths used by the graph identity.
Args:
store: Open graph store.
repo_root: Repository root.
source_files: Source file paths relative to ``repo_root``.
source_abs: Parallel list of normalized absolute paths.
churn_map: Per-file commit counts.
progress_cb: Optional ``(fraction, message)`` progress callback,
invoked every 50 files.
"""
weights: dict[str, dict[str, Any]] = {}
max_churn = max(churn_map.values()) if churn_map else 1
total = max(len(source_files), 1)
for idx, (f, f_abs) in enumerate(zip(source_files, source_abs)):
if progress_cb is not None and idx % 50 == 0:
progress_cb(idx / total, f"computing risk weights ({idx}/{total})")
w1 = _per_file_w1(f_abs, repo_root)
w2 = _topology_hits(store, f_abs)
rel_for_churn = f.lstrip("/").replace("\\", "/")
raw_churn = churn_map.get(f, churn_map.get(rel_for_churn, 0))
w3 = (raw_churn / max_churn) if max_churn else 0.0
is_high_risk = (w1 >= 2.0 or w2 > 0.0 or raw_churn >= 3)
weights[f_abs] = {
"w": w1 + w2 + w3,
"w1": w1,
"w2": w2,
"w3": w3,
"raw_churn": raw_churn,
"is_high_risk": is_high_risk,
}
if progress_cb is not None:
progress_cb(1.0, "risk weights done")
return weights
def _real_line_count(repo_root: Path, rel: str, cache: dict) -> int:
"""Real line count of a source file, cached. Independent of graph node
``line_end`` (verified to have a +-1 skew vs actual file length)."""
if rel in cache:
return cache[rel]
try:
n = len((repo_root / rel).read_text(encoding="utf-8", errors="replace").splitlines())
except OSError:
n = 0
cache[rel] = n
return n
def _union_len(ranges: list[list[int]]) -> int:
"""Covered line count of a list of inclusive [s,e] ranges (merged)."""
if not ranges:
return 0
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return sum(e - s + 1 for s, e in merged)
def _graph_semantic_units(store: GraphStore, root: Path, rel: str) -> list[dict]:
"""Graph semantic-unit nodes (Function/Class/Test) of a file."""
if not hasattr(store, "_conn"):
return []
q = (root / rel).as_posix()
try:
rows = store._conn.execute(
"SELECT kind, name, line_start, line_end FROM nodes "
"WHERE file_path = ? AND kind IN ('Function','Class','Test') "
"ORDER BY line_start",
(q,),
).fetchall()
except Exception:
return []
out = []
for r in rows:
try:
out.append(
{
"kind": r["kind"],
"name": r["name"],
"line_start": int(r["line_start"]),
"line_end": int(r["line_end"]),
}
)
except (KeyError, TypeError):
continue
return out
def _is_giant_file(graph_units: list[dict], real_lines: int) -> bool:
"""Unit-exempt when the largest unit spans >80% of the file's lines.
A single huge function (e.g. migrations.rs run_migrations = 98% of the
file) makes unit-completeness meaningless, so such files are checked on
line coverage only. Small files with 2-3 ordinary units are NOT exempt:
they must still cover every unit."""
if not graph_units or real_lines <= 0:
return False
largest = max(u["line_end"] - u["line_start"] + 1 for u in graph_units)
return (largest / real_lines) > 0.8
def _unit_overlap(a: list[int], b: list[int]) -> int:
lo, hi = max(a[0], b[0]), min(a[1], b[1])
return max(0, hi - lo + 1)
def _unit_covered(
graph_unit: dict,
read_ranges: list[list[int]],
unit_ranges: list[list[int]],
matched: set[int],
) -> bool:
"""A graph unit is covered iff one reported unit range matches exactly
(preferred) or overlaps >=80% of the graph unit span, is not already
claimed by a higher-overlap unit (one-to-one), and >=80% of the graph
unit's lines fall inside union(read_ranges)."""
gs, ge = graph_unit["line_start"], graph_unit["line_end"]
gspan = max(1, ge - gs + 1)
exact = [i for i, (s, e) in enumerate(unit_ranges) if s == gs and e == ge]
if exact:
idx = exact[0]
if idx in matched:
return False
matched.add(idx)
else:
best_idx, best_overlap = None, 0
for i, (s, e) in enumerate(unit_ranges):
ov = _unit_overlap([gs, ge], [s, e])
if ov > best_overlap:
best_overlap, best_idx = ov, i
if best_idx is None or best_idx in matched:
return False
if best_overlap / gspan < 0.8:
return False
matched.add(best_idx)
in_union = 0
for s, e in _merge_ranges(read_ranges):
lo, hi = max(gs, s), min(ge, e)
if lo <= hi:
in_union += hi - lo + 1
return (in_union / gspan) >= 0.8
def _merge_ranges(ranges: list[list[int]]) -> list[list[int]]:
merged: list[list[int]] = []
for s, e in sorted((int(a), int(b)) for a, b in ranges or []):
if s < 1:
s = 1
if e < s:
continue
if merged and s <= merged[-1][1] + 1:
merged[-1][1] = max(merged[-1][1], e)
else:
merged.append([s, e])
return merged
def _unit_gaps(
graph_units: list[dict],
reported_units: list[dict],
read_ranges: list[list[int]],
) -> list[dict]:
"""Return graph units not covered by the reported semantic units."""
unit_ranges = [
[int(u.get("range", [0, 0])[0]), int(u.get("range", [0, 0])[1])]
for u in reported_units
]
matched: set[int] = set()
gaps = []
for u in graph_units:
if not _unit_covered(u, read_ranges, unit_ranges, matched):
gaps.append(
{
"name": u["name"],
"range": [u["line_start"], u["line_end"]],
}
)
return gaps
def compute_coverage(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str],
include_churn: bool = True,
gate: str = "high_risk",
file_read_ranges: dict[str, list[list[int]]] | None = None,
file_semantic_units: dict[str, list[dict]] | None = None,
line_target: float = 95.0,
unit_target: float = 100.0,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Compute file-count review coverage for a set of deep-read files.
Coverage = number of deep-read files / total number of source files.
The overall coverage uses every source file as the denominator; the
high-risk coverage uses only the signal-flagged subset. Per-file risk
weights (w = w1 grade + w2 topology + w3 churn) are still computed and
used to rank ``priority_deep_read_files`` so the highest-risk files
are read first, but the coverage percentage itself is file-count based.
Also returns the list of files never touched by any deep-read /
signal (``silent_files``) for G2 spot-check sampling, the uncovered
files list, and (new) the file count still needed to reach the target
plus the priority deep-read file list that would close that gap.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files the agent actually deep-read during the
review (relative or absolute paths).
include_churn: Include git-churn as the w3 weight term.
gate: Coverage gate mode. ``"high_risk"`` (default) gates on the
signal-flagged subset only; ``"overall"`` gates on all source
files; ``"both"`` requires *both* the overall and the high-risk
coverage to meet the target; ``"both+line"`` additionally
requires per-file line coverage >= ``line_target`` and unit
completeness (gap-free) per file; ``"line+unit"`` (feature
reviews) checks ONLY line coverage and unit completeness -
file-count / high-risk coverage are not computed and
``coverage_pct`` / ``high_risk_coverage_pct`` return ``None``.
file_read_ranges: Optional mapping {rel_path: [[s,e], ...]} of the
line ranges a sub-agent actually read per deep-read file.
Used for the line-coverage gate (denominator = real file line
count). Absent file => line coverage treated as satisfied.
file_semantic_units: Optional mapping {rel_path: [{"range":[s,e],
"kind":.., "name":..}, ...]} reported per deep-read file.
Used for the unit-completeness gate (gap-free vs graph units).
Absent file => unit completeness treated as satisfied.
line_target: Min per-file line coverage percent for gate="both+line".
unit_target: Min unit completeness percent (gap-free share).
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with coverage_pct, high_risk_coverage_pct, deep_read_count,
total_files, deep_read_weight, total_weight, target_reached,
target (high-risk target, back-compat), overall_target,
high_risk_target, remaining_files_to_target (file-count gap) and the
compatible remaining_weight_to_target (risk-weight gap),
priority_deep_read_files, uncovered_files, silent_files and grade.
With gate="both+line" also returns line_coverage_pct,
unit_coverage_pct, line_gap_files, unit_gap_files,
unit_exempt_files, missing_data_files.
With gate="line+unit" same line/unit fields plus
``gate="line+unit"``; ``coverage_pct``/``high_risk_coverage_pct``
are ``None`` because file-count coverage is not computed.
FAIL-CLOSED (v2.5.2): a deep-read file with no file_read_ranges
(or no file_semantic_units when the graph has units) is recorded
as a line/unit gap and listed in missing_data_files. Missing data
therefore makes target_reached=false instead of silently passing.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"coverage_pct": 0.0,
"grade": "na",
"deep_read_count": 0,
"total_files": 0,
"deep_read_weight": 0.0,
"total_weight": 0.0,
"target_reached": False,
"target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"overall_target": COVERAGE_TARGETS.get("standard", {}).get("overall", 85.0),
"high_risk_target": COVERAGE_TARGETS.get("standard", {}).get("high_risk", 95.0),
"remaining_files_to_target": 0.0,
"remaining_weight_to_target": 0.0,
"priority_deep_read_files": [],
"uncovered_files": [],
"silent_files": [],
"note": "No source files found in graph.",
}
# Resolve relative paths against repo_root so graph identity matches.
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
# Normalise deep-read list against the graph's file paths.
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
# Line / unit coverage data (gate="both+line" / "line+unit").
line_gap_files: list[dict] = []
unit_gap_files: list[dict] = []
unit_exempt_files: list[dict] = []
missing_data_files: list[dict] = []
_line_cache: dict[str, int] = {}
_line_tot = 0
_line_cov = 0
_unit_tot = 0
_unit_cov = 0
if gate in ("both+line", "line+unit"):
root_str = str(repo_root).replace("\\", "/").rstrip("/")
for f, f_abs in zip(source_files, source_abs):
if f_abs not in deep_read_set:
continue
# rel is relative to repo_root (sub-agents report relative paths)
rel = f.replace("\\", "/")
if rel.startswith(root_str + "/"):
rel = rel[len(root_str) + 1:]
_line_tot += 1
_unit_tot += 1
# --- line coverage ---
ranges = (file_read_ranges or {}).get(rel) or (
file_read_ranges or {}
).get(f_abs)
real_lines = _real_line_count(repo_root, rel, _line_cache)
if ranges and real_lines > 0:
covered = _union_len(ranges)
pct = covered / real_lines * 100.0
_line_cov += 1 if pct >= line_target else 0
if pct < line_target:
line_gap_files.append(
{
"path": rel,
"coverage_pct": round(pct, 1),
"total_lines": real_lines,
"covered_lines": covered,
}
)
else:
# FAIL-CLOSED: a deep-read file without read_ranges (or an
# empty/unreadable file) cannot be verified - record a gap
# instead of silently treating it as satisfied. Without this
# branch, gate="both+line" would report target_reached=true
# while line_coverage_pct stays 0 (silent green).
line_gap_files.append(
{
"path": rel,
"coverage_pct": 0.0,
"reason": (
"missing read_ranges" if not ranges
else "unreadable/empty file"
),
}
)
missing_data_files.append(
{"path": rel, "field": "file_read_ranges"}
)
# --- unit completeness ---
units = (file_semantic_units or {}).get(rel) or (
file_semantic_units or {}
).get(f_abs)
g_units = _graph_semantic_units(store, repo_root, rel)
if g_units and units is None:
# FAIL-CLOSED: graph has semantic units but the sub-agent
# reported none - cannot verify completeness, record a gap.
unit_gap_files.append(
{
"path": rel,
"reason": "missing semantic_units",
}
)
missing_data_files.append(
{"path": rel, "field": "file_semantic_units"}
)
elif g_units and units is not None:
giant = _is_giant_file(g_units, real_lines)
if giant:
unit_exempt_files.append(
{
"path": rel,
"reason": (
"giant-file: units=%d largest_span=%.0f%%"
% (
len(g_units),
100
* max(u["line_end"] - u["line_start"] + 1 for u in g_units)
/ max(1, real_lines),
)
),
}
)
_unit_tot -= 1 # exempt from unit gate
else:
uncovered = _unit_gaps(g_units, units, ranges or [])
if uncovered:
unit_gap_files.append(
{
"path": rel,
"total_units": len(g_units),
"covered_units": len(g_units) - len(uncovered),
"uncovered": uncovered,
}
)
else:
_unit_cov += 1
unit_coverage_pct = (_unit_cov / _unit_tot * 100.0) if _unit_tot else 100.0
line_coverage_pct = (_line_cov / _line_tot * 100.0) if _line_tot else 100.0
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
# Single pass: compute overall weight (all source files) and the
# high-risk subset weight (files flagged by any signal).
total_weight = 0.0
deep_read_weight = 0.0
high_risk_total = 0.0
high_risk_deep = 0.0
high_risk_deep_count = 0
high_risk_files: list[str] = []
uncovered_files: list[str] = []
silent_files: list[str] = []
for f, f_abs in zip(source_files, source_abs):
wi = weights[f_abs]
w = wi["w"]
total_weight += w
if f_abs in deep_read_set:
deep_read_weight += w
if wi["is_high_risk"]:
high_risk_total += w
high_risk_files.append(f)
if f_abs in deep_read_set:
high_risk_deep += w
high_risk_deep_count += 1
elif f_abs not in deep_read_set:
# Not high-risk and not deep-read: silent candidate.
if wi["w1"] < 2.0 and wi["w2"] == 0.0 and wi["raw_churn"] < 3:
silent_files.append(f)
uncovered_files = [
f for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
total_files = len(source_files)
deep_read_count = len(deep_read_set)
coverage_pct = (
(deep_read_count / total_files * 100.0)
if total_files else 0.0
)
high_risk_total_files = len(high_risk_files)
high_risk_pct = (
(high_risk_deep_count / high_risk_total_files * 100.0)
if high_risk_total_files else 0.0
)
overall_target, high_risk_target = _coverage_targets()
overall_ok = coverage_pct >= overall_target
high_risk_ok = (
(high_risk_pct >= high_risk_target) if high_risk_total_files else overall_ok
)
if gate == "overall":
target_reached = overall_ok
gate_pct = coverage_pct
gate_target = overall_target
elif gate == "both":
target_reached = overall_ok and high_risk_ok
gate_pct = min(coverage_pct, high_risk_pct) if high_risk_total_files else coverage_pct
gate_target = min(overall_target, high_risk_target)
elif gate == "both+line":
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = overall_ok and high_risk_ok and quality_ok
gate_pct = (
min(coverage_pct, high_risk_pct, line_coverage_pct, unit_coverage_pct)
if high_risk_total_files
else min(coverage_pct, line_coverage_pct, unit_coverage_pct)
)
gate_target = min(overall_target, high_risk_target, line_target, unit_target)
elif gate == "line+unit":
# Feature reviews: check ONLY line coverage + unit completeness.
# File-count / high-risk coverage is deliberately not part of the
# gate (coverage_pct / high_risk_coverage_pct are returned as None).
quality_ok = (
len(line_gap_files) == 0
and len(unit_gap_files) == 0
)
target_reached = quality_ok
gate_pct = min(line_coverage_pct, unit_coverage_pct)
gate_target = min(line_target, unit_target)
else: # "high_risk" (default)
target_reached = high_risk_ok
gate_pct = high_risk_pct
gate_target = high_risk_target
grade = (
"good" if target_reached
else ("warn" if gate_pct >= gate_target * 0.8 else "fail")
)
remaining_files_to_target = max(
0.0, total_files * overall_target / 100.0 - deep_read_count
)
remaining_weight_to_target = max(
0.0, total_weight * overall_target / 100.0 - deep_read_weight
)
priority_deep_read_files = [
{"path": f_abs, "weight": round(weights[f_abs]["w"], 3)}
for f, f_abs in zip(source_files, source_abs)
if f_abs not in deep_read_set
]
priority_deep_read_files.sort(key=lambda e: e["weight"], reverse=True)
return {
"status": "ok",
"coverage_pct": round(coverage_pct, 1) if gate != "line+unit" else None,
"high_risk_coverage_pct": round(high_risk_pct, 1) if gate != "line+unit" else None,
"grade": grade,
"deep_read_count": deep_read_count,
"total_files": total_files,
"high_risk_total_files": high_risk_total_files,
"high_risk_deep_count": high_risk_deep_count,
"deep_read_weight": round(deep_read_weight, 2),
"total_weight": round(total_weight, 2),
"target_reached": target_reached,
"target": high_risk_target,
"overall_target": overall_target,
"high_risk_target": high_risk_target,
"gate": gate,
"line_coverage_pct": round(line_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"unit_coverage_pct": round(unit_coverage_pct, 1) if gate in ("both+line", "line+unit") else None,
"line_gap_files": line_gap_files if gate in ("both+line", "line+unit") else [],
"unit_gap_files": unit_gap_files if gate in ("both+line", "line+unit") else [],
"unit_exempt_files": unit_exempt_files if gate in ("both+line", "line+unit") else [],
"missing_data_files": missing_data_files if gate in ("both+line", "line+unit") else [],
"remaining_files_to_target": round(remaining_files_to_target, 2),
"remaining_weight_to_target": round(remaining_weight_to_target, 2),
"priority_deep_read_files": priority_deep_read_files,
"uncovered_files": uncovered_files,
"silent_files": silent_files,
"note": (
"行/单元门禁(feature):仅要求行覆盖 >= "
f"{line_target}% 且单元完整性无缺口;coverage_pct / "
"high_risk_coverage_pct 为 None(未做文件数/高风险覆盖检查)。"
) if gate == "line+unit" else (
"双重覆盖口径:全库 = 深读文件数 / 全部源文件数;高风险 = 深读 / "
"信号点名文件数。G3 门禁口径可配置(high_risk/overall/both)。"
"每文件风险权重(w = w1 指标分 + w2 拓扑 + w3 变更频率)仍用于"
"排序 priority_deep_read_files;低于目标 => 审查覆盖不足。"
),
}
def deep_read_plan(
store: GraphStore,
repo_root: Path,
deep_read_files: list[str] | None = None,
target_coverage: float = 85.0,
batch_size: int = 40,
include_churn: bool = True,
prior_covered: set[str] | None = None,
progress_cb: Callable[[float, Optional[str]], None] | None = None,
) -> dict[str, Any]:
"""Generate a grouped deep-read plan that closes the coverage gap.
Greedily selects files not yet deep-read until the file-count coverage
target is reached, preferring the highest-risk (highest-weight) files
first, then groups them by parent directory so each batch can be
dispatched to one parallel sub-agent.
Args:
store: Open graph store (caller owns and closes it).
repo_root: Repository root.
deep_read_files: Files already deep-read this round.
target_coverage: Target overall coverage percentage (default 85).
batch_size: Max files per group (default 40).
include_churn: Include git-churn as the w3 weight term.
prior_covered: Absolute paths already deep-read in prior rounds
(from the coverage index); excluded from the plan.
progress_cb: Optional ``(fraction, message)`` progress callback;
forwarded to churn and weight computation.
Returns:
Dict with current/remaining file counts (primary) plus compatible
weight counts, the greedy gap-closing file list and the directory
groups ready for sub-agent dispatch.
"""
all_files = store.get_all_files()
source_files = [f for f in all_files if _is_source_file(f)]
if not source_files:
return {
"status": "ok",
"current_coverage_pct": 0.0,
"current_files": 0,
"target_files": 0,
"remaining_files": 0,
"current_weight": 0.0,
"target_weight": 0.0,
"remaining_weight": 0.0,
"planned_files": [],
"planned_weight": 0.0,
"groups": [],
"estimated_batches": 0,
"gate": "overall",
}
def _abs(rel: str) -> str:
p = Path(rel)
if p.is_absolute():
return normalize_file_path(p)
return normalize_file_path(repo_root / p)
source_abs = [_abs(f) for f in source_files]
source_abs_set = set(source_abs)
deep_read_set: set[str] = set()
for f in deep_read_files or []:
norm = _abs(f)
if norm in source_abs_set or norm in set(all_files):
deep_read_set.add(norm)
if prior_covered:
deep_read_set |= {_abs(p) for p in prior_covered if _abs(p) in source_abs_set}
churn_map: dict[str, int] = {}
if include_churn:
churn_map = compute_file_churn(str(repo_root), progress_cb=progress_cb)
weights = _file_weights(store, repo_root, source_files, source_abs, churn_map, progress_cb=progress_cb)
total_weight = sum(wi["w"] for wi in weights.values())
total_files = len(source_files)
current_files = len(deep_read_set)
current_weight = sum(
weights[f_abs]["w"]
for f_abs in source_abs_set
if f_abs in deep_read_set and f_abs in weights
)
current_pct = (current_files / total_files * 100.0) if total_files else 0.0
target_files = total_files * target_coverage / 100.0
remaining_files = max(0.0, target_files - current_files)
target_weight = total_weight * target_coverage / 100.0
remaining_weight = max(0.0, target_weight - current_weight)
# Greedy: pick highest-weight uncovered files until the gap is closed.
candidates = sorted(
(
{"path": f_abs, "weight": weights[f_abs]["w"]}
for f_abs in source_abs
if f_abs not in deep_read_set and f_abs in weights
),
key=lambda e: e["weight"],
reverse=True,
)
planned: list[str] = []
accrued_weight = 0.0
for cand in candidates:
if len(planned) >= remaining_files:
break
planned.append(cand["path"])
accrued_weight += cand["weight"]
# Group by parent directory, keeping group order by total weight desc.
from collections import OrderedDict
by_dir: "OrderedDict[str, list[str]]" = OrderedDict()
for p in planned:
d = Path(p).parent.name or "."
by_dir.setdefault(d, []).append(p)
groups: list[dict[str, Any]] = []
for d, files in by_dir.items():
g_weight = sum(weights[_abs(f)]["w"] for f in files)
for i in range(0, len(files), batch_size):
chunk = files[i : i + batch_size]
groups.append(
{
"name": d,
"weight": round(g_weight, 2),
"files": chunk,
"batch_index": i // batch_size,
}
)
return {
"status": "ok",
"target_coverage": target_coverage,
"current_coverage_pct": round(current_pct, 1),
"current_files": current_files,
"target_files": round(target_files, 2),
"remaining_files": round(remaining_files, 2),
"current_weight": round(current_weight, 2),
"target_weight": round(target_weight, 2),
"remaining_weight": round(remaining_weight, 2),
"planned_files": planned,
"planned_weight": round(accrued_weight, 2),
"groups": groups,
"estimated_batches": len(groups),
"gate": "overall",
}
def check_community_health(store: GraphStore) -> dict[str, Any]:
"""Detect community/node attribution desync (nodes.community_id NULL).
The communities table may carry a correct ``size`` while
``nodes.community_id`` is stale (e.g. after an incremental build that
rebuilt nodes but skipped community re-attribution). Returns the
non-null ratio and a ``needs_postprocess`` flag so the review skill
can trigger ``postprocess`` before computing coverage.
Args:
store: Open graph store (caller owns and closes it).
Returns:
Dict with total_nodes, attributed_nodes, attribution_pct,
needs_postprocess and note.
"""
try:
total = store._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
attributed = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE community_id IS NOT NULL"
).fetchone()[0]
non_file = store._conn.execute(
"SELECT COUNT(*) FROM nodes WHERE kind != 'File'"
).fetchone()[0]
except Exception as exc: # pragma: no cover
return {
"status": "error",
"needs_postprocess": True,
"note": f"Community health check failed: {exc}",
}
ratio = (attributed / non_file * 100.0) if non_file else 0.0
# A fully healthy graph has ~100% attribution; allow a small delta for
# nodes without community membership.
needs = ratio < 90.0
return {
"status": "ok",
"total_nodes": total,
"attributed_nodes": attributed,
"non_file_nodes": non_file,
"attribution_pct": round(ratio, 1),
"needs_postprocess": needs,
"note": (
"nodes.community_id attribution ratio. Low ratio => run "
"`code-review-graph postprocess` to re-attach members."
),
}