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