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

This commit is contained in:
AuraK Developer
2026-08-31 11:37:03 +08:00
parent 307d2fd471
commit ecc55158c1
81 changed files with 7645 additions and 144 deletions
+315 -47
View File
@@ -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()