"""MCP tool wrappers for the unified-review scoring workflow. Wraps :mod:`code_review_graph.scoring` (score_review / dedupe_findings / build_report_data) into the three MCP tools consumed by the ``unified-review`` skill: * ``score_review_tool`` - objective Layer-2 metrics for changed files * ``dedupe_findings_tool`` - fingerprint dedup + confidence merge * ``generate_report_tool`` - render the HTML and/or Markdown review report """ from __future__ import annotations import json import os import subprocess from datetime import datetime from pathlib import Path 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+ _HAS_IMPORTLIB_RESOURCES = True except ImportError: # pragma: no cover _HAS_IMPORTLIB_RESOURCES = False # --------------------------------------------------------------------------- # Tool: score_review # --------------------------------------------------------------------------- def score_review_func( changed_files: list[str] | None = None, base: str = "HEAD~1", include_churn: bool = True, 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). 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 omitted). base: Git ref to diff against (default: HEAD~1). include_churn: Include git-churn risk factors (default: True). repo_root: Repository root. Auto-detected if omitted. detail_level: Output detail level. ``minimal`` returns only grades and values; ``standard`` includes evidence. 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: if all_files: changed_files = store.get_all_files() elif changed_files is None: changed_files = get_changed_files(root, base) if not changed_files: changed_files = get_staged_and_unstaged(root) if not changed_files: return { "status": "ok", "summary": "No changed files detected. Nothing to score.", "metrics": {}, "objective_grade": "good", } result = score_review( store, root, changed_files, include_churn=include_churn, progress_cb=progress_cb, ) if detail_level == "minimal": return { "status": "ok", "summary": result["summary"], "objective_grade": result["objective_grade"], "metrics": { name: {"value": m["value"], "grade": m["grade"]} for name, m in result["metrics"].items() }, "llm_judged": result["llm_judged"], } result["changed_files"] = changed_files result["next_tool_suggestions"] = [ "dedupe_findings -- merge specialist findings", "detect_changes -- risk-scored impact analysis", "generate_report -- export HTML report", ] return result except Exception as exc: return _error_response(str(exc)) finally: store.close() # --------------------------------------------------------------------------- # Tool: dedupe_findings # --------------------------------------------------------------------------- def dedupe_findings_func( findings: list[dict[str, Any]], suppress_prior: list[dict[str, Any]] | None = None, repo_root: str | None = None, ) -> dict[str, Any]: """Merge review findings by fingerprint and boost multi-source confidence. Mirrors the gstack-review "collect and merge" step: findings with the same ``path:line:category`` fingerprint are merged (highest confidence wins); findings confirmed by more than one source get confidence +1 (cap 10). Confidence gates route low-confidence findings to the appendix or suppress them, and a PR quality score is computed as ``max(0, 10 - (critical*2 + informational*0.5))``. Args: findings: Raw finding dicts with ``path``, ``category``, ``severity``, ``confidence`` and optional ``source``/``line``. suppress_prior: Previously user-skipped findings (from a prior review-log) to suppress when their file has not changed. repo_root: Repository root (used to resolve changed files when suppressing prior findings). """ try: suppressed_prior_list: list[dict[str, Any]] = [] if suppress_prior: for f in suppress_prior: suppressed_prior_list.append(f) result = dedupe_findings(findings, suppressed_prior_list) result["next_tool_suggestions"] = [ "generate_report -- export HTML report", "detect_changes -- risk-scored impact analysis", ] return result except Exception as exc: 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 # --------------------------------------------------------------------------- def _load_report_template() -> str: """Load ``report-template.html`` from the installed package assets. Falls back to the bundled template string when the asset cannot be loaded, so the tool never fails solely because the package data is missing. """ if _HAS_IMPORTLIB_RESOURCES: try: data = _pkg_files("code_review_graph").joinpath( "assets/report-template.html" ).read_text(encoding="utf-8") if data: return data except (FileNotFoundError, OSError): pass return _FALLBACK_TEMPLATE # Minimal self-contained template (used if the asset file is unavailable). _FALLBACK_TEMPLATE = """ Code Review Report
""" def generate_report_func( review_data: dict[str, Any], output_path: str | None = None, repo_root: str | None = None, format: str = "both", ) -> dict[str, Any]: """Generate code review reports (HTML and/or Markdown). Renders ``review_data`` (the output of ``score_review_tool`` plus ``dedupe_findings_tool`` results and free-form verdict/tier/scope) into a self-contained HTML report (``report-template.html``) and/or a standalone Chinese Markdown report. Both are written by default. ``output_path`` is treated as the basename/directory of the output: the extension is decided by ``format``, so ``both`` writes ``.html`` and ``.md``. Args: review_data: Review data dict (metrics, findings, verdict, tier, scope, files, baseline, timestamp, manual_review). output_path: Output base path (without an extension). Defaults to ``/code-review-report``. repo_root: Repository root. Auto-detected if omitted. format: Output format. ``html``, ``markdown``, or ``both`` (default: both). """ if format not in ("html", "markdown", "both"): return _error_response( f"Invalid format {format!r}; expected 'html', 'markdown' or 'both'." ) store, root = _get_store(repo_root) try: data = build_report_data(review_data) data["summary"] = review_data.get("summary", "") if output_path: base = Path(output_path) if not base.is_absolute(): base = root / base else: base = root / "code-review-report" written: list[dict[str, Any]] = [] if format in ("html", "both"): template = _load_report_template() # Escape "<" as "\u003c" so finding text can never close the # surrounding " 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") written.append({ "format": "html", "output_path": str(html_path), "size_bytes": len(rendered), }) if format in ("markdown", "both"): rendered_md = render_markdown_report(review_data) md_path = base.with_suffix(".md") md_path.parent.mkdir(parents=True, exist_ok=True) md_path.write_text(rendered_md, encoding="utf-8") written.append({ "format": "markdown", "output_path": str(md_path), "size_bytes": len(rendered_md), }) paths = [w["output_path"] for w in written] return { "status": "ok", "summary": f"Report written to {', '.join(paths)}", "output_path": paths[0] if len(paths) == 1 else paths, "files": written, "report_size_bytes": sum(w["size_bytes"] for w in written), } except Exception as exc: return _error_response(str(exc)) finally: store.close()