chore: sync local changes, add Chinese docs and opencode config
This commit is contained in:
+315
-47
@@ -14,10 +14,13 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
from . import incremental as _incremental
|
||||
from .graph import GraphStore
|
||||
@@ -34,8 +37,11 @@ from .prompts import (
|
||||
from .tools import (
|
||||
apply_refactor_func,
|
||||
build_or_update_graph,
|
||||
community_health_func,
|
||||
coverage_func,
|
||||
cross_repo_search_func,
|
||||
dedupe_findings_func,
|
||||
deep_read_plan_func,
|
||||
detect_changes_func,
|
||||
embed_graph,
|
||||
find_large_functions,
|
||||
@@ -62,6 +68,7 @@ from .tools import (
|
||||
query_graph,
|
||||
refactor_func,
|
||||
run_postprocess,
|
||||
save_coverage_index_func,
|
||||
score_review_func,
|
||||
semantic_search_nodes,
|
||||
traverse_graph_func,
|
||||
@@ -91,6 +98,115 @@ def _resolve_repo_root(repo_root: Optional[str]) -> Optional[str]:
|
||||
return repo_root if repo_root else _default_repo_root
|
||||
|
||||
|
||||
class _ProgressSink:
|
||||
"""Thread-safe progress channel from a worker thread back to the event loop.
|
||||
|
||||
The worker thread (``asyncio.to_thread``) calls the ``progress_cb`` that
|
||||
the engine functions accept; each call stores the latest ``(fraction,
|
||||
message)`` under a lock. The event-loop coroutine reads it via
|
||||
:meth:`snapshot` on every heartbeat so ``Context.report_progress`` runs in
|
||||
the MCP request context (where the contextvar / progress token lives) —
|
||||
never from the worker thread.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._fraction = 0.0
|
||||
self._message: Optional[str] = None
|
||||
|
||||
def progress_cb(self, fraction: float, message: Optional[str]) -> None:
|
||||
with self._lock:
|
||||
self._fraction = fraction
|
||||
self._message = message
|
||||
|
||||
def snapshot(self) -> tuple[float, Optional[str]]:
|
||||
with self._lock:
|
||||
return self._fraction, self._message
|
||||
|
||||
|
||||
#: Default interval (seconds) between MCP progress notifications. Must stay well
|
||||
#: below the MCP SDK's 60s request timeout so opencode's
|
||||
#: ``resetTimeoutOnProgress`` keeps the request alive on large repos.
|
||||
_PROGRESS_HEARTBEAT = 15.0
|
||||
|
||||
|
||||
async def _run_with_progress(
|
||||
ctx: Context,
|
||||
fn: Callable[..., Any],
|
||||
*args: Any,
|
||||
heartbeat: float = _PROGRESS_HEARTBEAT,
|
||||
tool_timeout: int = 0,
|
||||
provenance_root: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""Run a blocking tool body in a thread while keeping the MCP client alive.
|
||||
|
||||
The MCP SDK default request timeout is 60s; opencode resets it whenever a
|
||||
``notifications/progress`` arrives (``resetTimeoutOnProgress``). This
|
||||
helper runs ``fn`` via ``asyncio.to_thread`` and, in the event-loop
|
||||
coroutine (which is inside the MCP request context, so ``request_ctx`` /
|
||||
``progressToken`` are available), periodically calls
|
||||
``ctx.report_progress``. If ``fn``'s engine accepts a ``progress_cb``, a
|
||||
thread-safe sink relays real progress; otherwise a heartbeat keeps the
|
||||
connection alive regardless.
|
||||
|
||||
``provenance_root``, when given, wraps the result with
|
||||
:func:`with_provenance` (called from the worker thread).
|
||||
|
||||
``tool_timeout`` (seconds, ``CRG_TOOL_TIMEOUT``; 0 = disabled) is a
|
||||
server-side backstop that returns a readable error dict instead of letting
|
||||
the client time out.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
sink = _ProgressSink()
|
||||
|
||||
# Bridge: if the target accepts progress_cb, wire the thread-safe sink in.
|
||||
try:
|
||||
import inspect as _inspect
|
||||
|
||||
accepts_cb = "progress_cb" in _inspect.signature(fn).parameters
|
||||
except (TypeError, ValueError):
|
||||
accepts_cb = False
|
||||
|
||||
if accepts_cb:
|
||||
kwargs["progress_cb"] = sink.progress_cb
|
||||
|
||||
def _worker() -> Any:
|
||||
result = fn(*args, **kwargs)
|
||||
result = with_provenance(result, provenance_root)
|
||||
return result
|
||||
|
||||
async def _run() -> Any:
|
||||
task = asyncio.create_task(asyncio.to_thread(_worker))
|
||||
try:
|
||||
while not task.done():
|
||||
if ctx is not None:
|
||||
fraction, message = sink.snapshot()
|
||||
await ctx.report_progress(fraction, 1, message or "processing...")
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=heartbeat)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
return task.result()
|
||||
except Exception:
|
||||
task.cancel()
|
||||
raise
|
||||
|
||||
if tool_timeout > 0:
|
||||
try:
|
||||
return await asyncio.wait_for(_run(), timeout=tool_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
message = (
|
||||
f"tool timed out after {tool_timeout}s (CRG_TOOL_TIMEOUT). "
|
||||
"Increase CRG_TOOL_TIMEOUT or reduce the review scope."
|
||||
)
|
||||
error_response = {"status": "error", "error": message, "summary": message}
|
||||
if provenance_root is not None:
|
||||
return await asyncio.to_thread(with_provenance, error_response, provenance_root)
|
||||
return error_response
|
||||
return await _run()
|
||||
|
||||
|
||||
mcp = FastMCP(
|
||||
"code-review-graph",
|
||||
instructions=(
|
||||
@@ -642,6 +758,7 @@ async def detect_changes_tool(
|
||||
max_depth: int = 2,
|
||||
repo_root: Optional[str] = None,
|
||||
detail_level: str = "standard",
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Detect changes and produce risk-scored, priority-ordered review guidance.
|
||||
|
||||
@@ -649,9 +766,9 @@ async def detect_changes_tool(
|
||||
flows, communities, and test coverage gaps. Returns risk scores and
|
||||
prioritized review items. Replaces get_review_context for change-aware reviews.
|
||||
|
||||
Offloaded to a thread via ``asyncio.to_thread`` — runs `git diff`
|
||||
subprocesses and BFS traversals that can take several seconds on
|
||||
large repos. See: #46, #136.
|
||||
Runs in a worker thread while the event loop reports progress
|
||||
notifications (keeps MCP clients such as opencode from timing out on
|
||||
large repos).
|
||||
|
||||
Args:
|
||||
base: Git ref to diff against. Default: HEAD~1.
|
||||
@@ -663,32 +780,14 @@ async def detect_changes_tool(
|
||||
token-efficient summary. Default: standard.
|
||||
"""
|
||||
root = _resolve_repo_root(repo_root)
|
||||
|
||||
def _run() -> dict:
|
||||
return with_provenance(detect_changes_func(
|
||||
base=base, changed_files=changed_files,
|
||||
include_source=include_source, max_depth=max_depth,
|
||||
repo_root=root, detail_level=detail_level,
|
||||
), root)
|
||||
|
||||
coro = asyncio.to_thread(_run)
|
||||
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
||||
if tool_timeout > 0:
|
||||
try:
|
||||
return await asyncio.wait_for(coro, timeout=tool_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
message = (
|
||||
f"detect_changes_tool timed out after {tool_timeout}s. "
|
||||
"Reduce scope with CRG_MAX_CHANGED_FUNCS / CRG_MAX_TRANSITIVE_FRONTIER, "
|
||||
"or increase CRG_TOOL_TIMEOUT."
|
||||
)
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"error": message,
|
||||
"summary": message,
|
||||
}
|
||||
return await asyncio.to_thread(with_provenance, error_response, root)
|
||||
return await coro
|
||||
return await _run_with_progress(
|
||||
ctx, detect_changes_func,
|
||||
base=base, changed_files=changed_files,
|
||||
include_source=include_source, max_depth=max_depth,
|
||||
repo_root=root, detail_level=detail_level,
|
||||
tool_timeout=tool_timeout, provenance_root=root,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -699,17 +798,19 @@ async def score_review_tool(
|
||||
repo_root: Optional[str] = None,
|
||||
detail_level: str = "standard",
|
||||
all_files: bool = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""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) that back the unified-review scoring. LLM-judged
|
||||
metrics (requirement coverage, logic alignment, trust boundaries) are
|
||||
reported in ``llm_judged`` for the calling agent to fill in.
|
||||
vulnerability) that back the unified-review scoring. These five
|
||||
objective metrics are the full report metric set; ``llm_judged`` is
|
||||
returned empty for backward compatibility.
|
||||
|
||||
Offloaded to a thread via ``asyncio.to_thread`` — runs `git log`
|
||||
subprocesses and graph queries that can take several seconds.
|
||||
Runs in a worker thread while the event loop reports progress
|
||||
notifications (keeps MCP clients such as opencode from timing out on
|
||||
large repos).
|
||||
|
||||
Args:
|
||||
changed_files: Files to score (auto-detected from git diff if
|
||||
@@ -724,15 +825,14 @@ async def score_review_tool(
|
||||
whole-project reviews (default: False).
|
||||
"""
|
||||
root = _resolve_repo_root(repo_root)
|
||||
|
||||
def _run() -> dict:
|
||||
return with_provenance(score_review_func(
|
||||
changed_files=changed_files, base=base,
|
||||
include_churn=include_churn, repo_root=root,
|
||||
detail_level=detail_level, all_files=all_files,
|
||||
), root)
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
||||
return await _run_with_progress(
|
||||
ctx, score_review_func,
|
||||
changed_files=changed_files, base=base,
|
||||
include_churn=include_churn, repo_root=root,
|
||||
detail_level=detail_level, all_files=all_files,
|
||||
tool_timeout=tool_timeout, provenance_root=root,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -763,6 +863,175 @@ def dedupe_findings_tool(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def coverage_tool(
|
||||
deep_read_files: list,
|
||||
all_files: bool = True,
|
||||
include_churn: bool = True,
|
||||
gate: str = "high_risk",
|
||||
include_prior: bool = False,
|
||||
file_read_ranges: Optional[dict] = None,
|
||||
file_semantic_units: Optional[dict] = None,
|
||||
repo_root: Optional[str] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Compute file-count review coverage for deep-read files.
|
||||
|
||||
Coverage = number of deep-read files / number of all source files.
|
||||
Per-file risk weight (worst metric grade + graph topology hits +
|
||||
normalised git churn) still ranks the priority deep-read list so the
|
||||
highest-risk files are read first. Backs the project-review Step 7.5
|
||||
coverage self-check (G3).
|
||||
|
||||
Runs in a worker thread while the event loop reports progress
|
||||
notifications (keeps MCP clients such as opencode from timing out on
|
||||
large repos).
|
||||
|
||||
Args:
|
||||
deep_read_files: Files the agent actually deep-read during the
|
||||
review (relative or absolute paths). Required.
|
||||
all_files: When True (default), the denominator is every source
|
||||
file in the graph (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 + line/unit three-piece gate) /
|
||||
``"line+unit"`` (line + unit only, feature reviews; file-count
|
||||
/ high-risk coverage skipped and returned as ``None``).
|
||||
include_prior: When True, merge the cross-round coverage index
|
||||
(files whose SHA is unchanged) 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. REQUIRED for
|
||||
gate="both+line"/"line+unit" — without it those files are
|
||||
FAIL-CLOSED as line gaps (line coverage 0%).
|
||||
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
|
||||
reported semantic units per deep-read file (same gates).
|
||||
repo_root: Repository root path. Auto-detected if omitted.
|
||||
|
||||
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.
|
||||
"""
|
||||
root = _resolve_repo_root(repo_root)
|
||||
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
||||
return await _run_with_progress(
|
||||
ctx, coverage_func,
|
||||
deep_read_files=deep_read_files, all_files=all_files,
|
||||
include_churn=include_churn, gate=gate,
|
||||
include_prior=include_prior,
|
||||
file_read_ranges=file_read_ranges or {},
|
||||
file_semantic_units=file_semantic_units or {},
|
||||
repo_root=root,
|
||||
tool_timeout=tool_timeout, provenance_root=root,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def deep_read_plan_tool(
|
||||
repo_root: Optional[str] = None,
|
||||
target_coverage: float = 85.0,
|
||||
batch_size: int = 40,
|
||||
include_churn: bool = True,
|
||||
include_prior: bool = False,
|
||||
deep_read_files: Optional[list] = None,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""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. With ``include_prior``, files
|
||||
already deep-read in earlier rounds (coverage index, SHA unchanged)
|
||||
are excluded so incremental reviews only re-read what actually
|
||||
changed or is new.
|
||||
|
||||
Runs in a worker thread while the event loop reports progress
|
||||
notifications (keeps MCP clients such as opencode from timing out on
|
||||
large repos).
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path. 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 from the plan.
|
||||
deep_read_files: Files already deep-read this round.
|
||||
|
||||
Returns:
|
||||
Dict with current_coverage_pct, current/target/remaining file
|
||||
counts, planned_files (priority-ordered), directory groups and
|
||||
estimated_batches.
|
||||
"""
|
||||
root = _resolve_repo_root(repo_root)
|
||||
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
||||
return await _run_with_progress(
|
||||
ctx, deep_read_plan_func,
|
||||
repo_root=root, target_coverage=target_coverage,
|
||||
batch_size=batch_size, include_churn=include_churn,
|
||||
include_prior=include_prior, deep_read_files=deep_read_files,
|
||||
tool_timeout=tool_timeout, provenance_root=root,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def save_coverage_index_tool(
|
||||
deep_read_files: list,
|
||||
file_read_ranges: Optional[dict] = None,
|
||||
file_semantic_units: Optional[dict] = None,
|
||||
repo_root: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""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. Call at the end of every
|
||||
review round (after the report is generated).
|
||||
|
||||
Args:
|
||||
deep_read_files: Files deep-read this round (relative or absolute).
|
||||
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
|
||||
persist so a later review can reuse line coverage of
|
||||
SHA-unchanged files.
|
||||
file_semantic_units: Optional {rel_path: [{range,kind,name},...]}
|
||||
semantic units to persist likewise.
|
||||
repo_root: Repository root path. Auto-detected if omitted.
|
||||
|
||||
Returns:
|
||||
Dict with index_path, entry count and head_sha.
|
||||
"""
|
||||
return save_coverage_index_func(
|
||||
deep_read_files=deep_read_files,
|
||||
file_read_ranges=file_read_ranges or {},
|
||||
file_semantic_units=file_semantic_units or {},
|
||||
repo_root=repo_root,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def community_health_tool(
|
||||
repo_root: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""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 path. Auto-detected if omitted.
|
||||
|
||||
Returns:
|
||||
Dict with total_nodes, attributed_nodes, non_file_nodes,
|
||||
attribution_pct, needs_postprocess and note.
|
||||
"""
|
||||
return community_health_func(repo_root=repo_root)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_report_tool(
|
||||
review_data: dict,
|
||||
@@ -1135,18 +1404,17 @@ def pre_merge_check(base: str = "HEAD~1") -> list[dict]:
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def unified_review(base: str = "HEAD~1", tier: str = "standard") -> list[dict]:
|
||||
def unified_review(base: str = "HEAD~1") -> list[dict]:
|
||||
"""Three-layer unified review (CRG graph context + scoring + dedupe + report).
|
||||
|
||||
Fuses graph context with the objective scoring metrics, finding merge,
|
||||
and the standalone HTML report. READ-ONLY: every finding waits for a
|
||||
manual fix decision.
|
||||
manual fix decision. Runs at the fixed standard tier (all layers).
|
||||
|
||||
Args:
|
||||
base: Git ref to diff against. Default: HEAD~1.
|
||||
tier: Review tier (fast / standard / strict). Default: standard.
|
||||
"""
|
||||
return unified_review_prompt(base=base, tier=tier)
|
||||
return unified_review_prompt(base=base)
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
|
||||
Reference in New Issue
Block a user