chore: sync local changes, add Chinese docs and opencode config
This commit is contained in:
@@ -12,18 +12,165 @@ build_report_data) into the three MCP tools consumed by the
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..incremental import get_changed_files, get_staged_and_unstaged
|
||||
from ..scoring import (
|
||||
build_report_data,
|
||||
check_community_health,
|
||||
compute_coverage,
|
||||
dedupe_findings,
|
||||
deep_read_plan,
|
||||
render_markdown_report,
|
||||
score_review,
|
||||
)
|
||||
from ._common import _get_store, _error_response
|
||||
|
||||
#: Persistent cross-round deep-read index (relative path -> per-file SHA).
|
||||
COVERAGE_INDEX_REL = ".code-review-graph/coverage-index.json"
|
||||
|
||||
|
||||
def _git_head_sha(repo_root: Path) -> str | None:
|
||||
"""Current git HEAD SHA (None when not a repo / git unavailable)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _git_file_sha(repo_root: Path, rel_path: str) -> str | None:
|
||||
"""Blob SHA of the *current working-tree* content of ``rel_path``.
|
||||
|
||||
Uses ``git hash-object`` (content hash, does not touch the index) so an
|
||||
uncommitted edit still counts as stale. Returns None when the file does
|
||||
not exist in the working tree or git is unavailable.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "hash-object", rel_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_root),
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _coverage_index_path(repo_root: Path) -> Path:
|
||||
return repo_root / COVERAGE_INDEX_REL
|
||||
|
||||
|
||||
def _load_coverage_index(repo_root: Path) -> list[str]:
|
||||
"""Return the relative paths whose per-file SHA still matches HEAD.
|
||||
|
||||
Files that changed since the last review are automatically excluded
|
||||
(stale), so an incremental round never masks new code with old findings.
|
||||
|
||||
SHA check uses the graph's ``nodes.file_hash`` when the graph is
|
||||
current, else falls back to a single batched ``git hash-object``
|
||||
invocation (never one subprocess per file, see #46/#136).
|
||||
"""
|
||||
index_path = _coverage_index_path(repo_root)
|
||||
if not index_path.is_file():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
entries = payload.get("entries") or {}
|
||||
if not entries:
|
||||
return []
|
||||
return [
|
||||
rel
|
||||
for rel, info in entries.items()
|
||||
if _index_entry_current(repo_root, rel, info)
|
||||
]
|
||||
|
||||
|
||||
def _load_coverage_index_full(
|
||||
repo_root: Path,
|
||||
) -> tuple[list[str], dict[str, list[list[int]]]]:
|
||||
"""Load (current rel paths, their persisted read ranges)."""
|
||||
index_path = _coverage_index_path(repo_root)
|
||||
if not index_path.is_file():
|
||||
return [], {}
|
||||
try:
|
||||
payload = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return [], {}
|
||||
entries = payload.get("entries") or {}
|
||||
if not entries:
|
||||
return [], {}
|
||||
current, ranges = [], {}
|
||||
for rel, info in entries.items():
|
||||
if _index_entry_current(repo_root, rel, info):
|
||||
current.append(rel)
|
||||
if info.get("ranges"):
|
||||
ranges[rel] = info["ranges"]
|
||||
return current, ranges
|
||||
|
||||
|
||||
def _index_entry_current(repo_root: Path, rel: str, info: dict) -> bool:
|
||||
"""True when a persisted entry's SHA matches the current file content."""
|
||||
if not (repo_root / rel).is_file():
|
||||
return False
|
||||
expected = info.get("sha")
|
||||
if not expected:
|
||||
return False
|
||||
# Prefer graph file_hash (single SQL fetch, no subprocess). Fall back to
|
||||
# a batched git hash-object for files not in the graph.
|
||||
graph_hash = _graph_file_hashes(repo_root).get(rel)
|
||||
if graph_hash:
|
||||
return graph_hash == expected
|
||||
return _git_file_sha(repo_root, rel) == expected
|
||||
|
||||
|
||||
_GRAPH_HASH_CACHE: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
def _graph_file_hashes(repo_root: Path) -> dict[str, str]:
|
||||
"""All File-node content hashes for a repo, cached (one SQL fetch)."""
|
||||
key = str(repo_root)
|
||||
if key in _GRAPH_HASH_CACHE:
|
||||
return _GRAPH_HASH_CACHE[key]
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
store, root = _get_store(str(repo_root))
|
||||
try:
|
||||
rows = store._conn.execute(
|
||||
"SELECT file_path, file_hash FROM nodes WHERE kind='File'"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
fp = str(r["file_path"])
|
||||
rel = fp.replace("\\", "/")
|
||||
if rel.startswith(str(root).replace("\\", "/") + "/"):
|
||||
rel = rel[len(str(root).replace("\\", "/")) + 1:].replace("/", "\\")
|
||||
rel = rel.replace("\\", "/")
|
||||
result[rel] = r["file_hash"] or ""
|
||||
finally:
|
||||
store.close()
|
||||
except Exception:
|
||||
result = {}
|
||||
_GRAPH_HASH_CACHE[key] = result
|
||||
return result
|
||||
|
||||
try:
|
||||
from importlib.resources import files as _pkg_files # Python 3.9+
|
||||
|
||||
@@ -44,13 +191,14 @@ def score_review_func(
|
||||
repo_root: str | None = None,
|
||||
detail_level: str = "standard",
|
||||
all_files: bool = False,
|
||||
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute objective Layer-2 review metrics for changed files.
|
||||
|
||||
Runs the git-history / graph risk factors plus the heuristic metrics
|
||||
(SQL risk, exception coverage, redundancy, high-risk density,
|
||||
vulnerability). LLM-judged metrics are listed in ``llm_judged`` so the
|
||||
calling agent knows what still needs judgement.
|
||||
vulnerability). These five objective metrics form the full report
|
||||
metric set; ``llm_judged`` is returned empty for compatibility.
|
||||
|
||||
Args:
|
||||
changed_files: Files to score (auto-detected from git diff if
|
||||
@@ -63,6 +211,8 @@ def score_review_func(
|
||||
all_files: When True, score every source file in the graph,
|
||||
ignoring ``changed_files`` and the git diff. Used for
|
||||
whole-project reviews (default: False).
|
||||
progress_cb: Optional ``(fraction, message)`` progress callback
|
||||
forwarded to the scoring engine.
|
||||
"""
|
||||
store, root = _get_store(repo_root)
|
||||
try:
|
||||
@@ -85,6 +235,7 @@ def score_review_func(
|
||||
root,
|
||||
changed_files,
|
||||
include_churn=include_churn,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
if detail_level == "minimal":
|
||||
@@ -154,6 +305,238 @@ def dedupe_findings_func(
|
||||
return _error_response(str(exc))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def coverage_func(
|
||||
deep_read_files: list[str],
|
||||
all_files: bool = True,
|
||||
include_churn: bool = True,
|
||||
gate: str = "high_risk",
|
||||
include_prior: bool = False,
|
||||
file_read_ranges: dict[str, list[list[int]]] | None = None,
|
||||
file_semantic_units: dict[str, list[dict]] | None = None,
|
||||
repo_root: str | None = None,
|
||||
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute file-count review coverage for a set of deep-read files.
|
||||
|
||||
Coverage = number of deep-read files / number of all source files.
|
||||
The high-risk coverage uses only the signal-flagged subset (also
|
||||
file-count based). Per-file risk weights still rank the priority
|
||||
deep-read list so the highest-risk files are read first. This backs
|
||||
the project-review Step 7.5 coverage self-check (G3).
|
||||
|
||||
Args:
|
||||
deep_read_files: Files the agent actually deep-read during the
|
||||
review (relative or absolute paths). Required.
|
||||
all_files: When True, the denominator is every source file in the
|
||||
graph (default: True, whole-project semantics).
|
||||
include_churn: Include git-churn as the w3 weight term.
|
||||
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
|
||||
``"both"`` (both overall and high-risk must meet the target) /
|
||||
``"both+line"`` (both + per-file line coverage >= line_target
|
||||
and unit-completeness gap-free) / ``"line+unit"`` (line + unit
|
||||
only, feature reviews; file-count / high-risk coverage are
|
||||
skipped and returned as ``None``).
|
||||
include_prior: When True, merge the cross-round coverage index
|
||||
(files whose SHA is unchanged since the last review) into the
|
||||
deep-read set.
|
||||
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
|
||||
sub-agent actually read per deep-read file (for gate="both+line").
|
||||
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
|
||||
reported semantic units per deep-read file (gate="both+line").
|
||||
progress_cb: Optional ``(fraction, message)`` progress callback
|
||||
forwarded to the scoring engine.
|
||||
|
||||
Returns:
|
||||
Dict with coverage_pct, grade, deep_read_count, total_files,
|
||||
target_reached, target, remaining_files_to_target,
|
||||
priority_deep_read_files, uncovered_files, silent_files and note.
|
||||
``silent_files`` feeds the G2 spot-check sampling. For
|
||||
``gate="both+line"`` / ``"line+unit"`` also returns
|
||||
line_coverage_pct, unit_coverage_pct, line_gap_files,
|
||||
unit_gap_files, unit_exempt_files.
|
||||
"""
|
||||
store, root = _get_store(repo_root)
|
||||
try:
|
||||
deep_read = list(deep_read_files or [])
|
||||
ranges = dict(file_read_ranges or {})
|
||||
units = dict(file_semantic_units or {})
|
||||
if include_prior:
|
||||
prior, prior_ranges = _load_coverage_index_full(root)
|
||||
deep_read += prior
|
||||
for rel, rr in prior_ranges.items():
|
||||
ranges.setdefault(rel, rr)
|
||||
return compute_coverage(
|
||||
store,
|
||||
root,
|
||||
deep_read_files=deep_read,
|
||||
include_churn=include_churn,
|
||||
gate=gate,
|
||||
file_read_ranges=ranges,
|
||||
file_semantic_units=units,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _error_response(str(exc))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def deep_read_plan_func(
|
||||
repo_root: str | None = None,
|
||||
target_coverage: float = 85.0,
|
||||
batch_size: int = 40,
|
||||
include_churn: bool = True,
|
||||
include_prior: bool = False,
|
||||
deep_read_files: list[str] | None = None,
|
||||
progress_cb: Callable[[float, Optional[str]], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a grouped deep-read plan that closes the coverage gap.
|
||||
|
||||
Greedily selects the highest-risk files not yet deep-read until the
|
||||
file-count overall coverage target is reached, then groups them by
|
||||
parent directory (≤ ``batch_size`` per group) so each group can be
|
||||
dispatched to one parallel sub-agent.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root. Auto-detected if omitted.
|
||||
target_coverage: Overall coverage target percentage (default 85).
|
||||
batch_size: Max files per group (default 40).
|
||||
include_churn: Include git-churn as the w3 weight term.
|
||||
include_prior: Exclude files already covered by the cross-round
|
||||
coverage index (SHA unchanged) from the plan.
|
||||
deep_read_files: Files already deep-read this round.
|
||||
progress_cb: Optional ``(fraction, message)`` progress callback
|
||||
forwarded to the scoring engine.
|
||||
|
||||
Returns:
|
||||
Dict with current_coverage_pct, current/target/remaining file
|
||||
counts (plus compatible weight counts), planned_files
|
||||
(priority-ordered), directory groups and estimated_batches.
|
||||
"""
|
||||
store, root = _get_store(repo_root)
|
||||
try:
|
||||
prior = set(_load_coverage_index(root)) if include_prior else None
|
||||
return deep_read_plan(
|
||||
store,
|
||||
root,
|
||||
deep_read_files=deep_read_files,
|
||||
target_coverage=target_coverage,
|
||||
batch_size=batch_size,
|
||||
include_churn=include_churn,
|
||||
prior_covered=prior,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _error_response(str(exc))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
def save_coverage_index_func(
|
||||
deep_read_files: list[str],
|
||||
repo_root: str | None = None,
|
||||
file_read_ranges: dict[str, list[list[int]]] | None = None,
|
||||
file_semantic_units: dict[str, list[dict]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist the deep-read file list to the cross-round coverage index.
|
||||
|
||||
Records each file's per-file SHA at HEAD so a later review can tell
|
||||
which previously-deep-read files are still current (SHA unchanged)
|
||||
and which are stale and must be re-read.
|
||||
|
||||
Version 2: SHA comes from the graph's ``nodes.file_hash`` (one SQL
|
||||
query, zero subprocesses) instead of one ``git hash-object`` per file.
|
||||
Line ranges (``file_read_ranges``) are persisted so a later review can
|
||||
reuse the line coverage of SHA-unchanged files.
|
||||
|
||||
Args:
|
||||
deep_read_files: Files deep-read this round (relative or absolute).
|
||||
repo_root: Repository root. Auto-detected if omitted.
|
||||
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
|
||||
persist per file (index v2, reused by include_prior).
|
||||
file_semantic_units: Optional per-file semantic units; persisted
|
||||
for informational purposes (unit completeness is recomputed
|
||||
against the live graph each round, so this is advisory only).
|
||||
|
||||
Returns:
|
||||
Dict with index_path, entry count and head_sha.
|
||||
"""
|
||||
store, root = _get_store(repo_root)
|
||||
store.close()
|
||||
|
||||
file_hashes = _graph_file_hashes(root)
|
||||
|
||||
def _hash_for(rel: str) -> str | None:
|
||||
return file_hashes.get(rel) or _git_file_sha(root, rel)
|
||||
|
||||
entries: dict[str, dict[str, Any]] = {}
|
||||
for f in deep_read_files or []:
|
||||
p = Path(f)
|
||||
rel = str(p.relative_to(root)) if p.is_absolute() else str(p)
|
||||
rel = rel.replace("\\", "/")
|
||||
entry: dict[str, Any] = {"sha": _hash_for(rel) or _git_file_sha(root, rel)}
|
||||
ranges = (file_read_ranges or {}).get(rel)
|
||||
if ranges:
|
||||
entry["ranges"] = ranges
|
||||
units = (file_semantic_units or {}).get(rel)
|
||||
if units:
|
||||
entry["units"] = units
|
||||
entries[rel] = entry
|
||||
|
||||
payload = {
|
||||
"version": 2,
|
||||
"last_updated": datetime.now().isoformat(timespec="seconds"),
|
||||
"head_sha": _git_head_sha(root),
|
||||
"entries": entries,
|
||||
}
|
||||
index_path = _coverage_index_path(root)
|
||||
try:
|
||||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
index_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as exc:
|
||||
return _error_response(f"Failed to write coverage index: {exc}")
|
||||
return {
|
||||
"status": "ok",
|
||||
"index_path": str(index_path),
|
||||
"entries": len(entries),
|
||||
"head_sha": payload["head_sha"],
|
||||
}
|
||||
|
||||
|
||||
def community_health_func(
|
||||
repo_root: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Check node->community attribution health (nodes.community_id).
|
||||
|
||||
Detects the desync where ``communities.size`` is correct but
|
||||
``nodes.community_id`` is mostly NULL (e.g. after an incremental
|
||||
rebuild). Returns ``needs_postprocess``; the review skill should run
|
||||
``code-review-graph postprocess`` when True before computing coverage.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root. Auto-detected if omitted.
|
||||
|
||||
Returns:
|
||||
Dict with total_nodes, attributed_nodes, non_file_nodes,
|
||||
attribution_pct, needs_postprocess and note.
|
||||
"""
|
||||
store, root = _get_store(repo_root)
|
||||
try:
|
||||
return check_community_health(store)
|
||||
except Exception as exc:
|
||||
return _error_response(str(exc))
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool: generate_report
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,8 +589,30 @@ const el = document.getElementById("report");
|
||||
let html = `<h1>Code Review Report</h1>
|
||||
<p><span class="verdict ${data.verdictClass || "fail"}">${data.verdict || "NO VERDICT"}</span>
|
||||
· Tier: ${data.tier || "standard"} · Scope: ${data.scope || "change-level"}</p>`;
|
||||
if (data.files) html += `<p><b>Files:</b> ${data.files}</p>`;
|
||||
if (data.files || (data.reviewed_files && data.reviewed_files.length)) {
|
||||
if (Array.isArray(data.reviewed_files) && data.reviewed_files.length) {
|
||||
html += `<details><summary><b>Files (${data.reviewed_files.length})</b></summary><ul>`
|
||||
+ data.reviewed_files.map(f => `<li><code>${f}</code></li>`).join("")
|
||||
+ `</ul></details>`;
|
||||
} else {
|
||||
html += `<p><b>Files:</b> ${data.files}</p>`;
|
||||
}
|
||||
}
|
||||
if (data.summary) html += `<p>${data.summary}</p>`;
|
||||
const cov = data.coverage || {};
|
||||
if (cov && (cov.coverage_pct != null || cov.line_coverage_pct != null)) {
|
||||
const covOk = cov.target_reached;
|
||||
let c = `<p><b>Coverage:</b> ${covOk ? "target reached" : "coverage insufficient"}`;
|
||||
if (cov.coverage_pct != null) {
|
||||
c += ` · overall ${cov.coverage_pct}% (${cov.deep_read_count}/${cov.total_files})`
|
||||
+ ` · high-risk ${cov.high_risk_coverage_pct}% (${cov.high_risk_deep_count}/${cov.high_risk_total_files})`;
|
||||
}
|
||||
if (cov.line_coverage_pct != null) {
|
||||
c += ` · line ${cov.line_coverage_pct}% · unit ${cov.unit_coverage_pct}%`;
|
||||
}
|
||||
c += `</p>`;
|
||||
html += c;
|
||||
}
|
||||
html += `<h2>Objective Metrics</h2><table><tr><th>Metric</th><th>Value</th><th>Grade</th></tr>`;
|
||||
for (const [k, m] of Object.entries(data.metrics || {})) {
|
||||
html += `<tr><td>${k}</td><td>${m.value ?? "N/A"}</td>
|
||||
@@ -275,9 +680,12 @@ def generate_report_func(
|
||||
written: list[dict[str, Any]] = []
|
||||
if format in ("html", "both"):
|
||||
template = _load_report_template()
|
||||
rendered = template.replace(
|
||||
"{{REPORT_DATA}}", json.dumps(data, ensure_ascii=False)
|
||||
)
|
||||
# Escape "<" as "\u003c" so finding text can never close the
|
||||
# surrounding <script> tag (e.g. a literal "</script>" in a
|
||||
# message/fix). JSON keeps it a valid escape; the JS template
|
||||
# string re-parses it as "<" before esc() HTML-escapes it.
|
||||
payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
|
||||
rendered = template.replace("{{REPORT_DATA}}", payload)
|
||||
html_path = base.with_suffix(".html")
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
html_path.write_text(rendered, encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user